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 ssa_solq kind is registered but refuses activation, the generic pipeline refuses its kind at four independent layers, and the digest newtype is wire-only.

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 restADR-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 identifiersADR-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 retentionADR-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_solq partner row existed, any authorized caller could push a raw-SSN payload through the generic POST /v1/exchange/send, and it would persist verbatim in exchange_transactions + exchange_send_jobs (the generic pipeline stages Value payloads by design).

  • S2 — the WIC partner template’s error path preserves upstream response bodies into last_error, exchange.failed events, 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 (ssn_last_four, reporter fields, report envelopes)

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/Value surface; every operational surface is categorical (value-free); provenance-grade rows keyed by SsnDigest, never by SSN; plaintext SSN exists only wire-transiently (§D2)

The class rules, each with its enforcement home:

  1. No generic transit. SSA adapter kinds are DispatchClass::TypedOnly: the generic exchange pipeline (which persists Value payloads 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).

  2. 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.

  3. 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 canonical v{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).

  4. 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)

SsnDigest

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 terminally ssn_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’s created_by, captured from the live actor at B3 stage time), not X-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 from ssn-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 is SsnDigest’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-`String digest fields onto.

  • Refusals are audited too: the digest-class outcomes (digest_mismatch / digest_version_rotated / missing_ssn) stage the categorical case.person_ssn_release_refused event (UUIDs + reason token only — no digests) in their own tx before the 409 returns; releases stage the case.person_pii_exported family with purpose = 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):

  1. 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 injecting craig_correlation_id into a Value map 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.

  2. 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’s ReportSummaryRow is deliberately not Serialize — 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 shared craig_cases_contracts::screening_read DTOs are the only exits — cases serves the household-scoped outcome list + current facts and relays exchange’s cases-only latest-run view; SsaScreeningRun lost its convention Serialize derive and now maps only the columns its consumers read. #1557 amendment: the latest-run view additionally carries cohort_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

ssa_screening_runs / ssa_screening_members, benefit facts, ssa_import_batches / ssa_import_records

Provenance (audit) class

archive-then-prune via the shared craig-retention engine; NEVER hard-deleted while unarchived; purge refuses until DFCS names a schedule (ADR-058’s D14 posture; ⁂ #1480). C1’s terminal timestamps (H14) make the rows age-addressable. Precondition inherited from ADR-058: archive-then-prune requires INSERT-ONLY sources — B1/C1 schema design must keep these tables append/supersede-shaped, and onboarding any mutable SSA table needs a new decision. Known conflict (2026-08-16), RESOLVED 2026-08-17: the shipped B1 runs/members are UPDATE-in-place; #1479 resolved it as the explicit-amendment path — runs/members are admitted under mutation-quiescent-at-eligibility bounds (the scoped ADR-058 §"insert-only precondition" amendment ships in the same B5 MR as the craig-retention spec.rs doc rewrite). Decision record: § Amendment #1479.

SSA typed job rows + SSA exchange_transactions rows (delivery mechanics)

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 ssa_transport_handoffs handoff relation + the ssa-transport-sweep worker — § Amendment #1566.

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_error is 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: /v1/cases/persons/{id}/ssn-verification) whose guarded UPDATE binds to the expected_digest the run staged — the same no-validate-then-write-window shape as ADR-051 §D3’s human attestation (… WHERE id AND ssn_full_hmac = $digest AND ssn IS NOT NULL). Method value distinguishes machine verification (e.g. solq_match) from human evidence methods. Digest drift between staging and the CAS → 409, no write — the value-bound guarantee survives end-to-end.

The SOLQ outcome lives ONLY in screening-member outcomes + benefit facts; ssn_verified stays exclusively human-attested. Eligibility consumers read the run witness (UD1 assembly) rather than the person flag.

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 REQUIRED ErasedAdapter method — no default, so a hand-written impl must choose a class rather than inherit Generic by omission (crates/craig-exchange-contracts/src/erased.rs:38-45,105-110). The new typed_only macro arm pins TypedOnly AND makes the erased send_value/audit_value refuse unconditionally with the categorical ErasedAdapterError::TypedOnlyDispatch (kind token only, never payload bytes — erased.rs:136-144,317-373; the canary test at erased.rs:633-668 pins the no-echo property). Above that innermost layer sit three checkpoints, all keyed on the PARTNER-ROW kind via ExchangeRegistries::dispatch_class_of — never resolve(), whose noop:// substitution would mask the kind: send pre-claim 422 (services/craig-exchange/src/api/dispatch_guard.rs:21-38, called at api/transactions.rs:138), retry in-tx pre-restage (an Err rolls back the status flip; dispatch_guard.rs:75-87, called at api/transactions.rs:441), and the worker pre-resolve terminal typed-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). SsnDigest in crates/craig-cases-contracts/src/ssn_digest.rs: canonical one-string wire form v{n}:{44-char base64} (serde try_from/into String, :51-60); PartialEq IS the subtle::ct_eq constant-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); redacted Debug (version tag only) and NO Display{} 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 — no sqlx::Type (B1 decides the column shape), no ToSchema (consuming DTOs annotate value_type = String, the Sha256Hex precedent).

  • A1 — the value-free adapter (1427, S2/S10). crates/craig-partner-ssa-solq: SsaSolqError is 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 or mock feature (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-read Vec<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-crate impl_erased_adapter!(SsaSolqAdapter, "ssa_solq", typed_only) at crates/craig-partner-ssa-solq/src/adapter.rs:131). PINNED_KINDS 11→12 with the lockstep test asserting generic-path rejection for every member of PINNED_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 403 FEATURE_DISABLED naming the lift, keyed on dispatch_class_of (never the literal token), covering create, kind-flip-while-active, and PATCH {active: true} resurrection (dispatch_guard.rs:50-67, called at api/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 terminal typed-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 seeded ssa_solq row 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. SsnDigest is 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.

  • 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-Serialize projection 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) in craig-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 flips main.rs None → Some (the recorded S4 posture: activation changes CODE, never config). The harness-only NonAuthoritativeStub variant exists solely under the contract-stub feature, 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_preconditions checks the witness first: None ⇒ pause contract-pending (hyphenated, a recorded deviation from #1479’s contract_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, future auth-unconfigured); TERMINAL = a per-job/member fact no configuration heals (plus new freshness-expired). Post-send failures are never pause-class.

Run lifecycle (D3/D4, forks 1/6).

  • The invalidation_reason CHECK 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 only completed writer" 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_at stays 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’s paused AND unclaimed pending jobs → failed/run-invalidated with completed_at; terminal affected pending members → cancelled; then complete_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_resumed audit event preserves the pre-reset count; the shared #1185 claim shape is untouched. New paused_at column: pause_job stamps, resume clears.

Freshness + retention — resolves §D4’s known conflict (D8–D11, forks 2/4).

  • Fork 2 (RATIFIED) — the freshness no-op: invalidating a completed run 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 the craig-retention spec.rs insert-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_sql re-asserting the fixpoint, converting status-lattice regressions into the existing PruneCountMismatch rollback. Zero craig-retention engine changes. invalidated_at is 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_revision on 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 = completed AND past the 12-month quiescence horizon; the 12-month SQL literal is probe-asserted equal to SCREENING_FRESHNESS_MONTHS (never two numbers); the exchange SERVICE archive hot_window_days knob is load()-validated ≥ 365 d (per-engine, not per-table — recorded); purge stays refused pending DFCS (#1480). §D1 residual-risk note extended: to_jsonb archive NDJSON carries ssn_digest/cohort-hash values fetchable via xtask 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 B5 invalidate_run writer per run (never raw SQL) + the B6b invalidation event and stages the ssa.stale_cohorts_invalidated operator 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-transition update_person with the keyless fallback arm mirrored, clear_ssn, manual-link promotion, and §G4 auto-link promotion with the attempt tx threaded through promote_report_links; P7/P8 have no surface and are pinned as a data-model-page contract, not dead enum variants). Runs carry cohort_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, reusing case.person_updated (structurally insufficient), hash-compare (unsafe under reordering).

  • Fork 5 — MINIMAL event payload: ONE PII-free case.screening_cohort_changed outbox event per affected case, staged in the mutation’s own tx — payload = case UUID + cohort_revision + a categorical change token ONLY; no person_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-way cohort_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 B6 exchange.screening_run_invalidated event — auto-supersede IS the legitimate recovery mechanism); equal ⇒ 409 SCREENING_RUN_ACTIVE; older ⇒ 409 SCREENING_COHORT_STALE; the supersede 0-row race re-checks/409s, never falls through to the partial-unique 23505. StageScreeningRunRequest grows REQUIRED cohort_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_facts rest in cases (§D2’s custody home, confirmed), born insert-only: pointer-forward supersession chain (supersedes_id FK 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_role joins 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 not Serialize (§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, categorical last_error, timing). FK direction honors the migration-recorded rule (provenance never FK-depends on transport): the handoff FKs the MEMBER ON 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, worker ssa-transport-sweep) prunes terminal ssa_screening_jobs + their SSA exchange_transactions rows hourly under its own advisory lock, batched, windowed by CRAIG_EXCHANGESSA_SCREENINGTRANSPORT_RETENTION_DAYS (load()-validated 0..=90 naming 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 at load()). 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 a REPLAY_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-asserts NOT 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 to status, completed_at, invalidated_at, invalidation_reason, legal_hold (probe updated) — unlike invalidated_at (writer-side-refusal-only), legal_hold is 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 staging exchange.screening_hold_set/_cleared in the flip transaction (the reason rides the EVENT, never the row — the flag stays a bare boolean, the archive_records precedent). 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 + the archive_ledger migration — the craig-rules ledger shape verbatim, cargo xtask archive-fetch craig-exchange wired): three FK-ordered specs (ssa_transport_handoffs, ssa_screening_members, ssa_screening_runs), each embedding the family fixpoint (run completed AND past the 12-month quiescence horizon AND NOT legal_hold; the months ⇒ 12 SQL literal is probe-asserted equal to SCREENING_FRESHNESS_MONTHS) with NOT EXISTS deletion-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: unlike rule_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_id etc.) 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 NO legal_hold column 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), and ssa_screening_ready (the minute-cadence ssa-readiness-probe worker re-using live_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 is Observed by 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 is operations/ssa-screening-ops.adoc.

Edit this page · latest