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 |
|---|---|---|
|
|
30 d |
|
|
31 d |
|
|
outbox window |
|
|
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; the shipped 30/31 split’s 1-day margin dominates both, and
clock skew)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 = falsedefault, enforced at EVERY entry (scheduled worker ANDPOST /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 underretention-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+ therules.evaluations_archivedevent 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/purgeis 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 tosource_service = 'craig-security'— the craig-rules rows inarchive_recordsare fleet BOOKKEEPING copies whose objects rules' ownarchive_ledgergoverns. -
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’sclaims.sub; scheduled =system:retention. Asecurity.archive_purgedevent carries the purge actor. -
The
rules.evaluations_archivedcontract REQUIRES the D9 store identity (store_endpoint/store_bucketnon-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:
-
every write is MONOTONE toward a terminal fixpoint (converging status transitions, never rewrites);
-
the eligibility predicate admits ONLY fixpoint rows (so the MVCC-consistent
to_jsonbsnapshot is the row’s final form); -
every writer STRUCTURALLY refuses fixpoint rows (guarded/fenced UPDATEs that 0-row against terminal state); and
-
delete_by_ids_sqlRE-ASSERTS the fixpoint predicate, converting any status-lattice regression between select and delete into the existingPruneCountMismatchrollback — 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).
Amendment — #1566 (E3): the exchange specs landed; legal_hold as an eligibility-predicate column (2026-08-22)
The specs + floors owed by the #1466 amendment landed: craig-exchange runs the shared
engine over three FK-deletion-ordered specs (ssa_transport_handoffs →
ssa_screening_members → ssa_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-assertsNOT 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. Becauselegal_holdflips 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 aload()-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’sNOT 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.