ADR-065: SSA Benefit-Data Classification, Custody, and Retention
On this page
Status
Accepted (2026-08-14; UD2/UD3 ratified the same day — maintainer steer recorded on
#1434). Drafted as plan unit A5 of
SSA Benefit Verification (SOLQ / SDX / BENDEX)
(plan v2, Plan::SSA, epic &81; the plan’s Phase-A A5 bullet).
The companion ADR-066 (typed
dispatch, pipeline shape, counterparty model) is drafted with this one; the two split
cleanly — ADR-066 owns how SSA payloads move, this ADR owns what the data is, where
it rests, and how long it lives.
Ratification state, per section:
-
§D1–§D4 record decisions the plan’s settled constraints (SD1–SD14, the plan’s § Settled by the review block) already fix or that this ADR itself proposes (the benefit-fact custody home, §D2); they become Accepted with this ADR.
-
§D5 (UD2) and §D6 (UD3) were drafted as options and are now DECIDED — both ratified 2026-08-14 as Option 1 (maintainer steer, #1434 note): UD2 = the exchange-only audited CAS may set
ssn_verified(the ADR-051 amendment is applied); UD3 = the ADR-057 twin (writes gated, reads open). The option tables remain below as the decision record. -
The §D4 retention horizons additionally ride UD4/UD10 (needed by E3, later than UD2/UD3); the retention class assignment itself is decided here.
|
Phase A as-built (2026-08-14, #1427–#1430 per the plan Status table). The
enforcement substrate this classification leans on is already merged and live —
see § Phase A as-built for the precise code anchors. Nothing in Phase A
can move an SSN: the |
Context
IV-E eligibility (45 CFR 1355.52) requires SSA benefit verification for household members. Georgia reaches SSA through Gateway: SOLQ (real-time SSN lookup), SDX (SSI batch), BENDEX (RSDI batch). Every response in that family is federal benefit data keyed by SSN: the request carries a plaintext SSN, and the response binds benefit type/amount/eligibility status to that SSN. SSA is the data authority and Gateway the network recipient/proxy (the counterparty model is recorded in ADR-066; both identities belong in the Phase-P DSA evidence).
The existing tiers, and why none of them fits unmodified
CRAIG already names three data postures plus two retention classes; this data family straddles all of them:
-
Encrypted PII at rest — ADR-020 / ADR-048: field-level AES-256-GCM-SIV in craig-cases only, under a passive database-disclosure threat model, fail-closed. The covered-column list (ADR-048 § Covered columns) is cases-resident; craig-exchange has no field-encryption plumbing at all — so "just encrypt it in exchange" is not an existing tier, it would be a new program.
-
Custody-restricted identifiers — ADR-051: full SSNs rest ONLY in cases' encrypted columns, entry is write-only, no read surface serializes them, and the federal export is the single audited exit. ADR-051 §D5 explicitly anticipated this program: “#162’s SOLQ verification will need UNVERIFIED SSNs … a distinct purpose mode — its own allowlist entry (craig-exchange), unverified emission permitted, the same audit event with a purpose discriminator — specced at #162 pickup.” §D2 below is that spec.
-
Transport vs. audit retention — ADR-022 §Retention
ADR-058: transport rows (outbox/inbox/exchange_send_jobs) are hard-pruned at ~30 d; audit-class rows are archive-then-prune with purge refused until DFCS names a schedule (⁂ #1480). SSA rows are both: the send/txn rows are delivery mechanics, but the screening runs/benefit facts are eligibility evidence a fair hearing can demand years later.
What the 2026-08-14 stop-ship review proved
The v1 plan died on exactly the gaps a named classification prevents (the plan’s § Why v1 was rejected):
-
S1 — once an
ssa_solqpartner row existed, any authorized caller could push a raw-SSN payload through the genericPOST /v1/exchange/send, and it would persist verbatim inexchange_transactions+exchange_send_jobs(the generic pipeline stagesValuepayloads by design). -
S2 — the WIC partner template’s error path preserves upstream response bodies into
last_error,exchange.failedevents, and logs; an SSA error body is benefit data keyed by SSN. -
S3 — a dispatch-time "fetch the current SSN" read can transmit a different value than the one the run staged (unbound custody read).
-
H24 — no data-classification statement existed, so every one of the above was a local judgment call rather than a rule (the plan’s traceability row H24: "A5 (ADR-065) + D4 DTO layer").
Decision
D1 — Classification: SSA benefit data is a named class with value-free surfaces
CRAIG’s data postures, with the new class in place:
| Class | Members (examples) | Posture |
|---|---|---|
Operational plaintext |
person names, admin units, case metadata |
plaintext; searchable per ADR-049; ADR-041 formally accepts persons-index name plaintext under compensating controls (the posture ADR-049’s § Threat model reads as operationally non-confidential) |
Encrypted PII |
ADR-048’s covered columns ( |
CGEF at rest in cases, fail-closed, capability-registered search only |
Custody-restricted identifiers |
the full SSN (ADR-051) |
rests ONLY in cases; write-only entry; one audited exit; value-bound lifecycle |
Federal benefit data (NEW) |
SOLQ/SDX/BENDEX responses; derived benefit facts; screening run/member outcomes; staged import records |
never transits or persists through any generic/ |
The class rules, each with its enforcement home:
-
No generic transit. SSA adapter kinds are
DispatchClass::TypedOnly: the generic exchange pipeline (which persistsValuepayloads verbatim) hard-refuses them at four independent layers (SD1; mechanics in ADR-066; as-built anchors in § Phase A). Payloads move only through the dedicated typed pipeline (B4). -
Categorical errors and events, everywhere. Closed, value-free error enums across Display/persistence/events/logs (SD2): no upstream byte — body, reason phrase, transport string, serde decode context — survives the adapter’s mapping boundary. Domain events carry category codes only (plan B6); metrics are ids/counts/categories only (plan §Operational acceptance). The V1 canary battery (the plan’s verification matrix, row V1) pins the rule over DB rows,
last_error, logs, audit sink, DLQ payloads, crash diagnostics,exchange.failed, and OpenAPI examples. -
Digest-keyed, never SSN-keyed. Rows in this class reference the person via UUID and/or the versioned
SsnDigest(SD12) — an HMAC-SHA256 blind-index digest in canonicalv{n}:{44-char base64}form. The digest is secret-adjacent (treat as never-log) but non-reversible: the HKDF field key never leaves cases (ADR-051 §D3’s digest-exposure rationale carries over unchanged). -
Provenance-grade. Screening runs/members, benefit facts, and import ledger rows are records of an eligibility determination’s evidence, NOT prunable transport — they take the archive-then-prune class (§D4).
The audit tier stays PII-free by construction: the SolqAuditPayload codec projection
is the only SSA shape that reaches the queryable partner-audit surface, and whatever
fields the real Gateway contract adds to it must stay PII-free
(crates/craig-partner-ssa-solq/src/types.rs:62-69).
D2 — Custody map: who rests what, and the value-bound release
| Datum | Rests in | Rule |
|---|---|---|
Raw SSN |
craig-cases ONLY (unchanged ADR-051 §D1) |
exchange holds it wire-transiently: in memory between JIT custody release and the egress-controlled send (B4), never written to any exchange row, log, or event; import parse buffers are `zeroize`d (C1/H10) |
|
wire-only today; B1 decides the column shape |
the ONLY cross-service SSN correlate; versioned so rotation is classifiable (a rotated-away version is refused, never silently rehashed — the V2 pin) |
Screening runs / members / typed jobs |
craig-exchange (plan B1) |
pipeline provenance — provenance-grade rows, not prunable transport (H3/H14) |
Benefit facts |
craig-cases, alongside person custody |
decided HERE per plan B5 ("custody home decided in ADR-065 — recommendation: cases") |
Benefit-fact custody home — cases. Rationale: facts are person-scoped eligibility evidence (row-per-benefit with supersession/precedence/provenance, SD10) whose consumers are all cases-authorized — the UD1 assembly endpoint, the D4 case-scoped reads, the web proxy. Resting them beside the person row keeps ONE BOLA boundary and ONE PII custodian instead of teaching craig-exchange (a transport service whose tables are largely prunable, with no field-encryption plumbing) to be a second records custodian. Batch-imported facts are person-scoped only, with case association deferred to UD1’s assembly time (UD12 — RATIFIED 2026-08-16; the assembly-time authz/ambiguity/ correction semantics are C2 design scope). Alternative rejected: exchange-resident facts — it would put records-class person data in the transport service, split the authz story across two services for every read, and force the D4 web proxy to fan out.
The custody-purpose posture (SD3/S3 — the plan’s B2 design). This concretizes ADR-051 §D5’s anticipated exception as a distinct purpose mode, not a widening of the federal-export endpoint:
-
The S2S custody read carries
person_id+expected_digest+ purpose + run correlation. Cases releases plaintext ONLY on an exact current-digest match — the release is value-bound: it can never transmit a different SSN than the one the screening run staged. Mismatch → typed 409; the member is terminallyssn_stale(never silently re-fetched). -
Allowlist: craig-exchange only for this purpose (exact service allowlist per SD5); actor propagation carries the caseworker actor for audit while the service identity authenticates.
-
Unverified emission is permitted for this purpose — verification is the point (ADR-051 §D5’s carve-out, verbatim).
-
Every release stages the same read-audit event family as the federal export (
case.person_pii_exported) with a purpose discriminator, so an incident can answer "whose SSNs left, for what" across both exits. -
The plaintext-release gate is always fail-closed regardless of UD3 (H21): no read-availability answer, feature knob, or gate state ever opens plaintext release — it has its own dedicated gate.
As built (B2, #1463). POST /v1/cases/persons/{id}/ssn-release
(services/craig-cases/src/api/persons_ssn_release.rs), three refinements recorded:
-
The caseworker attribution rides the request body (
requested_by— the run’screated_by, captured from the live actor at B3 stage time), notX-Craig-Actor: the release happens at B4 dispatch time from a background worker, when no live caseworker exists to relay, and minting a synthetic actor token for an absent human would fabricate presence. The service identity authenticates; the audit events carry both identities plus the run/member correlation (the ADR-028 actor-vs-authn split, realized as data). -
The H21 "dedicated gate" is the unconditional in-handler check chain — allowlist (a handler const, deliberately NOT a jurisdiction-editable ruleset row), closed purpose vocabulary, version-before-bytes digest comparison (a rotated-away blind-index version is its own 409,
ssn-digest-version-rotated, distinct fromssn-digest-mismatch— the plan-V2 never-silently-rehash pin), and the fail-closed ADR-020 decrypt — not a settings knob. Compare-and-decrypt consume ONE row snapshot, so the released plaintext is exactly the value whose digest matched. The comparison isSsnDigest’s constant-time equality against the stored digest stamped with the effective registry’s current version — the emit-side versioning seam #1435 repoints the remaining bare-`Stringdigest fields onto. -
Refusals are audited too: the digest-class outcomes (
digest_mismatch/digest_version_rotated/missing_ssn) stage the categoricalcase.person_ssn_release_refusedevent (UUIDs + reason token only — no digests) in their own tx before the 409 returns; releases stage thecase.person_pii_exportedfamily withpurpose = ssa_solq_screening+ the correlation trio.
D3 — The DTO rule: wire ≠ persisted payloads; store models never serialize
Two construction rules, both settled (SD14; traceability row H24; realized by plan unit D4):
-
Wire payloads are constructed separately from persisted payloads. The typed worker (B4) builds the minimal outbound wire request from the JIT custody value
the persisted job’s non-PII fields at send time; the persisted job row never contains the wire body and the wire body is never round-tripped through a persisted row. Correlation rides an explicitly modeled field on the typed wire struct — the generic pipeline’s pattern of injectingcraig_correlation_idinto aValuemap at dispatch (services/craig-exchange/src/send_worker.rs:290-299) is exactly the unknown-field-drop hazard SD14 names: a typed#[non_exhaustive]wire struct would silently drop an unmodeled injected key on the deserialize side. -
Read APIs serve DTOs, never store models. No SSA store row derives
Serialize; list/latest endpoints (D4) map through dedicated DTO types, and the web reads proxy through cases authorization. Precedent: ADR-049 §Amendment #1158’sReportSummaryRowis deliberately notSerialize— the only exit is a consuming transform. The same shape makes "accidentally serialize the row with its digest column / provenance internals" unrepresentable without new code at the seam. LANDED (#1549): the sharedcraig_cases_contracts::screening_readDTOs are the only exits — cases serves the household-scoped outcome list + current facts and relays exchange’s cases-only latest-run view;SsaScreeningRunlost its conventionSerializederive and now maps only the columns its consumers read. #1557 amendment: the latest-run view additionally carriescohort_hash— the COHORT digest-of-digests the D3 assembly byte-recompares (the B1 promise); no PER-PERSON digest is representable in it, and the BFF view never maps it. The digest-column-serialization hazard this section guards therefore now has one DELIBERATE cohort-level exception; the per-person prohibition is unchanged.
Phase A already conforms: the A1 wire types are field-free #[non_exhaustive]
placeholders (crates/craig-partner-ssa-solq/src/types.rs:24-60) — inventing fields
before the C0 contract would fabricate a contract (the S9-class hazard), and unknown
fields are tolerated on decode so the first real Gateway response cannot fail on
unmodeled fields. B4 designs the actual minimal wire payload; person-identifying
inputs enter through the §D2 custody flow, never as free fields on the wire types.
D4 — Retention: class assignment here; UD4/UD10 RATIFIED 2026-08-16
SSA tables map onto ADR-058's two classes as follows — the assignment is decided here, and UD4/UD10 were ratified 2026-08-16 (#1434). UD4’s delimitation: the classification and the ≤90 d transport bound are CLOSED; row/history mechanics (insert-only vs supersede — see the precondition note below) were owned by the B5 design unit (#1479) and are now resolved — see § Amendment #1479 (mutation-quiescent-at-eligibility). E3 wires the machinery; the external DFCS schedule is #1480:
| Tables | Class | Policy |
|---|---|---|
|
Provenance (audit) class |
archive-then-prune via the shared |
SSA typed job rows + SSA |
Transport class |
UD4 (RATIFIED 2026-08-16): ≤ 90 d, and only once provenance is FK’d elsewhere — a
transport row is prunable exactly when the runs/members/facts carry every fact an
audit could need, with FKs proving the hand-off. Until that FK chain exists the
rows are retained. Landed 2026-08-22 (E3 #1566): the |
Two rules make the transport class safe to prune for this data family:
-
§D1’s categorical-surface rule means an SSA transport row’s
last_erroris value-free by construction — pruning it destroys no evidence that isn’t already in the provenance tables. -
§D3’s wire≠persisted rule means the transport row never contained the wire payload in the first place.
Legal hold (UD10 — RATIFIED 2026-08-16): a hold flag at run/batch granularity —
matching ADR-058’s legal_hold posture, at the granularity a hearing request actually
names. Transitive-hold scope (derived facts, transport provenance, events, audits),
set/clear authority + audit, and the sweeper-race semantics are E3 design
deliverables. The DFCS records-retention schedule remains ⁂ on #1480; until it lands,
all horizons here are OPERATIONAL knobs exactly as ADR-058 classifies its own.
D5 — UD2 (DECIDED 2026-08-14 — Option 1): does a digest-bound SOLQ match set persons.ssn_verified?
Question (the plan’s UD2 row; traceability row H20): a SOLQ response that matches on the exact staged digest is SSA itself confirming the SSN. Does that machine confirmation set the ADR-051 §D3 verification state? Needed by B5. Ratified: 2026-08-14 — Option 1 (YES), maintainer steer on #1434.
| Option 1 — YES (RATIFIED 2026-08-14) | Option 2 — NO (rejected) | |
|---|---|---|
Mechanism |
An exchange-only, audited CAS on cases: a dedicated S2S endpoint (precedent:
|
The SOLQ outcome lives ONLY in screening-member outcomes + benefit facts;
|
ADR-051 impact |
Requires the ADR-051 amendment below — §D3’s in-handler pure-service denial ("machine identities never attest") currently makes ANY machine write of verification state impossible; the amendment carves out this single value-bound S2S surface, leaving the human endpoint’s denial untouched. |
ADR-051 amended only with a clarifying note that SOLQ outcomes deliberately do NOT touch verification state (H20 requires the amendment either way, so the decision is recorded where the custody rules live). |
Trade |
One source of truth for "is this SSN verified" — federal reporting (ADR-051 §D5’s verified-only export gate) benefits immediately from every screening. Costs a carefully-fenced exception to a deliberately absolute rule. |
Keeps ADR-051’s rule absolute and simple. Costs a second, parallel notion of "SSA-confirmed but not verified" that every consumer (export gate included) must understand — and human workers re-attesting what SSA already confirmed. |
ADR-051 amendment (UD2 ratified 2026-08-14 — APPLIED to adr-051 § Amendments)
The amendment below is applied verbatim (dates filled) to
adr-051-person-ssn-custody.adoc § Amendments in the ratification commit:
* *#162/UD2 (____-__-__) — SOLQ digest-bound machine verification (amends §D3's pure-service denial).* The `#162` screening pipeline (ADR-065 §D5, Option 1 ratified ____-__-__) adds the ONE machine path to `ssn_verified`: an exchange-only S2S CAS, value-bound to the screening run's staged `expected_digest` via the same single-guarded-statement shape as the human attestation (digest mismatch → 409, no write; `ssn IS NULL` → 409). Method is a distinct machine value (`solq_match`); attribution carries the requesting run + the caseworker actor relayed per SD5. §D3's in-handler pure-service denial is UNCHANGED on the human endpoint — machine identities still cannot reach it; this is a separate, allowlisted surface, not a relaxation. Every ssn write still resets verification, same-value included (§D3's reset rule is untouched and now also covers SOLQ-verified values).
D6 — UD3 (DECIDED 2026-08-14 — Option 1): read availability when the write gates are off
Question (the plan’s UD3 row; traceability row H21): the SSA feature knobs (screening; import) gate writes. What do the READ surfaces do when a deployment turns a knob off while rows exist? Needed by B2/C1. Ratified: 2026-08-14 — Option 1 (ADR-057 twin), maintainer steer on #1434.
| Option 1 — ADR-057 twin (RATIFIED 2026-08-14) | Option 2 — symmetric gating (rejected) | |
|---|---|---|
Behavior |
Writes gated, reads open — the ADR-057 posture: existing screening runs, benefit facts, and import ledgers stay readable (and auditable) with the knob off; only new writes refuse. |
Reads refuse alongside writes: knob off ⇒ the whole surface 403s/404s. |
Trade |
An operator investigating "why was this child found eligible" can always see the evidence that fed the determination — retention/audit obligations don’t blink with a feature flag. Matches the fleet’s only in-tree precedent for gated-import data. |
Simpler mental model ("off means off") — but it makes recorded eligibility evidence unreachable by configuration, which sits badly with §D4’s provenance-class claim, and turns an operational knob flip into an evidence outage. |
Invariant regardless of the answer (decided, not optional): the §D2 custody plaintext-release gate is a SEPARATE gate and always fail-closed — neither UD3 option, nor any read surface, ever releases plaintext. UD3 only ever decides the visibility of already-persisted, digest-keyed, value-free rows.
Phase A as-built (the substrate already enforcing this ADR)
All four units merged 2026-08-14 (plan Status table; nothing here can move an SSN):
-
A2 — the classification’s teeth (#1428).
DispatchClass::{Generic, TypedOnly}is a REQUIREDErasedAdaptermethod — no default, so a hand-written impl must choose a class rather than inheritGenericby omission (crates/craig-exchange-contracts/src/erased.rs:38-45,105-110). The newtyped_onlymacro arm pinsTypedOnlyAND makes the erasedsend_value/audit_valuerefuse unconditionally with the categoricalErasedAdapterError::TypedOnlyDispatch(kind token only, never payload bytes —erased.rs:136-144,317-373; the canary test aterased.rs:633-668pins the no-echo property). Above that innermost layer sit three checkpoints, all keyed on the PARTNER-ROW kind viaExchangeRegistries::dispatch_class_of— neverresolve(), whosenoop://substitution would mask the kind: send pre-claim 422 (services/craig-exchange/src/api/dispatch_guard.rs:21-38, called atapi/transactions.rs:138), retry in-tx pre-restage (anErrrolls back the status flip;dispatch_guard.rs:75-87, called atapi/transactions.rs:441), and the worker pre-resolve terminaltyped-only-dispatch(services/craig-exchange/src/send_worker.rs:278-284— terminal, not retryable: a policy refusal can never succeed on retry). -
A4 — the digest identifier (#1430, SD12).
SsnDigestincrates/craig-cases-contracts/src/ssn_digest.rs: canonical one-string wire formv{n}:{44-char base64}(serdetry_from/intoString,:51-60);PartialEqIS thesubtle::ct_eqconstant-time compare over the full canonical bytes — version + digest together, so a rotated-away version is NOT equal and the consumer classifies via.version()(:189-201); redactedDebug(version tag only) and NODisplay—{}logging is a compile error (:205-209); categorical value-free parse errors (:106-115); leading-zero version tags refused for canonical uniqueness (:155-165); property tests pin parse-never-panics and byte-identical round-trips (the B1 cohort hash binds to those bytes). Wire-only — nosqlx::Type(B1 decides the column shape), noToSchema(consuming DTOs annotatevalue_type = String, the Sha256Hex precedent). -
A1 — the value-free adapter (1427, S2/S10).
crates/craig-partner-ssa-solq:SsaSolqErroris a CLOSED categorical enum — at most the bounded numeric HTTP status; body bytes, transport strings, and serde decode context are DISCARDED at the single mapping boundary (src/error.rs:19-56;src/adapter.rs:61-74— explicit per-arm mapping, never[from]; canary tests pin no-echo through the full send path). NO in-crate mock ormockfeature (a mock SSA endpoint would fabricate benefit-data authority; test fixtures land in C3 behind a production hard-refusal). Field-free#[non_exhaustive]wire placeholders per §D3. The transport-level response byte-CAP deliberately rides B4’s S13 egress controls — the transport hands adapters an already-fully-readVec<u8>, so A1 alone cannot bound the read (recorded in the crate docs,src/lib.rs:32-40). -
A3 — registration, refusal-gated (#1429). GeorgiaBundle registers the factory + audit codec (
crates/craig-state-ga/src/lib.rs:105-132; the erased registration is the in-crateimpl_erased_adapter!(SsaSolqAdapter, "ssa_solq", typed_only)atcrates/craig-partner-ssa-solq/src/adapter.rs:131).PINNED_KINDS11→12 with the lockstep test asserting generic-path rejection for every member ofPINNED_TYPED_ONLY_KINDS(services/craig-exchange/src/adapters/mod.rs:38-47,79-91) — future TypedOnly kinds inherit the rejection proof automatically. Activation refusal: an ACTIVE partner row for any TypedOnly kind is refused 403FEATURE_DISABLEDnaming the lift, keyed ondispatch_class_of(never the literal token), covering create, kind-flip-while-active, andPATCH {active: true}resurrection (dispatch_guard.rs:50-67, called atapi/partners.rs:174,318);{adapter_kind, active: false}stage-ahead stays legal. The worker checkpoint is proven live by a #1195-style direct-SQL test that CAS-flips a staged row’s partner to the registered kind inside the worker’s poll window and asserts terminaltyped-only-dispatch, never dispatch (services/craig-exchange/tests/api/send_worker.rs; ordering inverted vs t1195 because the registered kind 422s at ingress). Seed inventory: a DELIBERATE ABSENCE with the reason committed at the site (tools/craig-seed/src/datagen.rs:1664) — seeded partners are born active, so any seededssa_solqrow would violate this unit’s own refusal; the B phase seeds the partner when the typed pipeline exists.
Consequences
Positive
-
The S1/S2/S3 defect classes are structural, not procedural. Verbatim-persisting generic dispatch of benefit data is refused at four layers keyed on one registry fact; value leakage through error text is impossible by enum construction; a custody read cannot transmit a value the run didn’t stage. Each is pinned by a canary/lockstep test, not a review checklist.
-
One custodian per datum. Cases remains the single PII custodian (SSN AND benefit facts); exchange remains a transport + pipeline-provenance service; the digest is the only correlate that crosses. Incident response has one place to ask "whose SSNs left" (the purpose-discriminated read-audit event family).
-
Retention answers exist before the first row does. Every B1/C1 table lands with a named class, so "what do we do with this data" is never retrofitted — the exact failure ADR-058’s Context recorded for the pre-#965 fleet.
-
UD2/UD3 were contained. Both decisions were option-shaped with their blast radius pre-drawn (one ADR-051 amendment; one read-gate posture) and were ratified 2026-08-14 as Option 1 each — neither reshaped the classification, the custody map, or the DTO rule.
Negative / residual risk
-
The class costs a parallel pipeline. TypedOnly kinds forgo the generic pipeline’s existing retry/observability surface; B4 must rebuild the operational affordances (lease budget, failure classification, pause-vs-terminal) for the typed path. Deliberate: the generic pipeline’s persistence model is the hazard.
-
Digest-keying is correlation-resistant, not correlation-proof.
SsnDigestis deterministic per key-version by design (matching needs it); ADR-049’s threat-model caveat applies — equality/frequency of the digest column is visible to a DB reader. Confidentiality rests on the HKDF key (cases-resident) and ADR-051’s custody rules, not the digest. -
Horizons wait on people. UD4/UD10 were ratified 2026-08-16, but the DFCS schedule (#1480) still gates the concrete retention numbers; until it lands, provenance rows accrue under the indefinite-hold default — the same deliberate trade ADR-058 records.
-
Phase-A line anchors will drift. The code anchors above are as-of the A1–A4 merge SHAs; later phases will move lines (the plan’s Status table is the authoritative unit→issue map).
Neutral
-
Pre-1.0, no production data: the classification binds new tables only; no migration/backfill.
-
Nothing here changes ADR-020/048 encryption semantics, ADR-051’s human-facing surfaces (the ratified UD2 amendment adds a separate machine surface, not a change to the human one), or ADR-058’s engine — this ADR only assigns SSA tables to the existing machinery.
Related decisions
-
ADR-066 (companion, drafted with this ADR) — typed dispatch + pipeline + counterparty model; owns the mechanics §D1 leans on.
-
ADR-051 — full-SSN custody; §D2 is its anticipated §D5 exception made concrete; §D5 here carries its amendment stub.
-
ADR-058 — the two retention classes §D4 assigns SSA tables to.
-
ADR-022 — transport-window invariants; the replay boundary SSA transport rows inherit.
-
ADR-020 / ADR-048 — the encrypted-PII tier and its cases-only scope.
-
ADR-049 — blind-index threat model the digest posture inherits; the non-
Serializeprojection precedent. -
ADR-041 — the accepted-plaintext boundary of the operational tier (name plaintext under compensating controls; DOB blind-indexed).
-
ADR-057 — the UD3 Option-1 writes-gated/reads-open precedent.
-
ADR-062 — the
request_claims/claim-first machinery B3 reuses for run creation. -
ADR-032 — the partner registry + erased seam the DispatchClass rides.
-
Plan: SSA Benefit Verification — SD1–SD14 (§ Settled by the review), the UD table (§ User decisions), § Findings traceability, § Verification matrix.
Amendment (#1479, 2026-08-17) — the B5 design record: send-authority witness, run lifecycle, cohort invalidation, retention posture
The B5 design unit #1479 ratified 2026-08-17 (closed by this record): the adversarially-reviewed design brief (the working record rides #1479 as an issue note; decisions D1–D24) was ratified in full, including all six maintainer forks (2026-08-17 steer). This amendment records the decisions — dense, with rationale pointers into the brief; the carried acceptance criteria live on #1466. The outcome-transaction mechanics and the corrections to pipeline statements ADR-066 owns are recorded in ADR-066 § Amendment #1479; this ADR records custody, data shape, lifecycle vocabulary, and retention.
Custody — the pre-contract gate (D1/D2/D3).
-
The production capability gate is a witness type:
SolqSendAuthority::Gateway(GatewayProof)incraig-partner-ssa-solq, with a private-field proof struct and NO constructor exported pre-Phase-P — a production binary contains no expression that evaluates to it. Phase P exports the constructor in the same reviewed MR that flipsmain.rsNone → Some(the recorded S4 posture: activation changes CODE, never config). The harness-onlyNonAuthoritativeStubvariant exists solely under thecontract-stubfeature, enabled only from craig-exchange dev-dependencies; a blocking release-feature-graph lint asserts its absence from release builds (the resolver ≥ 2 assumption — dev-dep features don’t unify into release graphs — is recorded alongside the lint). -
The worker’s
dispatch_preconditionschecks the witness first:None⇒ pausecontract-pending(hyphenated, a recorded deviation from #1479’scontract_pending, matching the existing worker token vocabulary) before any custody read — every pause exit precedes the JIT plaintext release, closing the B4 interim "send happens, response discarded" defect at the seam. Staging stays open pre-contract (a staged run is inert data). Layering: L1 witness (structural), L2 the pause (behavioral), L3 the endpoint-shape activation guard — kept, but demoted to ergonomics and explicitly NOT the custody boundary — L4 egress empty-denies-all. -
Pause vs terminal taxonomy: PAUSE = deployment-scoped, operator/Phase-P-remediable, the same job may later send unchanged (
contract-pending,gate-disabled,partner-unavailable,dsa-not-active,egress-refused, futureauth-unconfigured); TERMINAL = a per-job/member fact no configuration heals (plus newfreshness-expired). Post-send failures are never pause-class.
Run lifecycle (D3/D4, forks 1/6).
-
The
invalidation_reasonCHECK is rebuilt to exactly three tokens:cohort_changed,operator_cancelled,freshness_expired. The one-active slot is released by those three invalidation reasons plus run completion — never permanently consumed. -
complete_run_if_final(an idempotent, guarded, converging UPDATE) fires from every member-terminal writer — the B5 outcome tx is one call site of several (the "outcome tx is the onlycompletedwriter" reading was review-refuted: B4 paths reach member-terminal with no outcome tx and would have held the slot up to 12 months). Invalidated-and-drained runs complete too — completion = "the pipeline finished draining";invalidated_atstays the orthogonal witness-void marker. NO third run status; no stored aggregate counters (derive by COUNT — counters would enlarge the mutable-column allowlist for zero read). -
Every invalidation writer runs ONE tx: stamp
invalidated_at+ reason; finalize the run’spausedAND unclaimedpendingjobs →failed/run-invalidatedwithcompleted_at; terminal affectedpendingmembers →cancelled; thencomplete_run_if_final. -
Fork 1 (ADOPTED) — operator run-cancel: an audited, HUMAN-only, cases-relayed endpoint (BOLA-checked, the B3-intent-path pattern) stamps
operator_cancelled. Replay posture: one-way predicate idempotency — a second cancel returns a deterministic already-invalidated; the audit event names the actor. -
Fork 6 — attempts reset to 0 on resume (#1469’s service-side human-admin live-precondition re-check): pause cycles must not burn the send budget; the
ssa.jobs_resumedaudit event preserves the pre-reset count; the shared #1185 claim shape is untouched. Newpaused_atcolumn:pause_jobstamps, resume clears.
Freshness + retention — resolves §D4’s known conflict (D8–D11, forks 2/4).
-
Fork 2 (RATIFIED) — the freshness no-op: invalidating a
completedrun past the UD11 12-month horizon is a categorical no-op, expressed as an in-WHERE predicate on the invalidation UPDATE (NOT (status='completed' AND as_of < CURRENT_DATE - INTERVAL '12 months')— DB-side, monotone, never app-side check-then-write). It is information-free (such a run is already not a UD1 witness) and it is what lets runs quiesce for archive; NO current+history split. Rescoped deliberately (the review’s HIGH finding): pending runs stay invalidatable at ANY age — the unqualified refusal would have permanently bricked a stale pending run’s slot. -
The §D4 known conflict is resolved WITHIN the UD4 delimitation (classification
≤90 d transport bound stayed closed; row/history mechanics were #1479’s): runs/members stay provenance class under mutation-quiescent-at-eligibility — a scoped ADR-058 amendment (shipping in the same B5 MR as thecraig-retentionspec.rsinsert-only doc-comment rewrite) admits sources where no designed write path can touch a row once archive-ELIGIBLE: (1) monotone writes toward a terminal fixpoint; (2) eligibility predicates admitting only fixpoint rows; (3) writers structurally refusing fixpoint rows (0-row UPDATEs); (4)delete_by_ids_sqlre-asserting the fixpoint, converting status-lattice regressions into the existingPruneCountMismatchrollback. Zero craig-retention engine changes.invalidated_atis named as protected SOLELY by the in-WHERE freshness refusal — precondition 4 covers only fixpoint-predicate columns. Rejected: event-sourcing (rewrites B3+B4) and a current+history split (quietly re-classifies runs/members to transport). -
Mutable-column allowlist (pinned here; DDL-probe-asserted): runs =
status, completed_at, invalidated_at, invalidation_reason; members =status, completed_at(outcome detail lives in the cases-side projection row, so the member allowlist does NOT grow).cohort_revisionon runs is insert-time-only; the watermark table is an operational projection outside both retention classes. -
Archive floors + residual risk: member fixpoint = terminal statuses with CHECK-paired
completed_at; run fixpoint =completedAND past the 12-month quiescence horizon; the 12-month SQL literal is probe-asserted equal toSCREENING_FRESHNESS_MONTHS(never two numbers); the exchange SERVICE archivehot_window_daysknob isload()-validated ≥ 365 d (per-engine, not per-table — recorded); purge stays refused pending DFCS (#1480). §D1 residual-risk note extended:to_jsonbarchive NDJSON carriesssn_digest/cohort-hash values fetchable viaxtask archive-fetch— the ADR-058 D9 store posture (encrypted, least-privilege, versioned bucket; Local refused) is the compensating control; never-log is unchanged. -
Fork 4 (option a) — digest-registry version rotation: an operator mass-invalidation xtask as a rotation-runbook step, filed as #1489 (blocked on #1466); the D3 consumption-gate hash recompute stays the backstop. LANDED:
cargo xtask ssa-invalidate-stale-cohorts --current-version N [--yes]— device-code OAuth as the human admin, driving the exchange sweep endpoint (POST /v1/exchange/ssa-screening-runs/invalidate-stale-digests), which invokes the B5invalidate_runwriter per run (never raw SQL) + the B6b invalidation event and stages thessa.stale_cohorts_invalidatedoperator summary; runbook =operations/ssa-digest-rotation.adoc.
Cohort invalidation delivery (D12–D16, forks 3/5 — essentials).
-
Producer-carried monotonic
cohort_revision(cases.screening_cohort_revision, bumped through ONE seam at producer sites P1–P5 — household add, digest-transitionupdate_personwith the keyless fallback arm mirrored,clear_ssn, manual-link promotion, and §G4 auto-link promotion with the attempt tx threaded throughpromote_report_links; P7/P8 have no surface and are pinned as a data-model-page contract, not dead enum variants). Runs carrycohort_revision BIGINT NOT NULL DEFAULT 0(insert-time);ssa_cohort_watermarks(case_id PK, cohort_revision, updated_at)is the per-case watermark. Revision comparison is order-independent; ABA over-invalidation is the accepted cost, failing toward re-screening. Rejected with recorded reasons: synchronous S2S, reusingcase.person_updated(structurally insufficient), hash-compare (unsafe under reordering). -
Fork 5 — MINIMAL event payload: ONE PII-free
case.screening_cohort_changedoutbox event per affected case, staged in the mutation’s own tx — payload = case UUID +cohort_revision+ a categorical change token ONLY; noperson_id, no digest (person-level tokens are activity-revealing to every queue reader). Exchange consumes on the existing inbox under the ADR-062 attempt tx: watermark GREATEST-upsert → strict one-waycohort_revision <invalidation (bounded by the fork-2 predicate) → the one-tx sweep. Replay no-op; out-of-order commutative; no wall-clock anywhere. -
Fork 3 (ADOPTED) — staging auto-supersede: the staging endpoint UPSERTS the watermark (the row lock is the serialization point) inside the claimed tx and branches three ways on the RETURNING value: strictly newer ⇒ invalidate the active run (
cohort_changed) + sweep + stage the new run in ONE tx (audited marker + the B6exchange.screening_run_invalidatedevent — auto-supersede IS the legitimate recovery mechanism); equal ⇒ 409SCREENING_RUN_ACTIVE; older ⇒ 409SCREENING_COHORT_STALE; the supersede 0-row race re-checks/409s, never falls through to the partial-unique 23505.StageScreeningRunRequestgrows REQUIREDcohort_revision(the mixed-version deploy window fails closed 4xx — pre-1.0, recorded). The "no active run below its case watermark" invariant is structural; NO reconciliation sweep — a report-only watchdog probes it.
Benefit facts + outcome projection — data shape (D13, D19–D20; mechanics in ADR-066).
-
ssa_benefit_factsrest in cases (§D2’s custody home, confirmed), born insert-only: pointer-forward supersession chain (supersedes_idFK KEPT, no-self-supersede CHECK; chain-root + successor unique partial indexes give structural exactly-one-current); the replay key is WIDENED to cover successor inserts (UNIQUE (screening_member_id, benefit_kind, beneficiary_role) WHERE screening_member_id IS NOT NULL);beneficiary_rolejoins the chain identity. Corrections, terminations, re-grants are all successor rows; the supersession race is insert-23505 → re-read head → retry. Facts are honestly indefinite-hold until DFCS names a schedule; onboarding facts to archive is a recorded future decision. Row types are notSerialize(§D3). -
ssa_screening_outcomes(cases) — the review-mandated outcome projection (facts alone cannot distinguish NoMatch/Unmappable/zero-fact-Screened):transaction_id PK, run_id, screening_member_id UNIQUE, person_id, member_outcome, fact_ids, recorded_by, recorded_at— insert-only, provenance class, NO digests; joins the allowlist probe. Replays (in-window or post-prune) re-derive the response from this row with ORIGINAL facts. -
Read posture: facts are true statements about (digest, person-binding-as-of-verify), NOT the person unconditionally — the SSN-correction window is the named residual; the UD1/D4 assembly gate is the only sanctioned consumer, and non-assembly fact readers must join run validity.
B5/B6 ordering (D17). B5 ships migrations + the wire field + the seam + all bumps
(P1–P5, no publishes) + the inbox handler AND queue binding
(consumers-before-producers) + staging hardening + sweep fns + watchdog. B6a = event
catalog (ADR-003 additions: case.screening_cohort_changed,
exchange.screening_run_invalidated) + security parser arms; B6b = the producers.
Between B5 and B6 the system degrades honestly: revisions bind, staging self-heals,
no event-driven invalidation yet.
As-built (B5, #1466 — data-shape deltas only; the pipeline deltas ride ADR-066’s
as-built note). beneficiary_role’s CHECK bounds SHAPE only (^[a-z_]{1,32}$) —
the token vocabulary pins at Phase P against the real contract (inventing it now
would fabricate contract semantics, the S9 hazard); the wire mirrors the bound. The
v1 fact assertion is `benefit_status ∈ {active, terminated} (a termination is a
successor ROW; amounts and richer state arrive additively at Phase P). The
ssa_screening_outcomes PK doubles as the LAST-line post-prune replay backstop
(savepointed insert; a PK conflict re-derives the original outcome) behind the
primary domain replay guard. The 12-month SQL bound is BOUND as a parameter from
SCREENING_FRESHNESS_MONTHS at every freshness predicate (stronger than the
probe-assert this amendment asked for — there is no second literal to probe). The
exchange hot_window_days ≥ 365 d load() floor landed in B5-U4 (ahead of E3’s
specs), enforced for the CRAIG_EXCHANGE prefix with the devstack default overridden
accordingly.
Amendment (#1566, 2026-08-22) — E3 as-built: the UD4 handoff relation, the transport sweep, UD10 legal holds, and the exchange archive wiring
E3 discharged the §D4 "E3 wires the policy" obligations. The as-built decisions:
-
The provenance handoff relation is
ssa_transport_handoffs— one insert-only row per member, written INSIDE the transport prune transaction, snapshotting the audit-relevant facts only the job/transaction rows held (correlation token, partner, attempts, final status, categoricallast_error, timing). FK direction honors the migration-recorded rule (provenance never FK-depends on transport): the handoff FKs the MEMBERON DELETE RESTRICT, which also forces the archive deletion order handoffs → members → runs. The plan’s archive-success precondition is discharged transactionally: the snapshot insert and the transport deletes are ONE atomic commit — no object-store round-trip gates the ≤ 90 d prune, and a crash can never leave a pruned row without its snapshot. -
The transport sweep (
ssa_retention.rs, workerssa-transport-sweep) prunes terminalssa_screening_jobs+ their SSAexchange_transactionsrows hourly under its own advisory lock, batched, windowed byCRAIG_EXCHANGESSA_SCREENINGTRANSPORT_RETENTION_DAYS(load()-validated0..=90naming UD4 — the ratified bound is a CEILING; 0 disables visibly, a devstack/test posture the overrun invariant catches in production, and one the archiver refuses to coexist with atload()). The jobs delete re-asserts the FULL eligibility predicate (terminal + windowed + member-terminal + typed-transaction-row +NOT legal_hold) PLUS a snapshot-exists guard (the handoff row must exist in the same transaction — a hold set→clear flap between the insert’s and the deletes' statement snapshots would otherwise commit a prune with no snapshot); the transactions delete re-asserts the hold
exchange-type legs (its job legs are consumed by the preceding statement). Short delete counts roll the whole batch back, and a mismatch-aborted pass withholds the sweep heartbeat so a persistent wedge alarms as staleness. Claims coordination is structural, pinned by test: no replay path consults transport rows (staging replays re-derive from the run, outcome replays from the projection), so the sweep can never manufacture aREPLAY_ENTITY_GONE. -
UD10 legal holds live on
ssa_screening_runs.legal_hold(run granularity as ratified; batch-grain holds land with the C1 import tables). The flag is an eligibility-predicate column: every retention statement selects on AND re-assertsNOT legal_hold, so ADR-058 §#1466 precondition 4 covers the sweeper race exactly — a hold committed between select and delete becomes a count-mismatch rollback (row locking makes this exact; no advisory lock). Transitive scope = the run join in every child predicate. The D10 mutable-column allowlist for runs is EXTENDED tostatus, completed_at, invalidated_at, invalidation_reason, legal_hold(probe updated) — unlikeinvalidated_at(writer-side-refusal-only),legal_holdis precondition-4-protected. Set/clear = human-admin-only endpoints (POST/DELETE /v1/exchange/ssa-screening-runs/{id}/hold, the fork-6 gate), reason-required both directions, each TRANSITION stagingexchange.screening_hold_set/_clearedin the flip transaction (the reason rides the EVENT, never the row — the flag stays a bare boolean, thearchive_recordsprecedent). The hold endpoints are writes-gate-EXEMPT (the fork-1 cancel precedent): a hold must be settable on a deployment that has disabled the feature. -
The exchange archive engine landed (
retention_archive.rs+ thearchive_ledgermigration — the craig-rules ledger shape verbatim,cargo xtask archive-fetch craig-exchangewired): three FK-ordered specs (ssa_transport_handoffs,ssa_screening_members,ssa_screening_runs), each embedding the family fixpoint (runcompletedAND past the 12-month quiescence horizon ANDNOT legal_hold; themonths ⇒ 12SQL literal is probe-asserted equal toSCREENING_FRESHNESS_MONTHS) withNOT EXISTSdeletion-order guards in BOTH the select and the delete. The family shares ONE age clock (completed_at= the terminal stamp carried onto the handoff), so the three tables quiesce together. Bookkeep is ledger-only — no fleet-bookkeeping event: unlikerule_evaluations, nothing downstream consumes SSA archival facts (recorded deviation from the rules shape). Consent (D11)
boot-probe (D10) + the ≥ 365 d exchange floor all apply; cases-side logical pointers (ssa_screening_outcomes.run_idetc.) resolve post-prune via the D17 ledger/archive-fetch path. No purge tooling ships (D14 — refused until DFCS names a schedule, #1480); the exchange ledger deliberately has NOlegal_holdcolumn until purge tooling exists to honor it — pre-archive holds are the run-row flag, and archived objects cannot be destroyed by any shipped path. -
Operational acceptance:
ssa_screening_oldest_pending_seconds(per-pass, zero-filled),ssa_screening_job_errors_total{class}+ssa_retry_exhaustion_total(post-commit at the retry choke point),ssa_partner_request_duration_seconds{outcome},ssa_retention_lag_rows{class=transport|provenance}+ssa_screening_held_runs(hourly, the sweep’s executed pass), andssa_screening_ready(the minute-cadencessa-readiness-probeworker re-usinglive_preconditions, spawned only while the gate is on). Deviation, recorded: the plan’s "readiness degrades on enabled-but-unready" lands on the METRICS plane (gauge + transition-edge warn + runbook alert), not/readyz— the ssa-worker isObservedby recorded decision, and a partner-side condition must never unroute or restart the pod. Two report-only invariants (ssa_transport_retention_overrun,ssa_provenance_hot_window_overrun) watchdog the windows; the operator runbook isoperations/ssa-screening-ops.adoc.