Plan: Per-Subject Determination + Program Mappers (ADR-035 Slice 1, #857, epic &63)
On this page
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 |
|
Done (2026-06-16) — signing compat + |
MR2. Per-program context-mapper seam (a staged realization of Decision 1) |
The orchestrator-internal |
Done (2026-06-16) — byte-identity |
MR3a. Orchestrator per-subject receive plumbing (behavior-inert) |
The orchestrator’s dispatch-parse boundary accepts either a bare envelope (every program today) or a |
Done (2026-06-17) — |
MR3b. CAPS per-subject behavior + the fallible context seam (first real per-subject behavior) |
Reshape |
Done (2026-06-16) — caps_test per-child reshape (incl. multi-child + signed- |
MR4. canopy-web per-subject Determination tab |
Replace the five per-program Determination tabs with one household-wide grouped roster ( |
Done (2026-06-17) — Rust render-fixture tests (representative + dense, strip/roster/all kinds/denial/unsat-checklist/actions/operator-cluster) + |
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 aVecinternally (the orchestrator never fans out, never runscompose_*_au— ADR-001/027 §7). -
Backward-compatible carrier first (MR1). The signed envelope is the cross-service trust boundary, so the
person_idfield lands alone, defaultedNone, withskip_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 theProgramInputplumbing. ADR-035 Decision 1 specifies aProgramContextMappertrait + per-program impls returningResult<ProgramInput, ContextError>incanopy-eligibility/src/mappers/. MR2 landed a thin pass-through free-fnmap_context; MR3b widens it tomap_context(program, &ApplicationContext) → Result<ApplicationContext, ContextError>— the complete-or-provisional gate, which is the architecturally load-bearing half of Decision 1. The untaggedProgramInput { Generic, Caps }enum + theProgramContextMappertrait are deliberately deferred (a precommit-Q4 deviation from this row’s original wording): the genericApplicationContextcarries none of CAPS’s worker-facts, so aCapsApplicationContextis not yet constructible — aProgramInput::Capsvariant would be constructed nowhere and failclippy -D warningsas 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. -
MissingInputis a typed carrier, notprogram_extensionJSON (Decision 4).ProgramResult.missing_inputs: Option<Vec<MissingInput>>mirrors acompliance/input-requirements/<program>.tomlgap row (field/source_class/gap_issue), so theinput_unsatisfiableoutcome 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 synthesizedinput_unsatisfiable(noperson_id) names the household-level un-buildable worker-facts and carriesdetermination_id: None(the MR1ProgramResult.determination_idcontract:Nonefor any result synthesized without a recorded determination — superseding the earlier "stable unsigneddetermination_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 (carryingperson_id) — is deferred: it is unreachable until the orchestrator can build partial per-child facts (#860 / corpus). The MR1person_idcarriers (on both the signed envelope andProgramResult) 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-presentperson_idcompat + canonical-payload coverage;ProgramResult/MissingInputround-trip + absent-key-drop).cargo xtask api-docs --updateregenerates the six embedding snapshots (snap/tanf/medicaid/caps/wic + eligibility) — additive optional properties only. The existing determination integration/e2e exercises the new column withNULL; 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 — noserde_json::Value, no new#[allow]).