ADR-066: Typed Dispatch (DispatchClass) and the SSA Exchange Pipeline Architecture

On this page

Status

Accepted (2026-08-14) for §D1–§D4, which record the Phase-A substrate AS BUILT (epic &81; A1 #1427, A2 #1428, A3 #1429, A4 #1430 — all merged 2026-08-14). §D5–§D6 record the Phase-B+ pipeline design and the counterparty model from the plan of record and are Proposed — they bind Phase-B implementation but their as-built deltas land here as amendments when the units merge. §D7’s UD1 was ratified 2026-08-14 (maintainer steer, #1434 note): Option 1 — cases-side authoritative assembly; unit D3 implements.

Plan of record: SSA Benefit Verification (SOLQ / SDX / BENDEX) (v2, rebuilt from the 47-finding external stop-ship review; this ADR is its unit A5). Companion: ADR-065 (SSA benefit-data classification, custody, and retention — owns the SsnDigest/expected_digest custody semantics and UD2/UD3; this ADR owns dispatch and pipeline shape). Related: ADR-032 (partner registry + open adapter-kind tokens), ADR-038 (the erased-adapter seam), ADR-062 (claim-first staging §D5 reuses), ADR-051 (SSN custody; the UD2 amendment was applied 2026-08-14 — the record is carried in ADR-065 §D5).

Context

IV-E eligibility determination (45 CFR 1355.52) requires SSA benefit verification for household members before eligibility is calculated; Georgia performs it through Gateway’s SSA interfaces — SOLQ (real-time SSN lookup), SDX (SSI batch), BENDEX (RSDI batch) (plan §Context). The v1 plan was rejected by an external stop-ship review whose structural finding for the exchange tier was S1 — the generic-send bypass (plan §"Why v1 was rejected", S1): the generic pipeline is deliberately payload-agnostic — callers stage arbitrary JSON Value payloads (POST /v1/exchange/send, retry restage) that persist verbatim in exchange_transactions
exchange_send_jobs before the worker marshals them across the erased seam (crates/craig-exchange-contracts/src/erased.rs). For every pre-SSA partner that is correct. For an SSN-keyed SOLQ payload it means one authorized caller turns the transport tables, retry surface, last_error columns, and exchange.failed events into an SSN store the moment an ssa_solq partner row exists.

Two constraints shaped the fix:

  • The guardrail must land before registration makes a partner row possible (the plan’s Phase-A ordering justification): A1 ships the crate unregistered (partner-create validates adapter_kind against the live registry, so no ssa_solq row can exist), A2 lands the rejection machinery testable against a synthetic kind, and A3 registers LAST, gated on the rejection tests being green.

  • Refusal must be a property of the adapter’s declared contract, not of call-site discipline. Three hand-placed if kind == "ssa_solq" checks would be the ADR-049 status-quo defect transplanted — per-site re-derivation that drifts. The classification therefore rides the erased trait itself and every boundary derives from one registry lookup.

The review also corrected the counterparty framing (the plan’s P5 traceability row): the v1 draft treated "Gateway" as the partner, which misattributes data authority — an S9-class fabricated-authority hazard applied to compliance evidence rather than to data.

Decision

D1 — Pipeline usage is a declared property of the adapter: DispatchClass

craig-exchange-contracts gains a closed two-variant enum and a REQUIRED erased-trait method (crates/craig-exchange-contracts/src/erased.rs:38-45, :110):

pub enum DispatchClass { Generic, TypedOnly }

pub trait ErasedAdapter: Send + Sync + 'static {
    // …send_value / audit_value / test_connectivity_value / kind…
    fn dispatch_class(&self) -> DispatchClass;   // REQUIRED — no default
}

Generic adapters (all 11 pre-SSA kinds) accept the generic pipeline. TypedOnly adapters (the SSA family) carry payloads that must never transit or persist through that surface. Two deliberate shapes:

  • No default. A hand-written impl must choose a class rather than inherit Generic by omission — fail-open through silence is the exact failure mode this guards against (erased.rs:105-110).

  • Deliberately exhaustive enum. A future third class must revisit every match at the rejection checkpoints rather than fall through silently (erased.rs:36-37).

Adapter crates do not hand-write the impl: impl_erased_adapter! gains a third typed_only arm alongside the existing typed-partner and SHINES-passthrough arms (both pinned Generic). The typed_only arm’s generated send_value/audit_value REFUSE unconditionally with the categorical ErasedAdapterError::TypedOnlyDispatch { kind } (kind token only — no payload byte can leak through Display; erased.rs:136-144, :317-373); only the payload-free test_connectivity_value probe forwards, so operators keep endpoint diagnostics. craig-partner-ssa-solq adopts it as impl_erased_adapter!(SsaSolqAdapter, "ssa_solq", typed_only) (crates/craig-partner-ssa-solq/src/adapter.rs:131). The crate itself carries the A1 posture (plan unit A1): the S2 closed categorical SsaSolqError (no upstream byte survives the mapping boundary — src/error.rs:19-56, src/adapter.rs:61-74), no in-crate mock (S10), and field-free #[non_exhaustive] wire placeholders pending the real Gateway contract (S9-class — src/types.rs). The data-classification consequences of those choices are ADR-065's subject.

D2 — Four rejection layers, outermost to innermost

All four fire for any kind whose registered class is TypedOnly; all four are categorical (partner id + kind token only, per SD2).

Layer Checkpoint Behavior Anchor

1

Generic send, pre-claim

preflight_partner refuses BEFORE the ADR-062 claim or any persistence — typed 422 bound to the partner_id request field

services/craig-exchange/src/api/transactions.rs:138api/dispatch_guard.rs:21-38

2

Retry, in-tx pre-restage

the partner row is consulted INSIDE the restage transaction, so the Err rolls back the failed → pending status flip — a typed-only kind restages nothing

api/transactions.rs:439-446dispatch_guard.rs:75-87

3

Send worker, pre-resolve

a claimed job whose partner-row kind is TypedOnly terminal-fails typed-only-dispatch (one tx: job + transaction stamp + exchange.failed, the #1195 shape) — never dispatches, not even to the noop sentinel; retrying a policy refusal can never succeed

services/craig-exchange/src/send_worker.rs:252-256, :280-284

4

Erased seam (innermost)

the typed_only macro arm’s send_value/audit_value refuse unconditionally — holds even if every upstream checkpoint is bypassed

erased.rs:317-373; canary-pinned no-echo test erased.rs:633-668

Layers 1–2 are the ingress guards for new traffic; layer 3 exists for rows that predate the guards or bypassed them (direct SQL); layer 4 is the defense-in-depth floor. A deliberate non-behavior: an UNREGISTERED kind passes the guard (dispatch_guard.rs:18-20, :113-125) — it keeps its existing fail-closed path (worker terminal unresolvable-dispatch) and can never reach a typed-only adapter, so the guard stays a classification check, not a second kind validator.

D3 — One registry seam, keyed on the partner-row token — never resolve()

ExchangeRegistries::dispatch_class_of(kind) → Option<DispatchClass> derives the class from the adapter map materialized ONCE at boot (services/craig-exchange/src/bundle_orchestrator.rs:167-169) — the metadata rides the erased trait via the macro arms, NOT the bundle factory-entry tuples, so registration required zero bundle-contract changes (plan unit A2, as-built notes). Every checkpoint keys on the partner-row kind token through this one function, deliberately never on resolve(): resolve() substitutes the Generic noop adapter for any noop:// endpoint (bundle_orchestrator.rs:145-147), which would mask a typed-only kind and let the generic pipeline stage payloads for it under test/sentinel endpoints. The classification guards pipeline usage for the declared kind, so the substitution must be invisible to it (bundle_orchestrator.rs:156-166; contrast test :655-670).

D4 — Registration LAST, plus the activation lock and its two-stage lift

A3 (plan unit A3) registered the kind only after the refusal matrix was green:

  • Bundle entry + audit codec. adapter_factory!("ssa_solq", …) in the GeorgiaBundle (crates/craig-state-ga/src/lib.rs:117) + SsaSolqAuditCodec (:132; crates/craig-partner-audit/src/codec/ssa_solq.rs:12) — registered like any partner, with the refusal rationale committed at the registration site (lib.rs:105-108).

  • Lockstep pins. PINNED_KINDS 11→12 plus the new PINNED_TYPED_ONLY_KINDS = ["ssa_solq"] (services/craig-exchange/src/adapters/mod.rs:38-47); the inventory test now also asserts, for EVERY pinned kind, that the generic ingress guard refuses exactly the TypedOnly subset (adapters/mod.rs:75-91) — a macro-arm or registration drift on any kind fails a pin a human has to read.

  • Worker-checkpoint proof. The A2-deferred layer-3 proof (no TypedOnly kind was registrable pre-A3): stage under a generic kind, then CAS-flip the partner row to ssa_solq by direct SQL inside the worker’s poll window — ordering INVERTED versus the t1195 precedent because the registered kind now 422s at ingress — and assert the terminal typed-only-dispatch outcome (services/craig-exchange/tests/api/send_worker.rs:418-543).

  • The activation lock. Until the typed pipeline exists, an ACTIVE ssa_solq row is pure misconfiguration surface, so activation refuses with 403 FEATURE_DISABLED naming the lift (dispatch_guard.rs:50-67), enforced at create (rows are born active, api/partners.rs:174) and at update on the effective kind — covering kind-flip-while-active and PATCH {active: true} resurrection (api/partners.rs:318; matrix test tests/api/partners.rs:371-445). {adapter_kind: "ssa_solq", active: false} stays legal so operators can stage configuration ahead of the lift. The lock keys on dispatch_class_of, never the literal token, so every future TypedOnly kind inherits it. Two-stage lift: B4 lifts for non-production ONLY (dev loopback/mock allowlist); Phase P lifts for production — until that epic closes, activating ssa_solq with a non-loopback endpoint is impossible in production builds (SD4; plan unit B4 + §Phase P; #1433).

  • Deliberate seed absence. Seeded partners are born active (the seed model carries no active flag), so any seeded ssa_solq row would violate this unit’s own refusal — the inventory site commits the reasoning and Phase B seeds the partner when the typed pipeline exists (tools/craig-seed/src/datagen.rs:1664-1670). The mock-route lockstep flipped to adapters-minus-ssa_solq (the first adapters↔mocks desync, by S10 design; craig-state-ga/src/lib.rs:512-520).

D5 — The typed SOLQ pipeline (Phase B design — Proposed)

The plan’s Phase-B units (B1–B7) define the pipeline that will carry SSA traffic instead of the generic dispatcher. Design commitments this ADR fixes (as-built deltas amend here):

  • Screening model (B1). ssa_screening_runs / ssa_screening_members / a typed job table in craig-exchange, with DDL-enforced invariants (status CHECKs, timestamp ordering, status-dependent nullability, one-active-run-per-case partial unique index, unique txn↔member). Cohort binding per H1: a cohort hash over (member set, versioned digests, as-of) — the hash binds to the SsnDigest canonical v{n}:{base64} byte form (crates/craig-cases-contracts/src/ssn_digest.rs; custody semantics in ADR-065) — plus the UD11 freshness horizon and an invalidation marker. Rows are provenance-grade, never prunable transport (H3/H14).

  • Cases-owned intent, cases-only endpoint (B3). The request path is cases-authoritative: craig-web mints and retains client_request_id; cases performs the per-case BOLA check under the caseworker actor and forwards unchanged to an exchange endpoint whose service allowlist admits ONLY cases (SD5); exchange claim_first creates run
    members + transactions + typed jobs atomically (ADR-062 request_claims). New ResourceType::{ScreeningRun, BenefitFact} with GA/TX authz fixtures. Partner resolution is zero/one/many fail-closed with active/DSA checked at stage time (H15).

    As built (B3, #1464). POST /v1/cases/cases/{id}/ssa-screeningPOST /v1/exchange/ssa-screening-runs, five refinements recorded: (1) the cohort is assembled SERVER-side (active household members, digests version-stamped through the B2 seam; a member without an SSN refuses typed — skipping would fabricate a complete UD1 witness) and the intent is HUMAN-only (deny_pure_service); (2) cases authenticates as ITSELF (its OIDC principal — the first cases→exchange client) with the caseworker riding the body as attribution, the ADR-065 §D2 as-built posture extended to this hop; the wire DTOs live in craig_cases_contracts::screening_wire (the cohort in motion — the exchange-contracts leaf stays pristine); (3) exchange re-derives everything derivable: ordinals from the canonical cohort sort (never wire order), the cohort hash recomputed via the ONE shared fn; the one-active-run check runs POST-claim so replays short-circuit at the claim; (4) H15’s DSA consult (status active + unexpired on the business date) is the first DSA-validity check in the codebase — the generic send path never had one; (5) exchange refusals relay VERBATIM through cases, and the WEB mint itself lands with E1’s form — B3 proved the client-held-id protocol end-to-end (full-chain replay test). The UD3 knob is CRAIG_EXCHANGESSA_SCREENINGENABLED (default off); the two new ResourceTypes ship deny-all rulesets in BOTH jurisdictions until D4 defines reads.

  • Typed worker (B4). A dedicated worker — never the generic dispatcher — whose per-job sequence is: claim typed job → last-responsible-moment re-checks (feature gate, partner-active, DSA, endpoint/adapter drift; an explicit pause-vs-terminal classification table per H16) → value-bound JIT custody call (expected_digest release, S3/ADR-065; mismatch = terminal ssn_stale) → a minimal wire payload built SEPARATELY from the persisted job (SD14; correlation rides an explicitly modeled field, never an insert into an unknown-field-dropping shape) → send under the S13 egress controls (HTTPS-only, strict host allowlist, redirects disabled, bounded response bodies, resolver pinning — verified by V9). Dedicated lease budget: compile-time floor lease ≥ 3 × (custody_timeout + send_timeout) or heartbeat extension (H17), with failure-mode classification (custody timeout ≠ partner timeout ≠ stale digest ≠ auth failure).

    As built (B4, #1465). Five refinements recorded: (1) the S13 controls live in the NEW craig_exchange_transport::EgressControlledTransport — https-only (literal-loopback http as the dev carve-out), exact-host allowlist (EMPTY denies all; Phase P populates production), resolve-then-vet-then-PIN DNS handling (https-class hosts refusing internal address space, the connection bound to the vetted address), redirects surfacing as BadStatus, and the A1-deferred 1 MiB chunked-read byte cap on all bodies; the generic path’s DirectHttpTransport is untouched. (2) The lease floor took the compile-time branch: CLAIM_LEASE_SECS = 120 with const asserts for BOTH ≥ 3 × (custody + send) and sequential batch × worst-case < lease (jobs process sequentially, CLAIM_BATCH = 2 — SOLQ volume is per-member; concurrency is a measured follow-up). (3) The H16 pause classes are job paused + categorical last_error (gate-disabled/partner-unavailable/ dsa-not-active/egress-refused), with the egress pre-check BEFORE the custody call so plaintext is never released for a send the policy would refuse; paused rows re-admit by operator UPDATE until resume tooling lands. (4) The activation lift is ENDPOINT-SHAPED: a host-literal loopback/mock check (noop://, or http(s) to localhost/127.0.0.1/::1) in refuse_typed_only_activation — deliberately not DNS-based, so no resolvable name can open production activation before Phase P. (5) The typed worker CONSTRUCTS its own SsaSolqAdapter (the erased registry deliberately cannot hand out typed adapters) and the minimal wire payload extends SolqScreeningRequest additively (ssn wire-transient with a redacting Debug, the modeled correlation_id, schema_version); on sent the member stays pending — the response hand-off is B5’s.

  • Results + benefit facts (B5). Categorical per-member outcomes; the S12/SD10 row-per-benefit fact model — attribution, beneficiary role, amount/frequency, as-of, effective/termination dates, supersession chain, source precedence (SOLQ vs SDX vs BENDEX), provenance FKs, replay-unique keys — never a scalar benefit_type (plan §"Why v1 was rejected" S12 + unit B5). Fact custody home and the persons.ssn_verified CAS ride ADR-065 (UD2). Aggregate run-state updates are fenced under the worker claim generation (H2).

  • Events + gate (B6/B7). Domain events carry PII-safe categorical payloads only; exchange.failed for SSA carries category codes only; security parsers deploy before producers. B7 (verification rows V1–V5 + V9) is the phase exit gate — nothing downstream consumes screening results before it is green.

D6 — Counterparty model: SSA is the data authority, Gateway the network recipient

CRAIG’s wire counterparty is the Georgia Gateway (the network recipient/proxy — the system the egress allowlist, endpoint config, and transport evidence actually name), but the data authority for every benefit fact is SSA (the source whose records the facts assert). BOTH identities are recorded — in the partner registry documentation, the DSA, and all compliance evidence (plan §Context; traceability row P5). Naming Gateway alone would misattribute data authority (the S9-class fabricated-authority hazard applied to compliance evidence); naming SSA alone would hide the real transport counterparty from the egress/audit story. Phase P owns the production half of this model (plan §Phase P): outbound auth
credential zeroization, secret rotation, the real Gateway wire schemas + SOLQ code mappings, an executed DSA recording both SSA and Gateway identities, the populated egress allowlist, cutover acceptance evidence, and the custody-path pen-test. UD9 (recorded 2026-08-16 as a split, #1434): the business/legal DSA + cutover SIGNATORY remains an unnamed, REAL Phase-P blocker (#1433) — DFCS program/legal authority, not a technical role; the project maintainer is technical coordinator only, and coordination is not signing authority.

D7 — UD1 (DECIDED 2026-08-14 — Option 1): who enforces "screened before determination"

Per Melody Debussey on #162, every household member is screened before the IV-E determination. Today nothing enforces that claim: no authoritative orchestration loads the screening witness
benefit facts, and generic /v1/rules/evaluate callers could omit or forge SSA input fields (S5, plan §"Why v1 was rejected"). This is a USER DECISION (UD1, plan §User decisions), needed by unit D3 and before Phase B closes (plan §Bookkeeping). The options:

Option 1 — cases-side authoritative assembly (RATIFIED 2026-08-14). A cases-owned assembly endpoint is the ONLY path that can assert the screening witness: it loads the screening result server-side (freshness-checked against the H1 cohort hash + the UD11 horizon), loads benefit facts server-side, and feeds the determination — generic evaluate callers structurally cannot claim the witness because the witness fields are injected only by the assembly path. Unit D3 implements. Strengths: the regulatory claim becomes machine-enforced and forgery-proof by construction; consistent with the house single-authoritative-boundary pattern (ADR-054’s proof-typed writers, ADR-049’s one-registry rule). Costs: a new cases orchestration surface with its own freshness/invalidation semantics; couples determination latency to screening-data availability.

Option 2 — drop the claim (rejected). CRAIG makes no machine-enforced "screened before determination" claim: screening results surface as advisory data (worklist/UI), procedure manuals own the sequencing, and the documentation states the enforcement boundary honestly. Unit D3 reduces to documentation. Strengths: no new orchestration surface; zero false-authority risk. Costs: the 45 CFR 1355.52 posture rests on procedure, not the system; a determination recorded against an unscreened household is representable.

Decision: Option 1, ratified 2026-08-14 (maintainer steer on #1434). Unit D3 implements the cases-side authoritative assembly. (UD2 and UD3 were ratified the same day — see ADR-065 §D5/§D6.)

Consequences

  • S1 is closed by construction, in order. The rejection machinery landed strictly before registration made a partner row possible (A2 → A3), and the activation lock keeps every registered-but-unusable row inert until the typed pipeline exists — at no commit on main has an SSN-capable generic path existed.

  • One declaration, every boundary derives. The adapter’s macro arm is the single source of the class; dispatch_class_of is the single lookup; the lockstep pin (PINNED_TYPED_ONLY_KINDS) is the single human-read drift tripwire. Future TypedOnly kinds (SDX/BENDEX handlers, if they become adapters) inherit all four layers and the activation lock by declaring the class — no new checkpoint code.

  • Wire-shape additions (pre-1.0, CHANGELOG’d). Generic send/retry against a TypedOnly kind is a typed 422 bound to partner_id; activation is a 403 FEATURE_DISABLED naming the lift; a bypassing row terminal-fails typed-only-dispatch. All categorical (SD2).

  • Residual: forensic rows, not silence. A direct-SQL malicious job row still produces a terminal transaction stamp + exchange.failed event (categorical text only) — deliberate: the residue is the forensic record of the attempt.

  • Residual: the guard is not a kind validator. Unregistered kinds pass it by design and keep their existing fail-closed worker path; conflating the two would have widened the guard’s contract for no safety gain.

  • Pre-B4 the SSA partner is operationally dark. Connectivity probing works (the payload-free erased method forwards); everything else refuses. This is the intended shape of "inert substrate" (plan §Phase A).

  • §D5–§D6 bind Phase B but are contingent. B-unit as-builts and any Phase-P counterparty evidence deltas land as amendments to this ADR (the UD1 ratification was recorded by inline §D7 edit in the 2026-08-14 ratification commit); the plan’s Status table stays the unit-level tracker.

Alternatives considered

  • Ingress-only guarding (checkpoints 1–2 alone). Rejected: rows that predate the guard or arrive by direct SQL would still dispatch; the worker checkpoint and the erased-seam refusal make the guarantee hold even with both handlers bypassed — the innermost layer is the one that cannot be routed around.

  • Keying the checkpoints on resolve(). Rejected: resolve() substitutes the Generic noop adapter for noop:// endpoints, masking a TypedOnly kind exactly where tests and sentinel configs would exercise the path (bundle_orchestrator.rs:156-166).

  • A defaulted dispatch_class() (default Generic). Rejected: a hand-written or new macro-armed adapter would inherit generic dispatch by omission — fail-open through silence (erased.rs:105-110).

  • Deferring registration entirely to B4. Rejected: it leaves the worker checkpoint unprovable in a real boot (no registrable TypedOnly kind), the audit codec + lockstep untested, and the activation lock unexercised until the riskiest unit. Registering last within Phase A proves the whole refusal matrix live while activation is still impossible.

  • A config/DB flag marking typed-only kinds. Rejected: a second source of truth that can drift from the adapter’s own contract; ADR-049’s lesson is that per-site re-derivation of a latent rule diverges — the rule must be data resolved in one place, and here that place is the adapter’s own erased impl.

Open questions

  • UD1/UD2/UD3 — ANSWERED 2026-08-14 (all Option 1; §D7 here and ADR-065 §D5/§D6 record the decisions).

  • Phase P externals (#1433) — the real Gateway wire schemas, SOLQ code mappings, and the executed dual-identity DSA are Phase-P external blockers (#1433 — NOT C0/#1432, which covers only the SDX/BENDEX fixed-width record contracts; conflation corrected 2026-08-16); §D5’s wire placeholders and §D6’s evidence requirements finalize only when they arrive.

Amendments

Phase-B as-built deltas (§D5) and Phase-P counterparty evidence (§D6) land here (the UD1 ratification was recorded by inline §D7 edit, 2026-08-14).

Amendment (#1479, 2026-08-17) — B5 design ratified: the witness custody boundary, run lifecycle, the outcome transaction, cohort-revision invalidation

The B5 design unit #1479 ratified 2026-08-17, closed by this record (all six maintainer forks ratified; the adversarially-reviewed brief rides #1479 as an issue note; carried AC on #1466). The full design record — classification, custody map, retention posture, fact/projection data shape, fork dispositions — is ADR-065 § Amendment #1479. This amendment records only what THIS ADR owns: the custody-boundary correction, the pipeline lifecycle, the outcome-transaction mechanics, and the invalidation protocol — including corrections to §D4/§D5 statements the design review found stale.

§D4 correction — the activation guard is NOT the custody boundary. The endpoint-shaped activation lift (loopback/mock host literals) is demoted to ergonomics: a loopback sidecar proxy is a real bypass, so endpoint shape cannot carry custody. The genuine pre-P custody boundary is the send-authority witness SolqSendAuthority::Gateway(GatewayProof) (private-field proof, NO pre-P constructor; the contract-stub harness-only stub variant is excluded from release graphs by a blocking feature-graph lint), held as Option<SolqSendAuthority> on BOTH the worker and exchange AppState (devstack runs the API with the worker disabled) and checked FIRST in dispatch_preconditions: None ⇒ pause contract-pending — the new precondition #0, joining the §D5/H16 pause vocabulary (hyphenated spelling, a recorded deviation from #1479’s contract_pending, matching the worker’s existing tokens) — with zero custody reads, strictly before the JIT plaintext release. Layering L1–L4: witness / pause / endpoint guard (kept, non-load-bearing) / egress empty-denies-all. The cases-side authority discriminator is defense-in-depth, NOT an independent lock (a caller-asserted wire field inside the S2S trust boundary); the genuine pre-P lock on outcomes is the field-free SolqScreeningResponseoutcome-unmappable. Phase P forward note: an auth-unconfigured pause class joins when outbound auth lands. The pause/terminal boundary rule is stated once: PAUSE = deployment-scoped and remediable (same job may later send unchanged); TERMINAL = a per-job/member fact no config heals; post-send failures are never pause-class.

§D5 lifecycle corrections. Run completion fires from every member-terminal writer via the idempotent guarded complete_run_if_final — the outcome tx is one call site of several ("fenced aggregate run-state updates" alone was insufficient: B4-only terminal paths would have held the one-active slot up to 12 months); retry_or_exhaust’s exhaustion arm upgrades to `terminal_pair
complete_run_if_final (today it finalizes the job only, stranding the member and the slot). The H2/D12 fencing splits PATH-based: dispatch-path stamps (pre-send ssn_stale, dispatch-class failed) keep the first-writer-wins pending guard; outcome-path stamps (screened/no_match/unmappable/outcome-class failed) are claim-generation-fenced through the job row — a stale lease-expired claimant must not win a screened stamp with an old response. SsaScreeningMemberStatus gains cancelled, no_match, unmappable (terminal, completed_at-paired, in the same CHECK-rebuild migration). The H5 digest-lookup split is recorded: ordinals/chunking move to C2; B5 keeps per-member ambiguous fail-closed (the plan’s H5 traceability row and B5 unit row match). Paused-row re-admission graduates from "operator UPDATE" to the #1469 resume endpoint (service-side, human-admin-only — as-built STRICTER than deny_pure_service: BFF-lifted humans are refused too, the operator flow is direct-token by design; re-runs the live precondition chain, refuse-when-condition-holds 409; attempts reset to 0 with the pre-reset count preserved in the ssa.jobs_resumed audit event — fork 6; LANDED as POST /v1/exchange/ssa-screening-jobs/resume: the chain extracted to the shared ssa_worker::live_preconditions so the worker’s pause gate and the resume re-check can never drift, filters = reason token and/or run id with a filterless call refused, and the pre-Phase-P authority None refusing every resume contract-pending — the honest posture until Phase P) and the fork-1 audited human-only cases-relayed cancel endpoint (operator_cancelled; one-way predicate replay; audit names the actor).

The honest race window. For a job claimed just before an invalidation stamp, the worker can still release and send plaintext for a dead run when the value didn’t change — the digest binds value, not run validity. Recorded posture: (a) the bounded (≤ ~35 s) residual window is stated honestly, here; (b) narrowed by a pre-send invalidated_at re-read immediately before dispatch; (c) the outcome tx re-reads invalidated_at under the claim-generation fence and downgrades to no-outcome (run-invalidated) — facts/CAS are never written for a dead run. The D23 three-part argument: facts bind (digest, person-binding-as-of-verify), not the person unconditionally — the SSN-correction window is the named residual, contained by witness voiding + the assembly-gate-only read posture (recorded in ADR-065); the CAS is independently safe (person/value-scoped guarded WHERE + the #1266 reset-on-value-change makes either commit order correct); the fenced member stamp keeps the run aggregate honest.

The outcome transaction (D19–D24). ONE cases-side operation, POST /v1/cases/ssa-screening-outcomes — exchange-service-only (handler const allowlist + require_service_caller), actorless per the fleet-blessed deferred-flow posture with requested_by attribution (the stage-time actor) riding the wire; the worker widens ClaimedSsaJob to carry transaction_id + requested_by. ONE cases tx: (1) claim_first with an identity-only IntentV1 (transaction_id, run_id, member_id, person_id, expected_digest — deliberately excluding entries/echo_matched/outcome; a recorded deviation from hash-the-whole-body, because retries RE-SEND and bytes may differ — body-hashing would 409 IDEMPOTENCY_CONFLICT and strand the job); (2) classification-only current-digest verify (ssn_stale refusal, no writes — the enforcing check lives in the CAS WHERE); (3) authority refusal (non-Gateway ⇒ 409 non-authoritative-source); (4) taxonomy map (any unmappable ⇒ zero facts); (5) fact inserts with the 23505 split by conflicting index — member replay key ⇒ replay no-op (terminates), chain-root ⇒ re-read head, retry as successor; (6) the guarded single-statement ssn_verified CAS (UD2) with both 0-row dispositions defined: already-verified ⇒ no-op success; digest-moved-mid-tx ⇒ commit-facts-skip-CAS; (7) the ssa_screening_outcomes projection insert — replays re-derive the response from this row (claims store no response; the honest ADR-062 posture). echo_matched is the worker’s transient value-equality assertion with NO structural backstop (recorded asymmetry vs the discriminator); pre-P it is vacuous. Exchange-side finalization is ONE tx: member-terminal stamp + job finalize
complete_run_if_final — never sequential statements; 0-row disambiguation by generation re-read (stale claimant drops silently; run-invalidated-mid-flight self-finalizes run-invalidated + member cancelled). Retry is a re-send (no persisted payload per H18), made safe by the identity-hash claim; exhaustion is member-terminal. Named runtime posture: the cases claim/projection row is truth; member status may transiently understate it until the next attempt or exhaustion — the B7 battery probes that the divergence is always convergent.

Cohort-revision invalidation protocol (pipeline mechanics; data shape + fork record in ADR-065). Producer-carried monotonic cohort_revision + the minimal PII-free case.screening_cohort_changed event (fork 5), consumed on the existing exchange inbox under the ADR-062 attempt tx: watermark GREATEST-upsert → strict one-way < invalidation of older runs (bounded by the fork-2 completed-and-stale no-op predicate) → the one-tx sweep (paused + unclaimed pending jobs → failed/run-invalidated with completed_at; pending members → cancelled) → complete_run_if_final. Staging UPSERTS the watermark inside the claimed tx (the row lock is the serialization point) and branches three-way: strictly newer ⇒ auto-supersede in ONE tx (fork 3 — invalidate + sweep + stage, audited marker + the B6 exchange.screening_run_invalidated event); equal ⇒ 409 SCREENING_RUN_ACTIVE; older ⇒ 409 SCREENING_COHORT_STALE. No reconciliation sweep — the no-active-run-below-watermark invariant is structural, with a report-only watchdog. B5/B6 ordering per D17: the inbox consumer + queue binding + all revision bumps ship in B5 (consumers-before-producers); B6a = catalog + security parser arms for BOTH events; B6b = the producers.

As-built (B6, #1467 — B6a !1449 + the B6b producers MR). B6a landed the ADR-003 catalog entry (which PINS both payload shapes) + the security parser arms: update/screening_cohort (own resource lane — the derived signal never double-counts as a case update) and update/ssa_screening_run with the exchange.screening_ resource-id family (run_id before the denormalized case_id). B6b staged the producers transactionally at every writer: publish_screening_cohort_changed lives INSIDE the cases screening_revision seam — each bump stages one fork-5 event per bumped case carrying that case’s OWN post-bump revision (the bump and its event can never disagree; the person-shaped fan-out stages per case), with the categorical change token typed as CohortChangeKind (household/person_digest/ssn_cleared/link_promoted) at the P1–P5 sites. exchange.screening_run_invalidated stages in all four invalidation writers' own transactions: the inbox consumer (one event per stamped run), the fork-3 supersede (carrying superseded_by_run_id = the replacement), the worker freshness path (ClaimedSsaJob widened with case_id so the event needs no re-query; the worker dispatch chain’s error widened to StageError), and the fork-1 cancel (user_id = the relayed requested_by — see as-built (iii) above). Refused stamps stage nothing (one stamp, one event — replays and 0-row races are silent by construction).

As-built (B5, #1466 — MRs !1445/!1446/!1447 + the U4 close-out; deltas only, each a recorded strengthening or narrow deviation). (i) The witness unconstructibility probe is a trybuild compile-fail suite RUN UNDER NEXTEST (no battery stage executes doctests), fixture-glob-liveness-guarded; "enabled-but-unready" is realized as the boot warning + the paused-jobs gauges, with the depth gauge ZERO-FILLED over the H16 vocabulary each drain pass (the cumulative Prometheus bridge re-reports a label’s last value forever — an unrecorded drained series would stick). (ii) The outcome tx gained a DOMAIN replay guard ahead of D20 step 2: the fresh-claim branch probes the projection row FIRST, so a post-prune redelivery is a PURE replay — it can neither re-fire the CAS on a human-revoked person nor commit facts outcome #1 never accounted for (a strengthening the adversarial review forced; both directions regression- tested). Duplicate (kind, role) pairs in ONE response refuse 400 (the same-request twin is indistinguishable from a redelivery at the replay key and would silently drop data). The CAS is LOCK-FIRST per attest_ssn’s own re-read-under-lock contract and stages `case.person_updated in the same tx when the flag flips (the audited-CAS posture). (iii) The fork-1 cancel is EXEMPT from the UD3 staging knob (disabling screening must not brick an occupied slot); its actor-naming audit EVENT rode B6 behind the parser arms — the deviation is DISCHARGED as of B6b (#1467): a fresh cancel stamp stages exchange.screening_run_invalidated with user_id = the relayed requested_by in the stamp’s own tx. (iv) ssa_cohort_invalidations_total is AT-LEAST-ONCE telemetry (the event/supersede sites record inside retryable transactions; the DB stamps are the accounting). (v) A paused-beyond-grace invariant file is deliberately NOT shipped pre-Phase-P — every gate-on deployment pauses contract-pending indefinitely by design; the gauges are the watchdog.

Amendment (#1548, 2026-08-21) — D1: zen absent-key semantics PROVEN; the deprivation-table design

Status: mechanics ACCEPTED (they realize the ratified UD13 + executable proof); the fact→basis mapping table was PROPOSED here and RATIFIED as proposed on #1548 (2026-08-21, maintainer steer) — D2/D3 are unblocked. The mapping remains a ⁂ best reading riding #1073 until DFCS confirms (it is unreachable before Phase P regardless — the placeholder partner response can never produce facts).

The proven semantics (S7 discharged). The committed executable fixtures (services/craig-rules/tests/absent_key_semantics.rs) pin zen-engine’s truth table — tested, never assumed:

  • An ABSENT input key (and a NULL value) matches NO equality or comparison cell — the row is skipped silently, never an error and never a null-coercion match.

  • The "" catch-all cell matches everything, absent keys included.

  • First-hit order is load-bearing: a catch-all above a specific row swallows it (S8’s failure mode, pinned by fixture).

  • Corollary — the transparency theorem: a new row keyed on a NEW input key with a non-empty cell is UNREACHABLE for every input lacking that key. Adding SSA-derived rows above the catch-all is therefore byte-identical for non-SSA inputs by construction, and the committed baseline corpus (tests/baselines/ive_eligibility/ — 12 fixtures at D1, 15 since the D2 rows landed with their own token + precedence fixtures; full-document equality, regeneration is a ruleset-change act) enforces it mechanically against every future edit.

The deprivation-table mechanics (UD13 realized). Per the RATIFIED UD13 precedence (documented basis first; SSA-derived rows before the "" catch-all as fallback), the D2 edit shape for `georgia-ive-eligibility’s Deprivation Basis table is:

  1. The five documented-basis rows stay FIRST, byte-identical (documented basis wins).

  2. SSA-derived rows insert BETWEEN them and the catch-all, each keyed on a NEW assembly-supplied input key (working name ssa_deprivation_evidence, a categorical token — never a raw benefit payload) with a non-empty equality cell — unreachable for legacy inputs per the transparency theorem.

  3. The "" catch-all stays LAST, unchanged.

  4. The new key is populated ONLY by the UD1/D3 cases-side assembly (from CURRENT benefit facts via the D4 heads-only read); generic evaluate callers cannot supply a screening witness (the D3 orchestration owns that refusal).

The fact→basis mapping (PROPOSED — the D2/D3 gate). Which benefit facts evidence which deprivation bases is POLICY, not engineering (the S9 fabricated-authority hazard): the ⁂ best reading pending ratification is an ACTIVE ssi fact for a parent household member evidences incapacity (SSI is disability-predicated), and Title II survivor-class facts evidence death — but Title II RETIREMENT facts evidence neither, and the pre-Phase-P placeholder response can never produce facts at all, so the mapping is unreachable before Phase P regardless. D2/D3 MUST NOT encode any mapping row until this table is ratified on #1548 (the #1479 ratification precedent; production write policy until DFCS confirms, the #1073 posture). Ratified as proposed, 2026-08-21 — recorded on #1548; the encode restriction above is discharged.

Amendment (#1556, 2026-08-21) — D2 as-built: ruleset rollout mechanics

Status: ACCEPTED (3-lens adversarial panel: refute / contract-integrity / domain-policy — all B_NEEDS_CHANGES, findings folded in; Alternative A (versioned rows + active pointer) REFUTED — it reopens the externally-reviewed #1188 CAS + rebinding doctrine across the fleet’s authz substrate for advantages revision-stamping delivers at a fraction of the blast radius).

History = f(CAS token). rule_set_snapshots is insert-only content history keyed UNIQUE(rule_set_id, revision), written on the SAME transaction as every API-path rule_sets mutation (create|update|import|promote|rollback|delete; reason mandatory for the consequential three by CHECK), opened by a migration-time baseline row per pre-existing set (the trail opens with the rules in force). Snapshots record the zen-engine compile vintage (engine_version; a lockfile-pinned const) and the canonical content sha256 (serde_json::to_vec of the parsed value — sorted keys, compact; the promote token’s binding). Capture is application-layer only: direct SQL bumps the bump-always revision trigger WITHOUT a snapshot — deliberately out-of-protocol (the #1188 stance), made DETECTABLE by the report-only rule_set_snapshot_revision_gap invariant.

Every evaluation stamps its revision. rule_evaluations.rule_set_revision (and the rules.evaluated pointer payload) carry the cache entry’s revision on every row — evaluation → exact content is a TOTAL join for post-D2 rows; rollback can enumerate affected determinations by revision range. NULL = pre-D2 (the version string never identified content — backfill impossible).

Promote: preview → consent. The preview is DB-BACKED evidence (rule_set_promotion_previews: crash-durable, replica-safe, no new secret material): compile the candidate, replay the last-N (=100) real rule_evaluations inputs against live + candidate on an isolated blocking thread (per-item #784 budget; never the shared eval queue), persist the CATEGORICAL report (verdict deltas + evaluation-id pointers + corpus provenance + per-input-key coverage; corpus INPUTS never ride the report — #1130 single copy, cross-privilege PII). The corpus is evaluations-only server-side — the serving container has no repo tree; committed-baseline fixtures are battery-side evidence. Execute re-binds content by canonical hash and re-CASes the live revision against the preview’s from_revision inside the flip tx: 409 promote-preview-stale, preview NEVER burned (the #1071/#1096 handshake). A below-floor corpus (< 20) marks the report thin_corpus and the flip refuses without the DISTINCT acknowledged_thin_corpus consent — an empty diff over an empty corpus is not evidence (the C24 zero-fault-green class). The promote snapshot carries the report; rules.ruleset_promoted (and the rollback twin) stage on the flip tx with security parser arms (promote/rollback on rule_set, user_id actor chain, rule_set_id selector). Preview requires RuleSet Update AND RuleEvaluation List. Corpus depth/floor are compile-time consts (admin-rate surface; knobs if operators ever need them).

Traffic-split canary REJECTED — policy, not engineering. IV-E eligibility is a legal determination under the rules in effect; similarly-situated children must never be adjudicated under different rules simultaneously. Shadow-replay + human-confirmed atomic flip is the mechanism. A future throughput-minded refactor may not reintroduce traffic-splitting as an engineering choice.

Rollback = restore-as-new-revision. Monotone history (the counter never rewinds); resolve by the FULL token; compile-revalidate (the engine may have moved past the snapshot’s vintage); reason mandatory; refuses soft-deleted sets (recreate is the path — rebinding doctrine); skips the canary (the target content previously served; its behavior is on record).

Pinning is NON-OPERATIVE. evaluate accepts pin {rule_set_id, revision} (full token, never name+revision); gated like the evaluations LIST (supervisor+ — the dispute-replay audience), compile-per-call (no LRU by decision — admin-rate, and superseded compiles never enter the name-keyed CAS cache); absent snapshot / no-longer-compiling content are typed refusals BEFORE an evaluation id is minted (the unknown-rule-set unrecorded precedent; ADR-006’s "compile errors are unrepresentable at evaluation time" now reads "…except the pinned replay path, where the class is a typed pre-identity refusal"); the audit row stamps the SNAPSHOT identity + pinned = true; no rules.evaluated staged (craig-financial dispatches on it — superseded-policy replays never enter the operational stream). Reproducibility bound recorded honestly: a pin replays exact INPUT CONTENT under evaluation-time engine semantics; the snapshot’s engine_version makes the bound auditable.

Convergence + retention. The devstack seed converger compares canonical content (jq -cS BOTH sides) and converges same-version drift (the rulesets.adoc gotcha retired); its receipt verifies content and names which side diverged. Import remains a peer write path but is DEMOTED for content rollout (promote is blessed); its no-ruleset.changed asymmetry is unchanged and its snapshots keep the trail total. rule_set_snapshots
rule_set_promotion_previews are audit-class, keep-forever-hot (admin-mutation-rate growth; insert-only satisfies ADR-058’s precondition if ever onboarded; D14 forbids destruction until DFCS names a schedule).

Recorded residues. Promote/rollback authz rides RuleSet Update (parity with PUT — a dedicated Approve/state-office gate is a recorded consideration, not shipped); DELETE /v1/rules/sets/{id} now REQUIRES ?reason= (pre-1.0 breaking); the D2 deprivation rows shipped inert with the pre-D3 caller-forgeable-token window recorded here: until D3 lands the cases-side witness refusal, any caller with rule-evaluation create can supply ssa_deprivation_evidence through generic evaluate — acceptable because no persistence/money path consumes ive-eligibility output pre-D3 and real facts are unreachable pre-Phase-P.

Amendment (#1557, 2026-08-22) — D3 as-built: the determination of record + the UD5 worklist

Status: ACCEPTED (3-lens adversarial panel, 7 blocking findings folded in pre-implementation — recorded on #1557).

The determination of record. ive_determinations (craig-cases) is the fleet’s FIRST persisted IV-E determination: the decision-bearing outputs denormalized as-of (eligible/determination/deprivation_basis/funding_source/ ffp_rate — parsed TYPED from the evaluate response; a missing key refuses, never defaults), rule_set_revision + evaluation_id joining the rules-side #1130 single copy exactly (the evaluate wire gained both, additively), the worker-ATTESTED input document (provenance-labeled — the UD13 first-hit table makes the fired row ambiguous from outputs alone, so the record distinguishes self-attestation from machine derivation), and the witness block (run/as-of/cohort-revision/the VERIFIED cohort hash/consulted fact ids/the derived token). Keep-forever-hot audit class (the ADR-058 #1556 posture). No eligibility.evaluated producer — the #1054 money fence holds; #1313 stays open, and its eventual wiring must consume determination_id
rule_set_revision, never generic rules output.

The assembly (POST /v1/cases/cases/{id}/ive-determination). The ONLY minting path (deny_pure_service + per-case Update BOLA): the F8 replay pre-flight answers replays before anything volatile; the witness ladder refuses categorically (409 ive-witness-not-current: screening-missing|run-pending|run-invalidated|run-stale|cohort-drift| facts-changed; MISSING_SSN/empty-household propagate from the cohort recompute) with the H1 12-month screening_is_fresh check and the B1 byte-recompute honored literally (the run view gained cohort_hash — a digest-of-digests, amending the §D3 no-digest pin’s wording: no PERSON digest is representable); the ONE claimed transaction re-validates every cases-local leg under the case row lock (revision equality, the delivered run-invalidation marker, the fact-head re-read) — the panel’s TOCTOU fix. Honest residues: an exchange invalidation UNDELIVERED at commit opens the worklist item on eventual delivery (the UD5 posture); a genuinely concurrent same-key pair can double-evaluate at most once on the rules side (audit-class only). The pre-D3 caller-forgeable-token residue CLOSES honestly: the wire refuses any ssa_-prefixed attested key (typed 422), and rules-direct forgery — still wire-possible — yields no determination of record.

Token derivation (the ratified mapping, honestly bounded). ssa_incapacity = an ACTIVE ssi head fact for an ACTIVE case_household.role='parent' member (role='parent' is the faithful ratified reading; the deliberate 'caregiver' exclusion is recorded — a step-parent/specified-relative extension is a mapping amendment, not an implementation choice). ssa_death is DORMANT BY CONSTRUCTION (S9: the survivor-class discriminator pins at Phase P; the arm returns None for title_ii, pinned by the pre-Phase-P zero-facts reality). Phase-P completion obligations, riding #1433: the survivor discriminator, AND a beneficiary_role predicate on the SSI leg (a representative-payee-class role must not derive parental incapacity from a parent receiving a child’s benefit). Priority when both derive: death first. Injection is jurisdiction-unconditional (zen ignores unconsumed keys — D1-pinned; TX has no SSA input).

The UD5 worklist (screening_review_worklist). Notify, never silent re-evaluation: ONE open item per case (partial unique + the #1172 standing-alert ON CONFLICT bump), opened ONLY post-determination (the predicate rides inside the INSERT..SELECT), by three same-tx writers all serialized on the case row lock — the outcome tx’s facts leg (PERSON→case fan-out over determined cases, sorted locks — the ratified UD5 element; a same-facts re-screen re-notifies, coalesced into occurrences), the revision seam’s cohort leg (per fanned-out case, already locked), and the run_invalidated leg on the WIDENED cases inbox subscription (one census surface, amended registry entry + its own two-path fault legs; the consumer also writes the cases-local screening_run_invalidations marker the determination tx consults). A cohort-driven invalidation double-notifies through the exchange cascade — coalesced, recorded. Surface: scoped list (the parent case’s assignment via auto_scope_list — Custom scope fails closed; NULL item-assignment visible within case scope), self-claim assign (single slot; NO history/event — the deliberate v1 asymmetry), and the audited dismiss: reason mandatory, who/when/why in-row AND case.screening_review_dismissed staged in the dismiss tx (the alerts-acknowledge bar; parsed update/screening_review with the case as the audited subject). craig-web ships the minimal list page.

Recorded deviations from the panel-locked design. The attested-inputs DTO is a bounded polymorphic object with an explicit ssa_-prefix refusal rather than a fixed deny_unknown_fields struct — GA and TX rulesets consume different field sets, and a fixed struct cannot cover both; the refusal (the actual enforcement) is unchanged in strength. The walk is RECURSIVE (the J-review’s defense-in-depth finding): an ssa_-prefixed key refuses at ANY depth, nesting caps at 8 levels, the all-levels NODE budget is 256 (array elements count — array payloads cannot dodge the bound), top-level keys cap at 64 and key names at 64 chars; string value sizes ride the HTTP body limit. Two further J-review remediations recorded as-built: created_by is the ACTING worker (an on-behalf-of token attributes the determination to the human, matching the claim actor), and the assign/dismiss responses serve case_number from the BOLA load (never an empty placeholder). The run-invalidation consumer’s posture is deliberate over-notification: ANY invalidated run on a determined case opens/bumps the case’s one open item — not just the determination’s witness run — because new SSA activity on the household is the review signal and coalescing keeps it one item.

Edit this page · latest