Plan: Gateway ACF-199 Data Export (#161)
On this page
Status
| Step | Description | Status |
|---|---|---|
0 |
Original plan + GitLab lockstep: 2026-03 draft revalidated + rewritten, nav Planned → Active, |
Done (2026-07-19) — commits 94bb71ac (rewrite) + a8cd84fd (status flip); 4 internal review rounds |
0b |
R5 restructure (external review: 17 stop-ship / 24 material / 8 consistency): program re-shaped per user steers — subsidy capability split to epic &70 (#1067–#1073), Gateway contract chore #1074 filed, #1065 closed superseded, #161 blocked, #1064 re-synced with cluster-C design, follow-ups #1075–#1080 filed, this revision authored + lens-reviewed |
Done (2026-07-19) — GitLab restructure verified via API reads; revision authored on this branch (committed after lens rounds CLEAN) |
1 (MR1) |
#1064 — craig-cases encrypted full-SSN custody + value-bound verification (ADR-051): registry field + multi-sibling row machinery, write-only semantics, atomic update + derived last-four, attestation/revocation/audited-clear lifecycle, service-only federal-export with read-audit, web-only entry |
Done (2026-07-20) — MR !1010 merged (5c939f0f; commits 82e3a7e3 + 0cfbfb60 + 6a2d2318); #1064 closed; J1–J8 4 findings remediated; battery caught + fixed the seed RNG-stream shift |
2 |
(was MR2 — UAS on rate tables) |
N/A — superseded 2026-07-19: classification belongs on the enrollment artifact; #1065 closed → epic &70 child #1072 (UAS on subsidy agreements) |
3 (MR3) |
#161 — the gateway-199 export lifecycle (Blocked living spec in §D5+; re-validate against epic &70’s as-built + the real ingestion contract at unblock) |
Blocked (epic &70 — no subsidy payment streams exist yet, #1067 foundation; and #1074 — Gateway ingestion contract unobtained) |
4 |
Follow-ups + reconciliation |
In progress — #1066 + #1075–#1080 filed 2026-07-19; plan archive + epic &68 close-out at program end |
Issues: #1064 (active, MR1) · #161 (blocked anchor) · #1074 (contract chore) — epic &68; prerequisite
epic &70 (#1067–#1073)
Branch: feature/gateway-199-reporting (plan revisions); MR1 on feature/1064-person-ssn-custody
Provenance: 2026-03 draft → full revalidation rewrite 2026-07-19 (9-area survey; 4 forks steered:
rate-table UAS / last-four SSN posture / realm-flat RBAC / bundle vocabulary; 4 internal contextless
rounds to CLEAN). R5 external review (user, 2026-07-19): 17 stop-ship + 24 material + 8 consistency
findings — plan pulled back. Rework grounded by three explorers (financial payment reality; verified GA
22.8/22.9 + ACF-199 instructions; in-repo remediation precedents). New steers 2026-07-19: restructure
block (subsidy capability = separately-planned epic &70; #161 blocks on it + the ingestion contract)
and #1064 proceeds now. The earlier "rate-table driven" steer is VOID — its option set was wrong:
UAS classification belongs on the program-enrollment artifact. Review lenses for every future round:
domain-policy grounding, trigger reachability/data sufficiency, money/as-of semantics, false-success
paths, crash/race coordination, PII lifecycle, per-jurisdiction service-principal posture, lockstep
re-diff.
Context
45 CFR requires states to report TANF relative-care payments (Georgia UAS codes 542/552/553) in the quarterly ACF-199 TANF Data Report. Gateway produces Georgia’s ACF-199; CRAIG (succeeding SHINES per OGG CR 3787) must feed it relative-care payment records with child demographics. Data-integrity requirement (#161): verified SSN on every transmitted child record, no blank required fields.
Verified domain facts the program must honor (2026-07-19 research; GA CW manual 22.8/22.9, COSTAR 3300, ACF-199/209 instructions OMB 0970-0338, 45 CFR 265):
-
542 ERR / 552 SG (550 non-relative) / 553 RCS are lifecycle-distinct monthly subsidy programs, not rate variants: ERR = initial kinship subsidy during DFCS custody (120-day foster-approval clock; ends when per diem begins); SG = post-permanent-guardianship (signed agreement BEFORE transfer; payments start the 1st of the following month); RCS = closed cohort since 2014-01-01, renewals only. Enrollment = signed program-specific Application & Agreement + 12-month renewal (+6-month paper review for SG/NRSG/RCS); missed review ⇒ suspension (distinct state). Payments are monthly — SG/NRSG from the 1st of the month after transfer; ERR effective from the placement date (first month may be partial — &70 planning confirms proration) — with subsidy/per-diem/TANF same-month mutual exclusivity; age 18 end (19 with school/GED predicate).
-
CRAIG has none of this today: one payment writer (first billing period only, per-diem-shaped, from
placement.activated,services/craig-financial/src/main.rs:339-427), no recurring generator, no subsidy/enrollment entity, and guardianship finalization voids remaining payments (store/payments.rs:201-215). 552/553 are unreachable; the 199 population barely exists. Hence epic &70 (kinship subsidy programs: #1067 agreements, #1068 monthly generator, #1069 ERR, #1070 SG, #1071 RCS import-only, #1072 UAS-on-agreement, #1073 DFCS confirmations) is the prerequisite, and #161 is Blocked on #1067 + #1074. -
ACF-199 child-only relative pattern: family Type-of-Family 3 + time-limit exemption 02; caretaker = adult record affiliation 3 (SSN 999999999 permitted, race/ethnicity required); each aided child = affiliation 1 with SSN mandatory — which is why #1064 (child full-SSN custody) proceeds now. Race/ethnicity are SIX separate yes/no elements (CRAIG’s single-valued
persons.raceis a known mapping limitation for the real contract). Case numbers must not embed SSNs. Quarterly files due in 45 days; penalty-free corrections through the following quarter; corrections post to the next open submission — never rewrite closed files (as-of snapshot doctrine). -
The Gateway-generates-199 fact is internal (OGG); the ingestion contract (fields, auth, ack/reject, idempotency) is unobtained — #1074, go-live-dependency pattern (#691–#693).
Scope
In (active now):
-
MR1 = #1064: person full-SSN custody + verification lifecycle in craig-cases (ADR-051), §D3/§D4 — the R5 cluster-C design. Independently valuable: hard ACF-199 child-record requirement + the plaintext source #162’s SOLQ queries need.
Blocked (living spec preserved, §D5+): the export lifecycle — tables, generate/review/approve/transmit, clients, rulesets, CLI/web/seed/e2e — corrected per R5 and parked until epic &70 lands payment streams and #1074 lands the contract.
Out (tracked): the subsidy capability itself (epic &70); conversion-time SSN promotion (#1066); SSA
verification automation (#162); pre-existing defects found during rework — reporting CAS race (#1075),
acting_worker attribution (#1076), end_date double-Option wire gap (#1077), adjustments deny/events
(#1078), claims accepted unreachable (#1079), capability-overstatement docs (#1080).
Design
D1/D2 — superseded (UAS classification moved to the enrollment artifact)
The 2026-07-19 rewrite hung uas_code off rate tables, denormalized at event-driven payment creation.
R5 proved the mechanism wrong at the domain level: the rate matcher’s inputs (jurisdiction, payment type,
age, date — store/rates.rs:189-214) cannot encode program enrollment (same-age kinship children in
542 vs 553 are indistinguishable; the disjoint-age-band seed split invented policy), and the
placement-activation trigger can never reach 552/553. UAS classification lives on the subsidy
agreement — epic &70 child #1072 (effective-dated bundle reference data; agreements validate
program↔code at their write boundaries; generated payments denormalize from the agreement). #1065 closed
superseded.
D3 — Person full-SSN custody in craig-cases (MR1 = #1064; ADR-051)
Custody + machinery:
-
Registry (
crates/craig-cases-fields/src/persons.rs): fieldssn—FieldScheme::BlindIndex, HKDFfield_domain = "ssn_full"v1,Canon::AsIsover the pre-canonicalized digits; siblingssn_full_hmac. Migration:ssn TEXT(ciphertext) +ssn_full_hmac TEXT+ partial index
verification columns + the CHECK below.ssnis excluded from the persons search descriptors in MR1 — full-SSN equality search would be a confirmation oracle for realm-flat caseworkers; the HMAC
index exist for write-time duplicate detection and #162’s future matching, not search capability (stated in the registry doc-comment). -
Row machinery is explicit work: extend
impl_encryptable_row!(crates/craig-search/src/row.rs:101-135, single-sibling today) to multiple HMAC siblings (or hand-write impls); updateCreatePersonParams/UpdatePersonParams/Person(services/craig-cases/src/api/encryption/rows.rs) + the seederPersonshape (tools/craig-seed/src/encrypt.rs). Missing a shape hard-errorsRowMissingHmacSlot("ssn_full_hmac")on person writes. The ADR-048 boot verify scan handles the new nullable encrypted column automatically; nothing else does.
Input + write semantics:
-
DTO
ssn: Option<String>:[garde(length(max = 11))]+[schema(max_length = 11)](#492 cap-sync; garde ships without the regex validator). Service-side canonicalization accepts ONLY digits with space/dash separators (letter-embedded input rejected —123x45x6789is not an SSN with decoration), then exactly-9 digit shape AND SSN range validation (area ∉ {000, 666, 900–999}, group ≠ 00, serial ≠ 0000) → 400bad_request_typed(INVALID_SSN)(new craig-common constant). -
Write-only:
#[serde(skip_serializing)]onPerson.ssn+Person.ssn_full_hmac(thessn_hmacprecedent — person reads return the store model; there is no read-DTO layer). Reads expose last-four only. Not clearable via update — correction is the audited clear operation below. -
Atomic writes: the update tx takes
SELECT … FOR UPDATEon the person row; CASE-arm SQL (the store’supdate_personis COALESCE-only today,store/persons.rs:80-94) writesssn
ssn_full_hmac+ derivedssn_last_four+ssn_hmacand resets ALL verification columns on EVERY ssn write, same-value included — deliberate policy: re-entry never re-verifies, even though the deterministicssn_full_hmacwould make equality knowable in SQL; do not "optimize" the reset away. TheSSN_LAST_FOUR_MANAGEDon-file check runs INSIDE the same locking tx. Create-with-ssn derives the same four columns + verification-false. Directssn_last_fourentry while a fullssnis on file — or both fields in one request — → 400bad_request_typed(SSN_LAST_FOUR_MANAGED)(new constant). New full-SSN writes carry the digit-shape guarantee (#1023’s DTO-boundary fix for standalone last-four stays that issue’s scope).
Verification lifecycle (value-bound, per-person):
-
Columns
ssn_verified BOOLEAN NOT NULL DEFAULT false,ssn_verified_at TIMESTAMPTZ,ssn_verified_by TEXT,ssn_verification_method TEXT(evidence source). DB CHECK:ssn_verified ⇒ (verified_at, verified_by, method) NOT NULLandNOT ssn_verified ⇒ all three NULL— method is nulled wherever the other two are (reset-on-write, revocation, clear). -
Digest acquisition
GET /v1/cases/persons/{id}/ssn-verification→{ digest: Option<String> (base64 `ssn_full_hmac, matchingCodec::Base64), ssn_present, ssn_verified, verified_at, verified_by, method }` — same authz as attestation (Action::Approve) + in-handler service denial. The keyed HMAC is non-reversible (HKDF field key held only by cases), so exposing it to attesters is safe; ADR-051 argues this. Without this surface the value-bound flow is unimplementable (ssn_full_hmacis skip-serialized on every other read path). -
Attestation
POST /v1/cases/persons/{id}/ssn-verification: body carries the digest fetched above + method. The attestation write is itself a single guarded statement (no validate-then-write window):UPDATE persons SET ssn_verified=true, … WHERE id=$1 AND ssn_full_hmac=$2 AND ssn IS NOT NULL RETURNING *; zero rows → re-read to disambiguate: person missing → 404;ssnNULL → 409conflict_typed(MISSING_SSN); hmac mismatch (SSN changed since the attester fetched) → 409conflict_typed(STALE_ATTESTATION)(all 409s viaconflict_typed, neverbad_request_typed). DB CHECK additionally enforcesssn_verified ⇒ ssn IS NOT NULL. Attributionclaims.acting_worker(). Authzauthz.check(…, ResourceType::Person, Action::Approve)— GA: admin+supervisor already allowed (allow-all rows; no ruleset change, pinned by tests); TX: the person ResourceRef carries no supervisor linkage (persons.rs:35-44) soi_supervisesnever matches → TX attestation = admin/regional only, deliberate and pinned. In-handler PURE-service-caller denial —claims.acting_worker().is_service()→ 403 (the Plan E §9b predicate; composition precedent atwrite_support.rs:212). NOT the literalclaims.is_service(), which would 403 craig-web’s own BFF calls (service token + liftedX-Craig-Actor— the sole entry surface). Test pins split accordingly: pure service token 403 (evil) vs craig-web-style service token + verified actor → 200 with acting-worker attribution (happy). The person rulesets grant service principals allow-all and changing that is out of scope here. This predicate applies to ALL FOUR verification endpoints (digest GET, attest, revoke, clear). -
Revocation
DELETE /v1/cases/persons/{id}/ssn-verification(reason required): same authz + service denial, nulls all verification columns, audited. -
Audited clear
DELETE /v1/cases/persons/{id}/ssn(body{ reason }, required): clears all four SSN columns —ssn,ssn_full_hmac,ssn_last_four,ssn_hmac— plus all verification columns. (After any full-SSN write the stored last-four IS derived from it, so retaining it would keep the wrong person’s PII residue; a still-valid standalone last-four is simply re-entered through the normal path afterward, which is permitted once no fullssnis on file.) The wrong-person/merge/data-subject remedy. Authz:authz.check(…, Action::Approve)PLUS an in-handler admin-role gate (a ruleset-routed check alone cannot be admin-only — GA supervisor rows are allow-all) and the same pure-service denial. ApiDoc-registered with the other three; evil-axis tests. -
Attestation/revocation/clear each stage person events on the transactional outbox (PII-free payloads, the
publish_person_updatedpattern —services/craig-cases/src/events.rs).
Entry surface — a NEW web person-edit surface (none exists today; craig-web has no person-edit
route/template — chain view is GET-only): GET/POST /cases/persons/{id}/edit in
services/craig-web/src/routes/cases/ (linked from the chain/case views), masked full-SSN input,
last-four display, attestation control (fetches the digest via the BFF, submits with it), revocation
clear controls gated to the roles that can use them, new cases-person-edit-/cases-ssn- FTL keys
(locales/en/web.ftl), #490 route-role markers on the POST routes. The CLI gains NO full-SSN
argument — shell-history/process-listing exposure; web-only entry is an ADR-051 decision.
D4 — Cases service-only federal export (MR1; consumer seam for #161/#162)
POST /v1/cases/persons/federal-export — require_service_caller() + "craig-reporting" allowlist
const + // authz: skip — annotation (the ncands.rs:26-78 pattern); ApiDoc registration for ALL FIVE
new cases endpoints (digest GET, attest POST, revoke DELETE, clear DELETE, this federal-export POST —
paths(…) is not compiler-forced; an unnamed registration silently ships missing from the API page).
-
Request
{ person_ids: Vec<Uuid>, include_full_ssn: bool (default false) }; >5,000 ids → manual length check → 400bad_request_typed(BATCH_TOO_LARGE)(existing constant; garde caps cannot emit typed problems). Consumers chunk at the cap. -
Response rows: demographics +
ssn_last_four+ssn_present+ssn_verified
ssn_digest: Option<String>(base64ssn_full_hmac— the value the blocked transmit re-affirmation compares; reporting cannot compute it, the HKDF key lives in cases); fullssnpopulated ONLY wheninclude_full_ssn = trueAND the row isssn_verified— unverified SSNs never leave cases via THIS purpose.Cache-Control: no-store. Unknown ids absent from the response. -
Each call stages a PII-export audit event (requestor service id, the person-id list,
include_full_ssn, purpose string) on the cases outbox — the repo’s first read-audit, and it must answer "whose SSNs left the service" in an incident (UUIDs are not themselves PII); reachesaudit_logvia the#subscriber. Doc-comment cites ADR-051: this is the only full-SSN-emitting surface today; #162’s SOLQ verification will need a distinct verification-purpose mode (separate allowlist entry, UNVERIFIED emission permitted — verification is the point — same audit event with a purpose discriminator), specced at #162 pickup; ADR-051 anticipates that exception explicitly.
D5+ — BLOCKED export spec
|
Blocked design — do not implement. Prerequisites: epic &70 payment streams (#1067 foundation) and the Gateway ingestion contract (#1074). Re-validate every item against &70’s as-built (agreements, monthly payments, UAS denormalization) and the real contract before implementation. Preserved so the R5 corrections cannot regress. |
-
Data sources: payments generated from subsidy agreements (&70 #1068) — monthly (SG/NRSG start the 1st of the following month; ERR is effective from the placement date, so the FIRST month may be partial — confirm proration in &70 planning),
uas_codedenormalized from the agreement; the financial export endpoint filtersstatus IN ('issued','cleared')and attributes each payment to exactly one quarter byperiod_endwithin the half-open FFY-quarter window, where quarter WINDOWS are half-open but paymentperiod_endis the inclusive last covered day (the existing financial convention —compute_period/inclusive_day_count+ theperiod_end >= period_startCHECK; &70 #1068’s generator must keep it, named in the re-validation checklist — a half-openperiod_endwould silently shift every quarter-final month into the next quarter). Keyset pagination + max-span bound + supporting index. Unclassified-candidate predicate (pinned): the financial endpoint ALSO returns rows matchinguas_code IS NULL AND placement/payment_type ∈ the jurisdiction’s relative-placement set(effective-dated reference data, not constants — financial evaluates the predicate; reporting materializes each as a validation failure). Never silently filtered. -
Quarters:
YYYY-Qn= federal fiscal quarter of FFYYYYY(Q1 = Oct–Dec of YYYY−1), parsed by a garde-custom validator in craig-reporting-contracts with half-open window derivation (2026-Q2 → [2026-01-01, 2026-04-01)), pinned test + never-panics proptest, half-open everywhere including the financial endpoint contract. -
Accounting basis: each submission captures an as-of cutoff timestamp at generate, applied as
issued_at ⇐ cutoffin the financial endpoint contract (post-cutoff status changes — voids, clears — are caught only by supersession, stated); rows snapshot everything except the full SSN (demographics, amounts, uas_code, bundle version,ssn_last_four,ssn_present/ssn_verified
thessn_digestfrom D4’s export row for transmit re-affirmation); history is never re-derived from current bundle contents. Late corrections → a NEW superseding submission (below). Late-arrival detection (the supersession trigger): generate additionally surfacesissued/clearedpayments whoseperiod_endfalls in a PRIOR period with a transmitted submission and whose issuance postdates that submission’s cutoff, as a quality issue naming the stale period — otherwise dollars are silently omitted during the 45 CFR 265.8(c) penalty-free correction window with zero operator signal. -
One active per period: partial unique index on
(reporting_period)over non-terminal statuses
superseded_bycolumn; regenerating a period supersedes-then-creates (case_plans lock-supersede-activate precedent) — supersession is a status flip to a new terminalSupersededvariant (vacating the index slot; a baresuperseded_bystamp would leave the old row non-terminal and the create would 23505). UNIQUE(reporting_period, revision)makes the idempotency key real. The supersede CAS’s expected-status set EXCLUDEStransmitting— an in-flight delivery must terminalize first (no successor revision can race a mid-POST worker into a double-submission under a different key). -
Counts: aligned with the NCANDS precedent —
record_count = COUNT()(total rows),validation_errors = COUNT() FILTER (WHERE NOT valid)(subset;ncands_child_rows.rs:287-289form) so the shared column names mean the same thing across programs — DB-authoritative in the generate tx. Zero rows ⇒validated_with_errors(terminal; exact NCANDS store semantics,store/ncands.rs:177-181) — which makes a legitimately EMPTY quarter untransmittable; whether Gateway needs an explicit empty-period signal is a #1074 contract question (named in the re-validation list). Missing-person rows: nullable name columns +valid=false+ notes (sentinel-free). The verified-SSN hard gate is DELIBERATELY stricter than federal (ACF element #69 permits a temporary000000000for an unobtainable child SSN with replace-later duty) — recorded choice with deadline exposure; placeholder handling is also a #1074 question. -
Lifecycle: the shared
SubmissionStatusenum gainsTransmittingandSupersededvariants, and the transition matrix becomes per-program (gateway-199’s chain addsapproved → transmitting → transmitted, the failure back-edgetransmitting → approved, and supersession edges; the AFCARS/NCANDS matrices are pinned UNCHANGED by tests — the shared matrix today is a strict forward chain and must not silently loosen). ALL transitions are CAS single-statements (WHERE id=$1 AND status='<expected>' RETURNING *) with the financial disambiguation protocol (404 / 400INVALID_STATE_TRANSITION/ 409CONCURRENT_MODIFICATION). Actor stampsgenerated_by/reviewed_by/approved_by/transmitted_byviaclaims.acting_worker(); approve and transmit must be distinct actors — enforced IN THE TRANSMIT CAS PREDICATE (… AND status='approved' AND approved_by IS DISTINCT FROM $actor) with a typed 403 + test, NOT in rulesets (per-jurisdiction fixtures are replaceable; a federal-integrity invariant must not be silently repealable). Rulesets stay role-gating only. (Deviation from AFCARS noted.) -
Transmit: no stub mode. URL unset → typed 503
TRANSMIT_TARGET_UNCONFIGURED, zero state change; devstack/e2e set the URL to craig-mock-server. When configured: the transmit tx CAS-flipsapproved → transmittingand stages a delivery job (send-jobs pattern: payload digest, correlation UUID UNIQUE, attempts,last_error, receipt column; UNIQUE(submission_id) WHERE job non-terminal — at most one live job per submission); a worker claims viaFOR UPDATE SKIP LOCKED, re-fetches SSNs (include_full_ssn=true, verified-only) and re-affirms each row’sssn_digest
verified state (any regression → job terminalizes AND the submission CAS-flips back toapprovedin the same tx; 409conflict_typed(STALE_SUBMISSION)surfaced on inspection), POSTs once per attempt with bounded timeout,redirect(Policy::none()), https-required URL + host allowlist (relate #771),Idempotency-Key: <submission_id>:<revision>; stores the response; success CAS-flipstransmitting → transmitted+ stages the event. Terminal HTTP failure (attempt cap, send-jobs MAX_ATTEMPTS/backoff schedule) → job terminalizes + submission CAStransmitting → approvedin the same tx — never wedged intransmitting(the one status the supersede CAS excludes); a bootstrap recovery sweep (send-jobs precedent) flips stale in-flight jobs. Full SSNs exist only in the outbound request body — never in reporting’s DB, the object store, logs, or events. -
Export artifact (review aid, not "PII-free" — it carries demographics + last-four): object-store TSV without full SSNs; retention/download-audit posture recorded in the design when implemented.
-
Quality issues: generate writes
data_quality_issueswith the tx-awarecreate_issue(PgExecutor), full field set (field_name/description/severity),source_service= data-owning service, pairedreporting.quality_issue_detectedevents; dedup natural key(source_service, source_record_id, issue_type, field_name)unresolved-unique so regeneration cannot duplicate; issues auto-resolve when a later generate finds the row valid. -
Authz: new
ResourceType::Gateway199Submission(snake_case rendering pinned); per-jurisdiction rulesets, service callers DENIED in both (GA: admin+supervisor full, readonly read+list; TX: modeled on TX afcars — admin/regional/supervisor-with-supervises/readonly — NOT a GA clone). A compromised peer service must not be able to drive generate→transmit. -
Row model → ACF-199 mapping: deferred to the real contract (#1074); known limitation recorded — ACF-199 race/ethnicity are six independent elements vs CRAIG’s single-valued
persons.race; family/person record structure is Gateway’s derivation unless the contract says otherwise. -
Module layout: flat
api/gateway_199*.rsfiles each honestly < 500 lines (B1 is non-recursive; subdirectories evade rather than satisfy it). Contracts DTOs in craig-reporting-contracts; typed test-lib client methods (B7 flat); FEATURE_MATRIX_CRATES + compose env when financial gains bundle features (&70 #1072).
Test strategy (MR1 — #1064)
All tests axis-tagged; devstack-gated via TestHarness; typed clients.
-
Registry round-trip encrypt/decrypt/HMAC for
ssn(happy); boot-verify green underENCRYPTION_MODE: required. -
Write-only contract: person GET/search/list responses never contain a 9-digit SSN (
evil). -
Derived last-four +
ssn_hmacoverwrite on ssn write (happy); verification reset on every ssn write incl. same value (sad). -
SSN_LAST_FOUR_MANAGED: direct last-four with full ssn on file (sad); both fields in one request (sad). -
Attestation: admin + supervisor 200 (GA), caseworker 403, pure service principal 403 (
evil), digest mismatch 409 STALE_ATTESTATION (sad), no-SSN 409 MISSING_SSN (sad), acting_worker attribution via X-Craig-Actor (happy); TX pins: admin/regional 200, supervisor 403 (evil). -
Digest GET: returns base64 digest + status to authorized attesters (
happy); pure service token 403 (evil). Revocation happy/403; audited clear: admin 200 clears ALL FOUR SSN columns + verification, standalone last-four re-entry permitted afterward (happy), supervisor 403 + pure-service 403 (evil), missing reason 400 (sad); concurrent ssn-write vs attestation race — the guarded single-statement + digest mismatch pinned (conc). -
Federal export: service-gate 403 for user tokens (
evil); verified-only full-SSN emission — an unverified row’sssnis null even withinclude_full_ssn=true(evil);Cache-Control: no-storeasserted; BATCH_TOO_LARGE over-cap (sad); PII-export audit event staged (happy). -
Proptests (mandatory — parsers): canonicalization never panics; property: accepted input ⇒ exactly 9 digits in valid SSN ranges; rejected: letter-embedded, bad area/group/serial.
-
DB CHECK violations unrepresentable (constraint test); seed: subset of persons with SSN + verified state;
verify_seedcovered-surface pins gainssn/ssn_full_hmac.
MR sequence
| MR | Issue | Contents |
|---|---|---|
MR1 |
#1064 (Closes) |
D3 + D4 + ADR-051 + craig-common constants (INVALID_SSN, SSN_LAST_FOUR_MANAGED, STALE_ATTESTATION, MISSING_SSN) + the 4 cases endpoints (digest GET, attest POST, revoke DELETE, clear DELETE) + the NEW web person-edit surface + seed + docs (data-model-cases, api regen, CHANGELOG) |
— |
#161 |
Blocked — unblocks after epic &70 + #1074; the §D5+ spec then gets its own revalidation round + MR planning |
Verification
-
Plan revision:
cargo xtask plan-lint; lens-assigned contextless review rounds (domain-policy, money-semantics, failure/crash, PII-lifecycle, authz-posture, lockstep) to CLEAN before commit. -
MR1: full pre-push battery (
cargo xtask validate) withcargo xtask dev reseed(persons seed changes identity) +cargo nextest run -p craig-cases --run-ignored=all; api-page regen for cases against a rebuilt container. -
GitLab state verified by API reads (done for the 0b restructure).
Follow-on issues
Filed 2026-07-19: #1066 (conversion-time SSN promotion) · #1075 (reporting CAS race) · #1076
(acting_worker attribution) · #1077 (end_date double-Option wire gap) · #1078 (adjustments deny
events) · #1079 (claims accepted unreachable) · #1080 (capability-overstatement docs). At unblock
time: Gateway-format mapping issue against the real contract; SMILE cd_svc_dtl_service ↔ UAS
reference-data reconciliation.
Reference anchors
Verified policy: GA CW 22.8 (https://pamms.dhs.ga.gov/dfcs/cws/22-08/), 22.9 (…/22-09/), COSTAR 3300
UAS list (FY2011 vintage — effective-dated reference data, not constants), ACF-199/209 instructions
(OMB 0970-0338, exp 2026-10), 45 CFR 265.3–265.8. Code templates: financial CAS
(services/craig-financial/src/store/payments.rs:127-183 + api/payments.rs:301-370), exchange
send-jobs (services/craig-exchange/migrations/20260505185937_exchange_send_jobs.sql
src/send_worker.rs), one-active partial unique
(services/craig-cases/migrations/20260514080000_one_active_case_plan.sql), NCANDS computed finalize
(services/craig-reporting/src/store/ncands.rs:164-200), ADR-049 registry
(crates/craig-cases-fields/src/persons.rs), encryptable-row macro (crates/craig-search/src/row.rs).