Plan: Per-Subject Determination + Program Mappers (ADR-035 Slice 1, #857, epic &63)

On this page
NOTE

Implements ADR-035 Slice 1 (#857) under epic &63 — the implementation design for ADR-034. CAPS is built first because it is the simplest, lowest-blast-radius per-subject program; the shared machinery it lands is reused by Medicaid (#860, Slice 2) and WIC (#769, Slice 3). Forward-only per ADR-016.

Slice 2 (#860, Medicaid per-member) shipped 2026-06-17 — reusing this Slice 1 machinery unchanged: canopy-medicaid enumerates ctx.members and returns one signed determination per member (the {determinations:[…​]} body MR3a already accepts; no orchestrator change), persisted atomically; the income test stays household-level (per-member budget-group composition deferred to #864, blocked on the tax_filing_status worker-fact). See the CHANGELOG entry.

Slice 3 (#769, WIC per-participant) shipped 2026-06-17 — ADR-035 Slice 1 (CAPS / Medicaid / WIC) now COMPLETE — reusing the Slice 1 machinery: canopy-wic enumerates ctx.participants and returns one signed determination per participant (the {determinations:[…​]} body, persisted + events in one tx; person_id set before signing), each scored on its own category against the shared economic-unit income (7 CFR 246.7). map_wic_context joins CAPS as a complete-or-provisional arm — it errs input_unsatisfiable naming the three worker-facts (participant_category / nutritional_risk_documented / is_breastfeeding_fully) until the &56 corpus (#858), replacing the silent 422; an orchestrator integration test pins the registered-WIC→input_unsatisfiable→zero-rows behavior. Nutritional risk stays service-verified from the assessments table (don’t-trust-caller). The member list is named participants[] (the WIC domain noun) per the CAPS children[] per-program-naming precedent — §8’s "members[]-based" is generic shorthand; per-program naming, plan↔ADR diff zero. The request-body reshape is not yet machine-documented in the OpenAPI snapshot (unexported, #862). See the CHANGELOG entry.

ADR-035 Slice 1 spans ~6 crates and changes core determination behavior (per-subject N-row dispatch + the input_unsatisfiable carrier replacing the silent CAPS 422). It is delivered as five forward-only, independently-green MRs: MR1 isolates the cross-service signed-envelope wire change with backward-compat tests before any behavior rides on it; MR2 isolates the dispatch send-seam refactor (broadcast → mapper) as a pure no-op; MR3a isolates the orchestrator’s per-subject receive plumbing (the bare-or-list parse + per-determination loop) as a behavior-inert no-op (the broadest-blast-radius change, proven before CAPS rides on it); MR3b is the first real per-subject behavior (the CAPS cutover + the send-side typed seam); MR4 is UI only.

Status

MR Description Status

MR1. Carrier + persistence foundation

person_id: Option<PersonId> on the signed SignableDetermination envelope (canopy-signing) + on ProgramResult; a typed MissingInput { field, source_class, gap_issue } carrier + missing_inputs on ProgramResult (the ADR-035 Decision 4 input_unsatisfiable shape); a forward-only nullable program_determinations.person_id column wired through the store model + INSERT + both orchestrator persist sites. Behavior-inert: person_id uses skip_serializing_if and is always None this slice, so it is omitted from the RFC 8785 canonical signing payload — existing/seed signatures verify byte-identically and the runtime determine wire is unchanged (six OpenAPI snapshots gain only optional schema properties).

Done (2026-06-16) — signing compat + ProgramResult/MissingInput round-trip tests; existing determination e2e covers the NULL column path.

MR2. Per-program context-mapper seam (a staged realization of Decision 1)

The orchestrator-internal canopy-eligibility/src/mappers/ seam (ADR-035 Decision 1), landed in a deliberately thin first form: a pub(crate) fn map_context(program, &ApplicationContext) → ApplicationContext (exhaustive match, every arm a pass-through clone) wired at the dispatch loop in place of the broadcast context.clone(). Behavior-inert / byte-identical dispatch. Decision 1’s full typed shape — the ProgramContextMapper trait, the closed-set ContextError, and the Result<ProgramInput, ContextError> return — is staged into MR3 (where CAPS makes it real), so MR2 carries no dead code, no new dep, and no pre-wired latent bug. The end-state still matches Decision 1 verbatim; this is staging, not divergence (plan ↔ ADR ↔ code diff is zero).

Done (2026-06-16) — byte-identity to_vec unit test across every program + structural wiring check; existing seed/e2e proves the no-op end-to-end.

MR3a. Orchestrator per-subject receive plumbing (behavior-inert)

The orchestrator’s dispatch-parse boundary accepts either a bare envelope (every program today) or a {determinations:[…​]} list (ADR-035 Decision 2), normalizing to a Vec via a typed parse_determinations helper (try bare; fall back to a {determinations:[…​]} wrapper; the bare-envelope error is preserved; an empty list is rejected at the boundary so a zero-determination program stays visible as pending). The verify/quarantine/persist/bucket collect arm wraps in for det in dets. Behavior-inert: every program returns a bare envelope today → a 1-element vec → the loop runs once → byte-identical persistence + buckets. This isolates the broadest-blast-radius change (the parse path is shared by all five household programs) before CAPS rides on it. No migration, no new dep, no contract change.

Done (2026-06-17) — parse_determinations unit test (bare/list/empty/whitespace/malformed-error-preserved) + an orchestrator mock-list integration test (N=2 → 2 rows + 2 results; empty → pending); existing seed/e2e proves the bare-path no-op.

MR3b. CAPS per-subject behavior + the fallible context seam (first real per-subject behavior)

Reshape CapsApplicationContext from a scalar single child to a household context carrying a per-child children: Vec<CapsChild> list (not members[] — CAPS subjects are the caller-supplied children-in-care, not a roster enumeration); canopy-caps enumerates the children under the age gate and returns N per-child signed determinations ({determinations:[…​]}, each carrying person_id signed over), persisted atomically by the handler in one transaction. The orchestrator seam widens to map_context → Result<ApplicationContext, ContextError> (the load-bearing half of Decision 1) — CAPS always errs this slice (its worker-facts are not sourceable from the generic context until the ADR-027 corpus, #56), so the orchestrator synthesizes a single household-level input_unsatisfiable result (determination_id: None, per the MR1 carrier contract) naming the missing facts, replacing the silent 422→pending, with the register_pending_verifications status guard. Adds the (eligibility_request_id, program, person_id) unique key (PG18 NULLS NOT DISTINCT) + the orchestrator’s persist-failure→pending guard so a duplicate-subject insert is never silently counted. Bumps caps.toml to v2; reshapes the CAPS tests + the byte-identity mapper test (carved to the 5 household/MAGI programs). DEFERRED (dead-code / unreachable pre-corpus, surfaced as a precommit-Q4 deviation): the untagged ProgramInput enum + ProgramContextMapper trait (a CAPS body is not constructible yet, so the variant would be clippy -D warnings dead code) and per-child input_unsatisfiable surfacing (the orchestrator cannot enumerate per-child gaps until partial per-child facts exist, #860 / corpus — the MR1 person_id carriers make it a zero-rework extension then).

Done (2026-06-16) — caps_test per-child reshape (incl. multi-child + signed-person_id JWS verification, two-eligible→two-authorizations, empty-children→422); mapper unit tests (5-program byte-identity + CAPS-errs-naming-gaps); orchestrator integration tests (input_unsatisfiable; duplicate-subject + household-NULL persist-failure→pending under the unique key); !N.

MR4. canopy-web per-subject Determination tab

Replace the five per-program Determination tabs with one household-wide grouped roster (DeterminationView { summary, programs }): grouped by program → subject (single-strip for SNAP/TANF, per-subject roster for Medicaid/CAPS/WIC), a cash-only summary + kind badges + status roll-up (no false grand total; CAPS subsidy dollars never folded into cash), and the amber "Verification needed" + named-missing-input checklist (rendered from the immediate determine response — the input_unsatisfiable carrier is not persisted, Decision 4). The roster is assembled by fanning out to each in-scope program’s read API (the cross-program table carries only generic envelope fields). Decision A (design-ratified): the 16 #392 caseworker action forms are re-homed into a collapsed "Actions ▾" disclosure per program group; Medicaid resolve-quarantined is the lone operator action, UI-hidden and backend-403’d to is_determination_operator roles (EligibilitySpecialist/Supervisor/Admin — a subset of can_write).

Done (2026-06-17) — Rust render-fixture tests (representative + dense, strip/roster/all kinds/denial/unsat-checklist/actions/operator-cluster) + summarize/initials/dollars/merge_input_unsatisfiable units + is_determination_operator 403 units; E2E determination-roster spec (light + dark) + the resolve-quarantined operator-gate split. Deviations (precommit-Q4): (1) program-scope READ filter added — the roster shows only the worker’s in-scope programs (mirrors the cross-program summary; without it the all-programs roster would leak other programs' data); (2) the operator set excludes StudioAdmin (it is intentionally not a case-write role per can_write); (3) CAPS gross-subsidy dollar + summary subsidy_total deferred (CAPS-policy net/gross nuance — provider + copay ship, both first-class); (4) negative letter-spacing from the handoff CSS normalized to 0 (team frontend constraint); (5) {% match %} → nested {% if/elif %} (Askama 0.15) and initials precomputed server-side (not a filter).

Design — decisions

  • Two cardinalities, the list scoped to per-subject programs (ADR-035 Decisions 2/3). Household-level programs (SNAP/TANF) keep ADR-002’s single { determination } response; per-subject programs (Medicaid/CAPS/WIC) return { determinations: […​] } — the program owns subject enumeration + AU composition and returns N from one dispatch, and the orchestrator normalizes both shapes to a Vec internally (the orchestrator never fans out, never runs compose_*_au — ADR-001/027 §7).

  • Backward-compatible carrier first (MR1). The signed envelope is the cross-service trust boundary, so the person_id field lands alone, defaulted None, with skip_serializing_if — its canonical signing bytes are byte-identical to a pre-field determination when absent, so existing signatures verify unchanged. The riskiest change is isolated and proven inert before any per-subject behavior depends on it.

  • MR2 stages Decision 1 thinly; MR3b lands its load-bearing half (Result/ContextError) and defers the ProgramInput plumbing. ADR-035 Decision 1 specifies a ProgramContextMapper trait + per-program impls returning Result<ProgramInput, ContextError> in canopy-eligibility/src/mappers/. MR2 landed a thin pass-through free-fn map_context; MR3b widens it to map_context(program, &ApplicationContext) → Result<ApplicationContext, ContextError> — the complete-or-provisional gate, which is the architecturally load-bearing half of Decision 1. The untagged ProgramInput { Generic, Caps } enum + the ProgramContextMapper trait are deliberately deferred (a precommit-Q4 deviation from this row’s original wording): the generic ApplicationContext carries none of CAPS’s worker-facts, so a CapsApplicationContext is not yet constructible — a ProgramInput::Caps variant would be constructed nowhere and fail clippy -D warnings as dead code. The enum lands when a CAPS body becomes constructible (the ADR-027 corpus, #56). The end-state still matches the ADR; this records the staging so the plan ↔ ADR ↔ code diff stays zero.

  • MissingInput is a typed carrier, not program_extension JSON (Decision 4). ProgramResult.missing_inputs: Option<Vec<MissingInput>> mirrors a compliance/input-requirements/<program>.toml gap row (field / source_class / gap_issue), so the input_unsatisfiable outcome names the exact facts a worker must supply. It is an in-flight signal, not persisted.

  • MR3b ships the household-level input_unsatisfiable; per-child gap surfacing is deferred. The synthesized input_unsatisfiable (no person_id) names the household-level un-buildable worker-facts and carries determination_id: None (the MR1 ProgramResult.determination_id contract: None for any result synthesized without a recorded determination — superseding the earlier "stable unsigned determination_id`" idea; MR4’s checklist keys off the immediate `DetermineResponse, not a persisted id, since the carrier is not persisted). The two-tier model’s per-child gap surfacing — a program naming a missing fact per enumerated child (carrying person_id) — is deferred: it is unreachable until the orchestrator can build partial per-child facts (#860 / corpus). The MR1 person_id carriers (on both the signed envelope and ProgramResult) already support it, so it is a zero-rework extension then. In MR3b the program returns only real signed determinations (ADR-002 black-box), and required per-child fields (has_special_needs) mean a missing fact is named in the household-level result rather than silently defaulted (ADR-034 §5).

Verification

  • MR1: cargo nextest run -p canopy-signing -p canopy-contracts-eligibility (signing absent-vs-present person_id compat + canonical-payload coverage; ProgramResult/MissingInput round-trip + absent-key-drop). cargo xtask api-docs --update regenerates the six embedding snapshots (snap/tanf/medicaid/caps/wic + eligibility) — additive optional properties only. The existing determination integration/e2e exercises the new column with NULL; seed + e2e stay byte-for-byte green (a signature failure would mean the compat guarantee broke).

  • Per-MR pre-push gate: cargo xtask validate + cargo xtask seed + cargo xtask e2e; cargo xtask quality-budgets (typed carriers — no serde_json::Value, no new #[allow]).

Edit this page · default