M7-07: Deletion journal and restore¶
Status: ready | Depends on: M7-04, M7-06 | Milestone: M7
Goal¶
After this ticket a restore can no longer bring back deleted data (R-DP-04, SEC-BAK-3, T-BK-02). Every deletion of a message, a file or an account writes a journal entry {resource_kind, resource_id, deleted_at, reason} without content (20 4.6). The journal lives outside the database: a consumer of the event log in the worker writes the entries continuously into the object storage under their own prefix, where the object backup of M7-02 copies them off-site; entries are kept for privacy.deletion_journal_retention, which must be at least as long as the longest backup period, or the server does not start (DP-T08). bothy restore finalize applies the journal before the instance accepts requests again: every entry whose resource exists again in the restored state is deleted again with the same use case (message deletion of 20 4.2, file deletion of 20 4.3, the erase steps of 20 4.4 with the recorded content mode), and the permission version rises so that all caches rebuild (SEC-BAK-3). The manifest of every backup run names the journal position, and the runbook's step for deletion requests completed after the target time is covered by the same journal (backup-restore.md 7 item 2). SEC-T15, DP-T07 and DP-T08 are green. Why: a restore that undoes a GDPR erasure or brings back a deleted message is a privacy incident; the journal is the only record that survives the restore.
Read first¶
docs/spec/20-privacy.md: 4.2, 4.3, 4.4, 4.6 (all of it), 10 (DP-T07, DP-T08), 11 (R-DP-04), 13 (privacy.deletion_journal_retention).docs/spec/19-security.md: SEC-BAK-3, SEC-T15;docs/security/threat-model.md(T-BK-02).docs/spec/03-data-model.md(core contract, read only): 6.3 (permission version), 6.5 (deleting a message), 6.7,event_consumer_cursors;docs/spec/04-sync-protocol.md7.6 (consumers in the worker);docs/spec/actions.yamleventsmessage.deleted,file.updated(redacted form after a deletion, R-DM-45),user.updated.docs/spec/16-operations.md8.1 (backup periods), 8.2, 8.4;docs/operations/backup-restore.md3, 5.2 (step 6), 7 (item 2);docs/spec/12-files.md4.4 (object key schema).docs/DECISIONS.md: D-W-100 and its extension to M7 (D-W-125); section 10 (format ofD-M7-07-<n>rows).- Briefs (merged names win, D-W-100):
M7-04-restore-finalize-epoch.md(FinalizeRestore),M7-06-privacy-requests.md(erase steps, participants),M7-02-backup-service.md(manifest, object backup),M2-01-worker-event-consumers.md(consumer cursor pattern),M3-01-object-storage-fs.mdandM3-02-object-storage-s3.md(storage port),M3-08-file-cleanup.md. - Code on the base branch:
services/server/internal/privacy,internal/instance(FinalizeRestore),internal/files(storage port),internal/messaging,internal/authz(permission version),cmd/bothy.
This ticket adds no action and no event. It adds the migrations of the table deletion_journal_state (03 4.12, R-DM-52) and of the check of event_consumer_cursors.name with privacy.journal, both as 03 already defines them (R-DM-27, D-W-125).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Periods only through named keys; code comments and docs name the key, never the value.
- Specification first: a change to behaviour or configuration updates
docs/spec/20-privacy.md,docs/spec/16-operations.mdand the runbooks in the same change. The core contract is not edited by this ticket; the migrations follow 03 as it stands. Everything this ticket adds is English. - No cost (D-W-125).
Product requirements implemented here:
- A journal entry carries only
resource_kind(message,file,account), the resource ID,deleted_at,reason(a code, for account deletions with the content mode) and theseqof the event it came from; never content, names or emails. - Entries are written after commit by the worker consumer
privacy.journalfrom the durable events, inseqorder, as immutable objects (never overwritten); the consumer cursor advances only after the object is stored. A retry that finds an object with the same key and the same content counts as stored, so a crash between storing and moving the cursor does not stop the consumer. bothy restore finalizeapplies every journal entry whose resource exists again, before it sets the new epoch, in the same command; application is idempotent, uses the normal deletion use cases (so tombstones, redaction and purges follow), and raises the permission version once (SEC-BAK-3).- The start of
apiandworkerandbothy config checkfail whenprivacy.deletion_journal_retentionis shorter than the longest ofbackup.logical.retention,backup.objects.retentionand the pgBackRest retention expressed as a period (DP-T08; the rule for converting full backup counts to a period is a ruling). - Journal objects older than
privacy.deletion_journal_retentionare removed by the jobretention.privacy_jobof M7-06 or a job of the same class, fromdeletion_journal_state.low_numberupward, which then moveslow_number; it removes an object only when its number is also below thedeletion_journal.next_numberof the manifest of every backup that is still kept (03 4.12), so a restore of any kept backup finds its first object; nothing else deletes them.
Scope¶
Server module (paths under services/server/ unless they start with docs/; every new Go file starts with // SPDX-License-Identifier: Apache-2.0):
internal/privacy:domain(entry, object keydeletion-journal/<number zero-padded>with a dense number fromdeletion_journal_state, Design note 2, the retention comparison),app(consumerprivacy.journal,ApplyJournalfor restore),adapters(storage of entries through the object storage port offiles, reading them back in order).internal/instance:FinalizeRestorecallsprivacy.ApplyJournalbefore the epoch step; on an error nothing of the epoch step runs and the command exits1.internal/authz(through its port): one increase of the permission version after the journal is applied.cmd/bothy:restore finalizeprints the counts of applied entries per kind (no IDs);bothy config checkand the start check of DP-T08.status(manifest of M7-02): the referencedeletion_journal.next_number(the number after the newest stored object) next to the referencespgbackrest.labelandrestic.snapshot_idofbackup-restore.md3; 03 names the required content fields ofbackup_manifest.v1and stays unchanged.migrations: the tabledeletion_journal_statewith its one row and the cursor check withprivacy.journal(03 4.12, 4.10), withmake migrations-lintandmake migrations-lockgreen.- Configuration:
privacy.deletion_journal_retentionbecomes active. - Docs:
docs/spec/20-privacy.md4.6 (where and how),docs/spec/16-operations.md8.2 and 8.4,docs/operations/backup-restore.md3, 5.2 and 7,docs/operations/disaster-recovery.md,docs/security/threat-model.md(T-BK-02 test reference), READMEs,services/server/CHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M7-07-<n>.
Out of scope¶
- Ordinary deletions after the target time that the instance operator wants to keep deleted beyond messages, files and accounts (20 lists exactly these three kinds).
- Data outside the server (20 4.7).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M7-07-deletion-journal; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(privacy): ...,feat(instance): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag, never publish an image or a release, never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules). Tests bind only
127.0.0.1and port 0; PostgreSQL and SeaweedFS only as throwaway containers on random ports (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes and containers you started, by PID or container ID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. Logs carry no resource ID of a deleted message or account.
- Time only through
kernel/clock. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M7 by D-W-125). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Source of entries (20 4.6; Ruling for docs/DECISIONS.md). The consumer reads
message.deleted,file.updatedwhose state is deleted, anduser.updatedwhose status isdeleted; the account entry'sreasoncarries the content mode the erase job used (read from the request through theprivacytables, which the consumer owns). The cleanup of finished requests (retention.privacy_job, M7-06) keeps a request until theprivacy.journalcursor has passed theuser.updatedevent of its account deletion, so a lagging consumer still finds the mode; a test delays the consumer while the cleanup runs. Writing from the log after commit means a crash between commit and storage loses nothing: the cursor did not move. The window between commit and storage is bounded by the consumer lag and named in the runbook. -
Storage (12 4.1, 4.4; R-DM-52; Ruling). Objects under a prefix that the file key schema never produces, with keys of lowercase letters, digits,
/and-only (domain.ValidateKeyoffiles). The object storage port has no listing and no conditional write (12 4.1, core S3), so entries carry dense numbers fromdeletion_journal_state: in its round the consumer writes the objectdeletion-journal/<next_number zero-padded>after aHeadthat finds no object (where one exists, aGetcompares the content: the same content counts as stored, a different one is an error), then movesnext_numberand its cursor in one transaction; the row lock ofevent_consumer_cursorskeeps it to one writer.ApplyJournalreads from the restorednext_number(every lower number is a deletion already contained in the restored state, and DP-T08 keeps that range) up to the first missing number and, in the transaction that sets the new sync epoch (M7-04), stores the number after the last object found, so the consumer never writes an existing key after a restore. The object backup of M7-02 copies the objects like files. -
Apply on restore (SEC-BAK-3). For each entry in number order (sequence numbers can repeat across timelines after a restore): message exists and is not deleted: run the deletion use case with the system actor; file: the same with the file deletion; account: if the principal is not
deleted, run the erase steps of M7-06 with the recorded mode. Entries whose resource does not exist or is already deleted are skipped. The command reports counts only. -
Retention comparison (DP-T08; Ruling). The pgBackRest retention is a count of full backups; convert it to a period with the full backup schedule (count times the interval of
backup.pg.full_schedule, rounded up to whole days) and compare the longest of the three periods. -
Rulings for
docs/DECISIONS.md(D-M7-07-<n>): event sources (note 1), object prefix, dense numbering and the write check (note 2), apply order (note 3), period conversion (note 4), the manifest reference.
Tests (write them first)¶
Unit:
privacy/domain:TestEntryHasNoContent(the JSON of an entry has exactly the five fields),TestRetentionComparison(DP-T08 cases with each of the three backup periods the longest).
Integration (pgtest and the storage test containers):
TestJournalFromEvents: deleting a message, a file and an account writes one entry each, inseqorder; a storage failure leaves the cursor and retries; an entry that was stored before a crash and before the cursor moved is found again and counts as stored; the migration accepts the cursor rowprivacy.journal.TestSECT15_RestoreKeepsDeleted(SEC-T15, DP-T07): seed, back up the database state (apg_dumpsnapshot is enough here), delete a message, a file and an account (both content modes in two runs), restore the snapshot, runrestore finalize: the message, the file and the account are deleted again, the permission version rose, and the epoch is new.TestApplyIsIdempotent: running the apply twice changes nothing the second time.TestNumberAfterRestore: afterrestore finalizethe next entry gets the number after the last object found, and no existing object is written again; after the retention removed the oldest objects,ApplyJournalstill applies every entry from the restorednext_numberon, and the retention keeps every object at or above thenext_numberof the oldest kept backup even when it is older than the retention.TestDPT08_StartAbort: a too shortprivacy.deletion_journal_retentionaborts the start and failsbothy config check.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/privacy/... ./internal/instance/... ./internal/messaging/... ./internal/files/... ./cmd/... ./internal/wire/...make migrations-lint,make migrations-lock,make arch,make register-check,make config-doc-check,make docs,make linkcheckmake check
Acceptance criteria¶
- SEC-T15, DP-T07 and DP-T08 are green.
- Every deletion of a message, file or account writes a content-free journal entry outside the database, after commit and in order.
-
restore finalizeapplies the journal before the epoch step and raises the permission version. - 20, 16, the runbooks, the threat model, READMEs, the CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
privacyandinstanceupdated in English. - Rulings of Design note 5 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- The bound of the window between commit and journal storage as measured in the test.
- What is not proven (a full PITR with the journal on a production-sized instance).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.