ADR-051: Person Full-SSN Custody and the Federal-Export PII Path

On this page

Status

Accepted (2026-07-19). Implemented by #1064 (Gateway ACF-199 program, epic &68; plan plans/gateway-199-reporting.adoc §D3/§D4). Designed under the R5-reviewed rework: value-bound verification, pure-service denial, and the clear-all-columns correction path came out of that review’s PII-lifecycle findings.

Context

The ACF-199 TANF Data Report requires the SSN of every aided child (element #69, affiliation-1 child records; the caretaker relative’s SSN is NOT required — 999999999 is permitted for adult affiliation-3 records). CRAIG previously stored only an encrypted ssn_last_four: sufficient for worker-facing identification, structurally insufficient for federal reporting — and #162’s SOLQ verification needs a plaintext SSN to query SSA at all.

Storing full SSNs is a custody decision, not a column addition: entry surfaces, display posture, verification semantics, correction/removal, read auditing, and the exit path all have to be decided together or the PII leaks through the seam nobody specified.

Decision

D1 — Custody: an ADR-049 registry field, at rest only in craig-cases

persons.ssn is a FieldScheme::BlindIndex registry field (crates/craig-cases-fields/src/persons.rs): CGEF ciphertext at rest, with a deterministic ssn_full_hmac sibling under its own HKDF domain (field_domain = "ssn_full", v1) — domain-separated from the last-four’s "ssn" domain so the two columns can never be correlated. Full SSNs never rest outside cases' encrypted columns: not in reporting’s DB, not in the object store, not in logs, not on the event bus.

ssn is excluded from the persons search descriptors: full-SSN equality search would hand realm-flat caseworkers a confirmation oracle. The HMAC
partial index exist for write-time duplicate detection and #162’s future SDX/BENDEX matching only.

D2 — Write-only entry; derived last-four

The API accepts ssn on person create/update (digits with space/dash separators; canonicalized service-side; SSA range-validated — area not 000/666/9xx, group not 00, serial not 0000 → 400 INVALID_SSN). No read surface serializes ssn or ssn_full_hmac (#[serde(skip_serializing)] on the store model — person reads return the model directly).

Setting ssn derives and overwrites ssn_last_four + ssn_hmac in the same statement — one source of truth. Direct last-four entry while a full SSN is on file (or both fields in one request) → 400 SSN_LAST_FOUR_MANAGED; the on-file check runs inside the update’s FOR UPDATE locking transaction.

Entry is web-only: the person-edit page carries a masked input. The CLI deliberately has NO full-SSN argument — command-line values persist in shell history and process listings.

D3 — Value-bound verification

Verification state is person-level (ssn_verified + at/by/method; DB CHECKs make partial state unrepresentable, including ssn_verified ⇒ ssn IS NOT NULL). The lifecycle is value-bound:

  • GET /v1/cases/persons/{id}/ssn-verification returns the digest (the base64 ssn_full_hmac) — the one deliberate exposure of the sibling. The keyed HMAC is non-reversible (the HKDF field key never leaves cases), so the digest identifies the stored value without revealing it.

  • POST …/ssn-verification carries that digest + an evidence method. The write is a single guarded statement (… WHERE id AND ssn_full_hmac = $digest AND ssn IS NOT NULL): no validate-then-write window. Digest mismatch → 409 STALE_ATTESTATION; no SSN → 409 MISSING_SSN.

  • Every ssn write resets verification — same-value included. Deliberate policy: re-entry never re-verifies, even though the deterministic HMAC would make equality knowable; do not "optimize" the reset away.

  • DELETE …/ssn-verification (reason required) revokes.

  • Attribution is claims.acting_worker() throughout (ADR-028).

Authorization: Action::Approve on ResourceType::Person, PLUS an in-handler pure-service denial (claims.acting_worker().is_service() → 403): machine identities never attest — the person rulesets grant service principals allow-all, and the literal is_service() check would wrongly 403 craig-web’s BFF (service token + lifted actor). Per-jurisdiction consequence, pinned by L3 tests: the person ResourceRef carries no supervisor linkage, so under the Texas ruleset i_supervises can never match — TX attestation is admin/regional only; GA admits admin+supervisor via existing allow-all rows (no ruleset change; added rows would be dead rules).

D4 — Correction: the audited clear

DELETE /v1/cases/persons/{id}/ssn (reason required; Action::Approve + an in-handler admin gate — a ruleset-routed check alone cannot be admin-only since GA supervisor rows are allow-all; pure-service denied) clears all four SSN columnsssn, ssn_full_hmac, ssn_last_four, ssn_hmac — plus verification state. The derived last-four is wrong-person PII residue and MUST go with the SSN; a still-valid standalone last-four is re-entered through the normal path afterward (permitted once no full SSN is on file). This is the wrong-person / merge / data-subject-correction remedy; ssn is deliberately non-clearable via ordinary update.

The revoke/clear reason reaches the durable audit trail via the case.person_ssn_admin_action event (→ audit_log through the # subscriber) — deliberately NOT via tracing: free-form correction text can carry PII, and service logs are neither access-controlled nor durable. It is the one person-event payload carrying worker-authored text; the UI labels the field "audited" so authors know where it goes.

D5 — The federal export: the single exit, with a read-audit

POST /v1/cases/persons/federal-export (service-only: require_service_caller() + the craig-reporting allowlist) returns demographics + ssn_last_four + ssn_present/ssn_verified + ssn_digest (for the blocked gateway-199 transmit re-affirmation — consumers cannot compute it), and the decrypted full ssn ONLY when the caller sets include_full_ssn = true AND the row is ssn_verified. Unverified SSNs never leave cases through this purpose. Responses carry Cache-Control: no-store; ids are capped at 5,000 per call (400 BATCH_TOO_LARGE; consumers chunk).

Every call stages case.person_pii_exported on the transactional outbox — the repo’s first read-access audit event — carrying the requestor service, the person-id list (UUIDs are not PII; an incident must be able to answer "whose SSNs left"), the include_full_ssn flag, and a purpose string. It reaches audit_log via craig-security’s # subscriber.

Anticipated exception: #162’s SOLQ verification will need UNVERIFIED SSNs (verification is the point). That is 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. Until then this endpoint is the only full-SSN-emitting surface.

Consequences

  • The gateway-199 export (#161, blocked) and #162’s SOLQ both have their SSN source; attestation gives federal reporting an auditable verified-SSN gate.

  • Person mutation events (case.person_updated) fire on attestation, revocation, and clear — the lifecycle is observable without PII on the bus.

  • Seeded children carry range-valid full SSNs with derived last-fours and deterministic attestation metadata; seeded adults keep standalone last-fours (a legitimate pre-custody state).

  • The impl_encryptable_row! macro supports multiple HMAC siblings; any row shape missing the new slots fails loudly (RowMissingHmacSlot), never a silent plaintext write.

Amendments

  • #162/UD2 (2026-08-14) — SOLQ digest-bound machine verification (amends §D3’s pure-service denial). The #162 screening pipeline (ADR-065 §D5, Option 1 ratified 2026-08-14) 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). Implementation lands with plan units B2/B5 (epic &81).

  • #1066 (2026-08-08) — link-time SSN promotion from report envelopes (the recorded PII review for the new population path). Report person entries (partner/SDK-submitted, SHINES-shaped children[].ssn / adults[].ssn, resting inside the encrypted-opaque report envelopes) are now promoted into persons.ssn when the entry is LINKED to a person — by the §G4 auto-link consumer (which re-promotes over the report’s TABLE-TRUTH links each attempt, so replays and pre-conversion manual links converge) and by the manual link endpoint (atomically with its link transaction). The review’s findings, each load-bearing for the D1–D5 posture:

    • Weakest provenance, by construction. The single guarded UPDATE can only land on a person with NO full SSN whose on-file last-four (if any) agrees with the entry’s derivation — keyed rows compare the deterministic ssn_hmac sibling, keyless-Optional rows compare the plaintext base (the update no-op gate’s own fallback precedent). Promotion can never displace a worker-entered or attested value, and ssn_verified is structurally out of reach (the promotion params carry no verification columns; the DB CHECK forbids half-states). Attestation and #162 remain the only verification paths.

    • No new read surface. Both hook sites already decrypt the envelopes for their pre-existing work (auto-link entry collection; manual-link bounds-checking); promotion adds a FIELD read (ssn) from an already-decrypted value — deliberately via the raw JSON, never through craig_matching::JsonbEntry, which keeps its no-ssn shape so SSNs stay out of the matching layer entirely. The envelopes remain immutable and opaque at rest; no new decrypt call, endpoint, or serialization exists (the D2 no-read-surface rule is untouched — promotion tests assert at the table).

    • Junk-tolerant, value-free. Envelope values are unsanitized hearsay (the intake J4 rule forwards them byte-identical); a value failing canonicalize_ssn SKIPS without failing the link or conversion and without logging the value (a debug line carries role + index only).

    • Event-bus exposure is a COUNT. The auto-link rollup event gains the additive ssn_promoted counter; per D1, values (and their digests) never ride the bus. The manual-link path logs a value-free promotion label.

    • Population origin (per the #1066 recon, not established by the change itself). Intake accepts ssn on every channel and forwards it verbatim (craig-intake api/validation.rs entry structs + the sanitizer’s deliberate ssn skip; sink/cases_forwarder.rs verbatim-forward pin), while the standalone-SHINES profile sinks submissions to SHINES rather than cases (backend/mod.rs routing) and the public browser form only offers the field under that profile. Envelope SSNs reaching cases therefore come from partner/SDK submissions on the integrated deployment — the SHINES-bound population manual re-keying previously served.

  • ADR-041 — name/DOB/last-four posture this extends

  • ADR-048 — key custody + boot verification

  • ADR-049 — the registry that declares the field

  • ADR-028 — acting-worker attribution

  • Gateway ACF-199 plan — §D3/§D4 as-built; the blocked export design

Edit this page · latest