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-verificationreturns the digest (the base64ssn_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-verificationcarries 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 → 409STALE_ATTESTATION; no SSN → 409MISSING_SSN. -
Every
ssnwrite 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 columns — ssn, 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
#162screening pipeline (ADR-065 §D5, Option 1 ratified 2026-08-14) adds the ONE machine path tossn_verified: an exchange-only S2S CAS, value-bound to the screening run’s stagedexpected_digestvia 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 intopersons.ssnwhen 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_hmacsibling, 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, andssn_verifiedis 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 throughcraig_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_ssnSKIPS 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_promotedcounter; 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
ssnon every channel and forwards it verbatim (craig-intakeapi/validation.rsentry structs + the sanitizer’s deliberate ssn skip;sink/cases_forwarder.rsverbatim-forward pin), while the standalone-SHINES profile sinks submissions to SHINES rather than cases (backend/mod.rsrouting) 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.
-
Related
-
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