Skip to content

M7-05 Retention per class

No Date Ruling Reason
D-M7-05-1 2026-10-24 Participant port (design note 1): internal/kernel/retention holds Participant{Class, Tables, Purge(ctx, cutoff, batch) (deleted, more, err)}, the class list of 20 4.1, a Registry that takes each participant with its period (zero means no automatic deletion, the job skips it) and refuses a table with two participants, Run (participants in registration order, called again while more, bounded by a time budget) and the job arguments of the five kinds retention.<class>. platform/jobs accepts the prefix retention. for argument types of exactly this package; every other kind keeps the prefix of its module. Each module registers its participants in its own module_<name>.go. Owner rule 2: the job never runs SQL on a module's table. The job kinds of 02 12.3 are named by class, not by module, so one port package carries them. A registration with its own period lets the audit class run two periods (retention.audit, privacy.audit_ip_key_retention).
D-M7-05-2 2026-10-24 End time per table (design note 2): refresh_tokens.expires_at; refresh_token_families.revoked_at; auth_sessions and api_tokens the earlier of expires_at and revoked_at; device_keys, devices, passkey_credentials revoked_at; enrollment_codes the earlier of expires_at and used_at; server_signing_keys.not_after of a retired key (no retirement time is stored; it follows not_after by one verification window), never purpose audit_checkpoint; push_registrations.disabled_at; terminal_sessions.closed_at; host_session_users and hosts hosts.retired_at; webhook_deliveries delivered_at, for a failed or dead delivery created_at (the time of its last attempt is not stored), never pending; cost_entries.occurred_at; price_table.valid_to; messages.created_at (not deleted, not a system line); audit_events.occurred_at of a prefix from the first retained row; audit_ip_keys.valid_until. A row is removed when its end time lies strictly before now minus the period. A row whose delete a rule of another row refuses (SQLSTATE class 23: a foreign key with ON DELETE RESTRICT or NO ACTION, or a check of a referencing row that ON DELETE SET NULL would break, such as a completed device pairing that must name its device) is skipped in its own savepoint (pg.PurgeEach) and stays until that row is gone; a batch that removed nothing reports no more. 20 4.1 "after expiry, revocation or end"; a participant may not read another module's table to find references, the database already knows them. The keys of audit_checkpoint must stay because the sink keeps every checkpoint and audit.verify checks each with the key of its kid.
D-M7-05-3 2026-10-24 Coverage (design note 3): TestEveryRetentionTableHasAParticipant reads the Retention: line of every table of 03 and covers the classes that end by time (security, content, transient, audit, cost, privacy_job, ops, import, stats, event_log): each table is named by exactly one participant of its class, or listed once as handled by a job of the catalogue of 02 (event_log, idempotency_keys, auth_challenges, auth_login_flows, device_pairings, stats_minutes, file_upload_sessions, file_upload_parts), by its module outside a job (gateway_presence, R-DM-51), by a later ticket (gdpr_requests M7-06, the four status tables and backup_runs M7-09, import_sources and import_items M8, dev_environments V2), as going with a covered parent by the deletion path of 20 2 (message details and attachments with messages, thumbnails and extracts with files, components and interactions, thread tags, agent sessions and runs with threads, run steps and follow-ups with runs, DM participants with channels), or as carrying no end time (spaces, channels, threads under 03 principle 7, forum_tags, message_templates, local_credentials, agent_consumer_cursors). A listed job must be a row of the catalogue. The brief's list of later owners and existing jobs, plus the two kinds the data model itself fixes: rows that go with a parent and structure that is never deleted by time.
D-M7-05-4 2026-10-24 Audit trim (design note 4): the job opens a pool of bothy_maint (password migrate.maint_password_ref, one connection) for each trim and closes it afterwards; in one unit of work whose registry holds only the audit hook it takes the checkpoint lock, removes the prefix of rows that occurred before the cutoff, at most retention.batch_size, never the head and never the row of the newest checkpoint exported to audit.checkpoint_sink or a later row (without a sink nothing is exported, so nothing is trimmed), records the anchor entry (action audit.retention_trim, no actor, details anchor_seq and anchor_hash, numbered and chained by the hook) and deletes the checkpoints up to the anchor. Without a checkpoint nothing is trimmed. The migration grants bothy_maint SELECT, INSERT, DELETE on audit_events, SELECT, DELETE on audit_checkpoints, SELECT and UPDATE (value) on sequence_counters, nothing else. audit.verify, VerifyRows (the restore test, which now reads the newest anchor of its copy) and the walks start at the first retained row with the anchor's hash when the newest anchor names the row before it; a checkpoint below the anchor is not compared, one at the anchor must equal it, audit.verify requires every checkpoint of the sink below the anchor to be older than now minus retention.audit and the newest one to lie after the anchor (else checkpoint_mismatch); after a trim the first checkpoint left in audit_checkpoints may link a removed one. audit.retention_trim is not an entry of the action catalogue, like the audit codes of other system steps (D-M7-04-2). 03 audit_events and 16 7.1: the maintenance role trims with an anchor. Keeping the newest checkpoint's row lets audit.chain_verify continue from its checkpoint, and trimming only covered rows means no row leaves before a checkpoint outside the database sealed it.
D-M7-05-8 2026-10-24 Review and red team round 1 (NEIN), fixed with a test each: a revoked device that a completed device pairing still names broke the run with SQLSTATE 23514 (the class fix: pg.PurgeEach skips every integrity violation, TestPurgeOnlyExpired of identity); a correctly chained anchor written by whoever holds bothy_maint hid an erased log from audit.verify (the newest sink checkpoint must lie after the anchor and every one set aside must be older than retention.audit, TestForgedAnchorIsDetected, TestTrimmedCheckpoints); a trim without a sink made audit.verify fail once a sink was configured (the trim stops at the newest exported checkpoint, TestTrimNeverWithoutExportedCheckpoint). Taken from the minors: Run goes on with the other participants of a class after one fails (TestRunContinuesAfterAFailure), retention.content has the minimum 1d and a reason of only spaces counts as empty (TestConfigCheck_DPT13), the integration test checks the registry the server builds and keeps files.purge_objects from running. A raise of retention.audit after a trim makes audit.verify report the checkpoints of the rows removed under the shorter period until they are old enough; the owner then sees that rows left under the earlier rule. Blockers and the reproduced Major of the round; the rest are minors listed in the report.
D-M7-05-9 2026-10-24 Re-review (Major, older than this branch): nothing deleted device_pairings, although 02 12.3 says identity.auth_cleanup does and the coverage test lists the table with that job; so every device that came in through a pairing would have stayed for ever. identity.auth_cleanup now deletes, in its first unit of work, the completed, rejected and expired pairings past expires_at (an open one is expired first and goes with the next run); the migration of this ticket grants bothy_app DELETE on device_pairings. Test TestDeleteClosedPairings. 03 device_pairings is class transient with auth.pairing_code_ttl; the coverage claim must be true.
D-M7-05-5 2026-10-24 New keys retention.job_interval (default 1h, at least 1m: the interval of every class job and the time budget of one run, so the River job has no timeout of its own) and retention.batch_size (default 500, 1 to 10000), in 20 13. retention.content is a string key with the cross-check retention_content (none or a duration of at least 1d). retention.security_after_end, retention.cost and retention.webhook_deliveries have the minimum 1d like retention.audit. Brief scope "Configuration"; 20 13 has no bounds, the minimum keeps a typo such as 1s from deleting fresh data.
D-M7-05-6 2026-10-24 Content (fixed requirement 13): the participant of messaging deletes each expired message in its own unit of work through the existing deletion of message.delete without authorization and without an actor: deleted_by stays NULL, message.deleted carries no deleted_by (optional in MessageDeletedPayload), and no audit entry is written, because the job runs no action of the catalogue. A message moved to another channel between the read and the deletion is skipped until the next run. Spaces, channels, threads, runs and run steps are not removed by time; spec gap below. 20 4.2 is the only content deletion path; 03 principle 7 forbids hard deletion of the structure.
D-M7-05-7 2026-10-24 Spec gap, not built: 20 2 names retention.content as the deletion path of threads, channels and spaces and "with the thread" for runs and run_steps, while 03 principle 7 keeps threads and channels for ever and no tombstone for a thread title, a channel name or the text of a run exists. With a period, those contents stay. The core contract is not edited by this ticket (P-11); the planning decides whether such tombstones are wanted.