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, Plan::GATEWAY-199 label, #1064/#1065/#1066 filed, #161 AC trued-up

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.race is 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): field ssnFieldScheme::BlindIndex, HKDF field_domain = "ssn_full" v1, Canon::AsIs over the pre-canonicalized digits; sibling ssn_full_hmac. Migration: ssn TEXT (ciphertext) + ssn_full_hmac TEXT + partial index
    verification columns + the CHECK below. ssn is 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); update CreatePersonParams/UpdatePersonParams/Person (services/craig-cases/src/api/encryption/rows.rs) + the seeder Person shape (tools/craig-seed/src/encrypt.rs). Missing a shape hard-errors RowMissingHmacSlot("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 — 123x45x6789 is not an SSN with decoration), then exactly-9 digit shape AND SSN range validation (area ∉ {000, 666, 900–999}, group ≠ 00, serial ≠ 0000) → 400 bad_request_typed(INVALID_SSN) (new craig-common constant).

  • Write-only: #[serde(skip_serializing)] on Person.ssn + Person.ssn_full_hmac (the ssn_hmac precedent — 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 UPDATE on the person row; CASE-arm SQL (the store’s update_person is COALESCE-only today, store/persons.rs:80-94) writes ssn
    ssn_full_hmac + derived ssn_last_four + ssn_hmac and resets ALL verification columns on EVERY ssn write, same-value included — deliberate policy: re-entry never re-verifies, even though the deterministic ssn_full_hmac would make equality knowable in SQL; do not "optimize" the reset away. The SSN_LAST_FOUR_MANAGED on-file check runs INSIDE the same locking tx. Create-with-ssn derives the same four columns + verification-false. Direct ssn_last_four entry while a full ssn is on file — or both fields in one request — → 400 bad_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 NULL and NOT 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, matching Codec::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_hmac is 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; ssn NULL → 409 conflict_typed(MISSING_SSN); hmac mismatch (SSN changed since the attester fetched) → 409 conflict_typed(STALE_ATTESTATION) (all 409s via conflict_typed, never bad_request_typed). DB CHECK additionally enforces ssn_verified ⇒ ssn IS NOT NULL. Attribution claims.acting_worker(). Authz authz.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) so i_supervises never matches → TX attestation = admin/regional only, deliberate and pinned. In-handler PURE-service-caller denialclaims.acting_worker().is_service() → 403 (the Plan E §9b predicate; composition precedent at write_support.rs:212). NOT the literal claims.is_service(), which would 403 craig-web’s own BFF calls (service token + lifted X-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 columnsssn, 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 full ssn is 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_updated pattern — 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-exportrequire_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 → 400 bad_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> (base64 ssn_full_hmac — the value the blocked transmit re-affirmation compares; reporting cannot compute it, the HKDF key lives in cases); full ssn populated ONLY when include_full_ssn = true AND the row is ssn_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); reaches audit_log via 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_code denormalized from the agreement; the financial export endpoint filters status IN ('issued','cleared') and attributes each payment to exactly one quarter by period_end within the half-open FFY-quarter window, where quarter WINDOWS are half-open but payment period_end is the inclusive last covered day (the existing financial convention — compute_period/inclusive_day_count + the period_end >= period_start CHECK; &70 #1068’s generator must keep it, named in the re-validation checklist — a half-open period_end would 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 matching uas_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 FFY YYYY (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 ⇐ cutoff in 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
    the ssn_digest from 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 surfaces issued/cleared payments whose period_end falls 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_by column; regenerating a period supersedes-then-creates (case_plans lock-supersede-activate precedent) — supersession is a status flip to a new terminal Superseded variant (vacating the index slot; a bare superseded_by stamp 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 EXCLUDES transmitting — 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-289 form) 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 temporary 000000000 for an unobtainable child SSN with replace-later duty) — recorded choice with deadline exposure; placeholder handling is also a #1074 question.

  • Lifecycle: the shared SubmissionStatus enum gains Transmitting and Superseded variants, and the transition matrix becomes per-program (gateway-199’s chain adds approved → transmitting → transmitted, the failure back-edge transmitting → 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 / 400 INVALID_STATE_TRANSITION / 409 CONCURRENT_MODIFICATION). Actor stamps generated_by/reviewed_by/approved_by/transmitted_by via claims.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-flips approved → transmitting and 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 via FOR UPDATE SKIP LOCKED, re-fetches SSNs (include_full_ssn=true, verified-only) and re-affirms each row’s ssn_digest
    verified state
    (any regression → job terminalizes AND the submission CAS-flips back to approved in the same tx; 409 conflict_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-flips transmitting → transmitted + stages the event. Terminal HTTP failure (attempt cap, send-jobs MAX_ATTEMPTS/backoff schedule) → job terminalizes + submission CAS transmitting → approved in the same tx — never wedged in transmitting (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_issues with the tx-aware create_issue (PgExecutor), full field set (field_name/description/severity), source_service = data-owning service, paired reporting.quality_issue_detected events; 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*.rs files 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 under ENCRYPTION_MODE: required.

  • Write-only contract: person GET/search/list responses never contain a 9-digit SSN (evil).

  • Derived last-four + ssn_hmac overwrite 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’s ssn is null even with include_full_ssn=true (evil); Cache-Control: no-store asserted; 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_seed covered-surface pins gain ssn/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

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

  2. MR1: full pre-push battery (cargo xtask validate) with cargo 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.

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

Edit this page · latest