ADR-058: Retention Enforcement — Transport Sweeps and Archive-Then-Prune

On this page

Status

Accepted (2026-07-26). MR-A (#965: the transport sweeps, this document’s §Transport class) is implemented; MR-B (#1129: the archive engine, §Audit class) implements against this decision and amends it with the as-built. Plan: Retention Enforcement (program; archived — MR-B)
#965 (transport mechanics; archived — MR !1062).

Context

Every churn table grew unbounded: event_outbox/event_inbox ×8 promised a 30-day retention (ADR-022 + migration comments) that no code enforced; audit_log, rule_evaluations (the single copy of every evaluation’s input/output since #1130), dead_letter_audit, exchange_send_jobs, and subsidy_reconcile_queue had no policy at all. CRAIG is a child-welfare system of record — fair hearings, child-fatality reviews, and federal audits can demand the trail years later, and the records-retention schedule is DFCS’s to name (#1073), not a code constant.

Decision

Two classes, resolved by the user-directed classification of 2026-07-26 (recorded on #965 + #1129):

Transport class — operational delivery/dedup artifacts, hard-pruned

event_outbox, event_inbox, exchange_send_jobs, settled subsidy_reconcile_queue rows. These are NOT records of the system of truth — the domain facts live in domain tables and the audit trail.

Table Prune predicate Window

event_outbox

published_at IS NOT NULL AND created_at < cutoffpublished only: a pending row is the sole evidence of an undelivered notification (no outbox retry cap/DLQ exists) and is never age-deleted; outbox_unpublished_beyond_grace is its operator signal. Published-only also never touches a live claim (claims are only taken on unpublished rows).

30 d

event_inbox

received_at < cutoff — state-blind: the dedup horizon is purely time-based

31 d

exchange_send_jobs

status IN ('sent','failed') AND completed_at < cutoff — terminal only

outbox window

subsidy_reconcile_queue

processed_at IS NOT NULL AND processed_at < cutoff — settled only; the dead-letter operator queue (processed_at IS NULL AND attempts >= 10) is structurally outside the predicate

outbox window

The window invariant (supersedes ADR-022’s "matching" wording): the two tables are pruned asynchronously by different service databases, so equal windows fail at the boundary — a replayed outbox row can arrive after its consumer dedup record was pruned and re-execute side effects. The invariant is inbox ≥ outbox + (max prune cadence
clock skew)
; the shipped 30/31 split’s 1-day margin dominates both, and ServiceSettings::load() rejects inbox < outbox + 1. Window knobs (CRAIG_<SVC>__EVENT_{OUTBOX,INBOX}_RETENTION_DAYS; 0 disables that table’s sweep) move fleet-wide only — one service raising its outbox window above a peer’s inbox window re-opens replay-past-dedup.

Mechanics (craig-mq::retention): hourly + service-name-jittered prune inside every OutboxWorker, AFTER the drain; outbox-before-inbox (a partial failure leaves the dedup-safe direction); batched id-keyed deletes (5,000/batch, yield + cancellation between batches); a 1,000,000-row cycle cap that stops-and-warns instead of trusting a possibly-jumped clock; single-pruner-per-DB via pg_try_advisory_lock on a DETACHED connection whose drop closes the socket (an async unlock cannot run in Drop; a pooled lock strands on an idle connection and retention silently self-disables — the subsidy-import-finalize detached-lease precedent). The two service-local sweeps reuse the same loop with their own lock keys.

The replay boundary is mechanical: POST /v1/admin/events/replay refuses from older than now() - outbox_window with a typed 400 naming the boundary. The replay window IS the retention window; application-layer idempotency must hold across it (ADR-022).

Monitoring split: the four *_retention_overrun invariants are devstack/CI drift tools (report-only, thresholds = default windows + 48 h grace). Production observability is the event_rows_pruned_total{table} counter family — deleting audit-relevant rows silently is not acceptable.

Audit class — records surfaces, archive-then-prune (MR-B implements)

audit_log, dead_letter_audit (craig-security), rule_evaluations (craig-rules). Never hard-deleted while unarchived. The accepted design (MR-B; the program plan §B1–B5 carries the unit detail):

  • Hot window default 90 d (an OPERATIONAL knob — ⁂ #1073); consent-gated retention_archive.enabled = false default, enforced at EVERY entry (scheduled worker AND POST /archive/run).

  • One batch = one crash-atomic unit: select oldest rows past the window → NDJSON
    sha256 → conditional no-overwrite puts (Store::put_create) of data THEN manifest under retention-archives/{service}/{table}/… → ONE transaction: id-keyed DELETE (count-mismatch rolls back) + the service’s LOCAL ledger row (+ staged event) → commit. Crash between put and commit re-archives under a new id; orphan objects are harmless and GC’d against the LOCAL ledger only.

  • Each archiving service owns a durable local ledger written in the prune tx (security: archive_records; rules: archive_ledger + the rules.evaluations_archived event as validated, idempotent fleet bookkeeping).

  • Purge refuses everything until DFCS names a schedule: every destructive statement independently requires retention_until IS NOT NULL AND retention_until ⇐ CURRENT_DATE AND NOT legal_hold (2 CFR § 200.334’s three-year floor runs from an expenditure-report anchor CRAIG cannot derive — indefinite hold is the only safe default).

  • Preconditions recorded: archive-then-prune requires INSERT-ONLY sources (all three tables verified; onboarding a mutable table needs a new decision); the object store must be S3-compatible with versioning/object-lock, least-privilege credentials, and an encrypted bucket (Local is refused at boot); evaluation_id/audit addressability survives via ledger id-ranges + xtask archive-fetch.

As-built amendments (MR-B, 2026-07-26)

  • The manifest/ledger id range is [min, max], not positional: batches page by the AGE column, and under write concurrency id order can diverge from age order within a batch — a positional range could invert and silently match nothing. D17 lookups scan CANDIDATE ranges (normally exactly one; overlap possible at batch boundaries, inversion impossible).

  • /archive/purge is consent-gated too (D11 extends to purge: an unconsented deployment cannot verify its store), slice-bounded (?limit=, 1..=100, more = full-selection heuristic), and scoped to source_service = 'craig-security' — the craig-rules rows in archive_records are fleet BOOKKEEPING copies whose objects rules' own archive_ledger governs.

  • The D14 predicate is re-checked immediately BEFORE the object deletes (they are destructive statements too); a stamp refused after deletes escalates to error! with bucket versioning as the named recovery.

  • D15 rides per-invocation engines: manual /run = the operator’s claims.sub; scheduled = system:retention. A security.archive_purged event carries the purge actor.

  • The rules.evaluations_archived contract REQUIRES the D9 store identity (store_endpoint/store_bucket non-empty); refused envelopes WARN-and-skip (no poison loop — the generic audit row still records the attempt); identical replays no-op on the id conflict; conflicting replays never overwrite and alarm (retention_bookkeep_divergence_total).

  • The D9 Local-backend refusal lives in boot_probe (service boot wiring), deliberately NOT in engine construction, keeping the engine hermetically testable.

  • The seed’s audit rows are dated within 35 days of the as-of month (frozen seed data ages against wall-clock, so no deterministic seed stays green under a 97-day watchdog forever — a fresh devstack gets a ≥62-day green shelf life, after which red IS the staleness signal to reseed).

Amendment — #1168: the archiver’s I/O bounds are object_store’s defaults (2026-07-27)

The engine’s object I/O carries NO explicit timeouts — its puts/deletes are bare awaits and craig-store sets no ClientOptions/RetryConfig, so the load-bearing bounds are object_store’s own defaults, verified against the vendored 0.13.2 source: 30 s per request, 5 s connect (`ClientOptions::default, client/mod.rs), 10 retries under a 180 s retry budget (RetryConfig::default, client/retry.rs). This is what bounds "stalled upload delays shutdown" to ≈ the 180 s retry budget rather than forever (the audit F33 claim, refuted by these defaults); the timeout-less Local backend is already refused at boot (D9). Maintenance rule: an object_store upgrade must re-verify both Default impls — if upstream loosens them, pin explicit ClientOptions/RetryConfig in craig-store rather than silently inheriting the change. The related session-budget consequence of the engine’s detached advisory-lease connection (outside pool accounting) is recorded once, in the deployment guide § Database connection budget (#1160 — not duplicated here per #1168).

Amendment — #1293: the no-overwrite guarantee is backend-independent (2026-08-03)

Store::put_create’s `If-None-Match: * is honored by AWS S3 / MinIO / R2 but PERMANENTLY ignored by Garage (CRDT design, no consensus — upstream documents it as a known limitation, not roadmap; the committed canary crates/craig-store/tests/garage_put_create.rs pins the observed overwrite). Since the devstack backend IS Garage, the conditional put alone cannot carry this ADR’s "archive objects are immutable once written". The archiver’s object writes now go through craig_retention::persist_one_object — the §U digest-verified create (craig_store::store_blob_create): precheck the key, REFUSE a foreign occupant with the typed ArchiveError::ForeignObject BEFORE any write (the batch fails; the prune is structurally downstream of the write returning Ok, so nothing hot is ever deleted against an unverified archive), converge on an identical occupant with a warn! (anomalous — archive keys are minted under fresh UUIDv7 `archive_id`s), and re-verify on a conditional-put refusal from honoring backends. The advisory lease already excludes concurrent leased writers, so the precheck’s read-then-write window only faces the misdirected-writer class the refusal exists to catch. On honoring backends the native conditional put remains the first line; the digest verify is the guarantee everywhere else.

Amendment — #1466 (B5): mutation-quiescent-at-eligibility sources (2026-08-21)

The "archive-then-prune requires INSERT-ONLY sources" precondition above is restated: insert-only is the degenerate case (a row is at its fixpoint from birth). What the protocol actually requires is that NO DESIGNED WRITE PATH can touch a row once it is archive-ELIGIBLE — the select and the delete observe the row at different instants, and a mutation between them would be silently lost from the archive. A source qualifies under FOUR preconditions:

  1. every write is MONOTONE toward a terminal fixpoint (converging status transitions, never rewrites);

  2. the eligibility predicate admits ONLY fixpoint rows (so the MVCC-consistent to_jsonb snapshot is the row’s final form);

  3. every writer STRUCTURALLY refuses fixpoint rows (guarded/fenced UPDATEs that 0-row against terminal state); and

  4. delete_by_ids_sql RE-ASSERTS the fixpoint predicate, converting any status-lattice regression between select and delete into the existing PruneCountMismatch rollback — nothing pruned, the batch retries.

Detectability boundary, stated plainly: precondition 4 protects only columns the fixpoint predicate covers. A column that may legitimately change while the row is otherwise terminal — ssa_screening_runs.invalidated_at, which can stamp a COMPLETED run — is protected SOLELY by a writer-side in-WHERE refusal (the fork-2 freshness no-op: NOT (status = 'completed' AND as_of < CURRENT_DATE - make_interval(months ⇒ $n)), DB-side and monotone, never app-side check-then-write). Onboarding a source with such a column REQUIRES that refusal to exist and to be named.

Onboarded under this admission (#1466, ADR-065 § Amendment #1479 D8–D11): ssa_screening_runs (fixpoint = completed AND past the 12-month quiescence horizon; the SQL bound binds SCREENING_FRESHNESS_MONTHS as a parameter — never a second literal) and ssa_screening_members (fixpoint = the terminal statuses, CHECK-paired completed_at). Their archive SPECS + floors land with E3; the exchange service’s hot_window_days is load()-validated ≥ 365 d from B5 (per-ENGINE, not per-table — recorded limitation). Zero craig-retention engine changes: the spec.rs doc contract was rewritten in the same MR as this amendment (the J5 rule — the doc asserted insert-only while this admission is the new truth). Rejected: event-sourcing the runs tables (rewrites B3+B4) and a current+history split (quietly re-classifies runs/members to transport).

The specs + floors owed by the #1466 amendment landed: craig-exchange runs the shared engine over three FK-deletion-ordered specs (ssa_transport_handoffsssa_screening_membersssa_screening_runs; the two tables WITH dependents carry NOT EXISTS order guards in BOTH select and delete — handoffs, dependent-free, need none) against its own archive_ledger (the craig-rules shape; cargo xtask archive-fetch craig-exchange resolves it). Bookkeep is ledger-only — no fleet event (nothing downstream consumes SSA archival facts; recorded deviation from the rules shape). Two admission notes this amendment adds to the doctrine:

  • legal_hold (UD10, run granularity) is an ELIGIBILITY-PREDICATE column — every destructive statement selects on AND re-asserts NOT legal_hold, so precondition 4 converts a mid-sweep hold into the count-mismatch rollback. That makes a hold flag the D14 archive-records shape generalized to a HOT source table, and it is the distinction from `invalidated_at’s writer-side-refusal-only class: a mutable column is admissible without a named writer refusal exactly when it joins the eligibility predicate itself. The runs mutable-column allowlist is extended accordingly (probe-asserted).

  • The transport prune generalizes precondition 4 to a multi-statement transaction (ssa_retention.rs): handoff-snapshot INSERT + two deletes commit atomically. Because legal_hold flips BOTH ways and each statement takes its own READ COMMITTED snapshot, re-asserting the predicate alone is NOT sufficient — a hold set→clear flap straddling the statements would re-admit rows the insert skipped. The jobs delete therefore re-asserts the full predicate PLUS a snapshot-exists guard (EXISTS(handoff)), and the transactions delete the hold + type legs (its job legs are consumed by the preceding statement); short counts roll the whole batch back, and a mismatch-aborted pass withholds the heartbeat so persistence alarms as staleness. The UD4 ≤ 90 d transport window is a load()-validated CEILING (CRAIG_EXCHANGESSA_SCREENINGTRANSPORT_RETENTION_DAYS, 0 = visible disable, refused while the archiver is consented — a sweep-less archiver would idle forever behind the members spec’s NOT EXISTS(jobs) guard).

As-built detail: ADR-065 § Amendment #1566. Purge stays refused (D14, #1480); the exchange ledger carries no legal_hold column until purge tooling exists to honor it.

Amendment — #1556 (D2): ruleset history tables are keep-forever-hot (2026-08-21)

rule_set_snapshots + rule_set_promotion_previews (craig-rules) are audit-class by nature but deliberately NOT onboarded to the archive-then-prune tier: growth is human-admin-mutation-rate × KB-scale content (unlike per-evaluation rule_evaluations), pin resolution and dispute replay require hot presence, and both are insert-only — satisfying this ADR’s precondition if that call is ever revisited. The D14 posture (no destruction until DFCS names a schedule, #1073) applies regardless.

Consequences

  • The two hottest tables per service are bounded (~30–31 d) for the first time; the first post-deploy drain clears months of backlog batched and capped across cycles.

  • Admin replay is bounded by the retention window — five-year replays are structurally refused rather than runbook-forbidden.

  • Pending outbox rows now accumulate FOREVER by design until an outbox DLQ exists — the deliberate trade (evidence over tidiness); the beyond-grace invariant carries the operator signal. A retry-cap/DLQ follow-up remains filed per #965.

  • Audit-class tables keep growing until MR-B lands and a deployment consents to the archiver; the hot-window watchdogs stay green in devstack by construction until then.

Alternatives considered

  • Equal 30/30 windows (the old ADR-022 wording) — rejected: unsafe at the async-prune boundary (see the window invariant).

  • Deleting pending outbox rows at the window (the old migration-comment wording) — rejected: destroys the only evidence of undelivered cross-service notifications.

  • Plain prune for audit-class tables — rejected: makes DFCS’s retention decision in code; deletes are irreversible where archives preserve every future answer.

  • Partition-forever for audit-class tables — rejected: decides "keep everything in the expensive tier" while leaving the operational problem half-solved.

  • pg_cron / external schedulers — rejected: the repo’s four in-process worker precedents (idempotency, jws, detection, subsidy schedulers) already own this shape.

Edit this page · latest