ADR-034: Per-Program Determination Context-Mapping

On this page

Status

Accepted (2026-06-15).

NOTE
Ratified; implementation is tracked by epic &63 (orchestrator per-program determination-context building) and is post-UAT correctness work (UAT is SNAP-only). This follows the ADR-027 / ADR-028 pattern: the decision lands Accepted, with implementation sequenced under a tracking epic.

Amends

ADRs are immutable once accepted, so this ADR amends ADR-002 rather than editing it. Read both together: ADR-002 makes a determination a signed, minimal black-box output and says the orchestrator "submits an application context" to each program; this ADR specifies how that context is built — per-program, typed, and complete-or-provisional — and forbids the two failure modes the under-specified generic context produces today.

Relates

  • ADR-028 — Determination Input Snapshot: ADR-034 is the how-the-input-is-built companion to ADR-028’s what-the-determination-freezes. ADR-028 already requires the orchestrator to enrich the determine context (provenance + resolved policy params + ruleset corpus-hash) for the signed snapshot; ADR-034 makes that enrichment per-program and complete. The two share the coordinated multi-service signing-change pattern ADR-028 specifies for snapshot_hash (ADR-028 § "Binding, immutability, and storage").

  • ADR-027 — Worker Fact Authoring and Provenance: the worker-authored, provenance-carrying, valid-time-versioned fact corpus is the source the per-program mapping reads from. ADR-027 is ratified but not yet built (post-UAT, epic &56); pre-corpus the mapper reads today’s canopy-persons aggregation + names the inference shims as explicit provisional inputs.

  • ADR-001 / ADR-004: the boundary the mapping must preserve (see Decision 10).

This ADR extends ADR-031 — Policy Coverage Assurance: the per-program input-requirements manifest + satisfiability CI gate (Decision 6) is a fourth machine-checkable coverage axis in the ADR-031 family, with the same staged advisory→blocking discipline that ADR-032/033 inherit.

Structural precedent: the worker-portal composition loader (ADR-021 / ADR-022 / ADR-024) — a PluginSource trait + a #[canopy_plugin]/linkme manifest + closed-set `CompositionLoadError`s, resolving a baseline + layers into a per-surface output with structured early failures. The eligibility analog is a fact-corpus → per-program typed determine input, with closed-set `ContextError`s + a per-program input-requirements manifest.

NOTE
The orchestrator’s existing pending_verification bucketing behavior is the implementation pattern the provisional / input-unsatisfiable path generalizes — not ADR-005, which is Modular Deployment Profiles and whose required→required rule is fail-fast 503 (a deployment-misconfiguration signal, the opposite of a missing-fact signal).

Context

ADR-002 makes each program service a black box returning a signed, minimal determination, and says the orchestrator "submits an application context" to each program. It does not specify how that context is built. Today the orchestrator builds one generic ApplicationContext (services/canopy-eligibility/src/orchestrator.rs:714-733) and POSTs the identical .json(&ctx) to every program (orchestrator.rs:861) — there is no per-program branch. Because each program ships its own typed determine contract, this single broadcast produces two failure modes:

  • Silent serde-defaults → confidently-wrong verdicts, with no error. canopy-medicaid defaults roughly sixteen policy-material inputs the generic context never sets — is_pregnant: false, is_institutionalized: false, level_of_care_met: false, has_medicare_part_a: false, countable_resources: ZERO (a live TODO(#856) marks it), medical_expenses_monthly: ZERO, and more (services/canopy-medicaid/src/determine.rs:229-254). canopy-tanf defaults dependent_children: 0 and deprivation_verified: false. Each default silently produces a verdict on a fabricated assumption. (The orchestrator→Medicaid age/disability instance was corrected as the first slice of epic &63 by resolving the applicant from the threaded members[]; that fix is retroactively the first input under this contract.)

  • Required-no-default 422 → a mis-labeled, non-actionable "pending" bucket. CAPS CapsApplicationContext (crates/canopy-contracts-caps/src/determine.rs:18-32) and WIC WicApplicationContext (crates/canopy-contracts-wic/src/determine.rs) have required fields with no #[serde(default)] that the generic context lacks, so the dispatch deserializes to a 422. The orchestrator carries the raw 422 body but mis-labels it "Service unavailable: …" and buckets it pending_verification (orchestrator.rs:903,1115) — a result no worker can act on.

There is no architectural rule that a program determination’s input wire-shape must be satisfiable from what the orchestrator can supply. That missing rule is the root cause both failure modes share.

The one existing good pattern

deprivation_provisional is the seam to generalize. The orchestrator infers TANF deprivation from household composition, sets a provisional flag (orchestrator.rs:711), and canopy-tanf’s mark_provisional_deprivation helper (services/canopy-tanf/src/determine.rs:206-219) writes it into the signed program_extension so an inferred basis is distinguishable from a verified one. This is the "fact absent → mark provisional, don’t silently default" pattern — and program_extension is the only program-signable channel for such a marker today.

The carriers — there is no provisional / missing-input carrier anywhere today (load-bearing for the decisions)

  • The signed SignableDetermination (crates/canopy-signing/src/envelope.rs:70-119) carries only denial_reason_codes + program_extensionprogram_extension is the only program-signable channel.

  • verification_items_required (services/canopy-eligibility/src/determination.rs:22) is a legacy/internal orchestrator field, empty everywhere (no program’s signed determination populates it; the worker-portal "default SNAP checklist" at services/canopy-web/src/api/applications.rs:229 is a separate UI artifact, not this field). It is not a precedent to lean on.

  • The orchestrator result carriers — ProgramResult (crates/canopy-contracts-eligibility/src/determine.rs:45), CombinedResult (services/canopy-eligibility/src/store/models.rs:47), and the persisted rows — have no provisional / missing-input fields; the orchestrator reads only program_extension.assigned_coa from a returned determination (orchestrator.rs:1069); failures synthesize an unsigned pending_verification ProgramResult (orchestrator.rs:1115).

So every provisional concept this ADR introduces needs new carriers at up to three layers (signed program output → orchestrator result → persisted row). v1 uses the one that exists (program_extension); a first-class signed field is the coordinated canopy-signing + multi-service + verifier change ADR-028 specifies for snapshot_hash. The decisions below name these additions rather than implying existing fields suffice.

Decision

The eligibility orchestrator builds each program’s determination input via an explicit, typed, per-program context mapping that is complete-or-provisional.

1. Per-program mapping replaces the generic broadcast

The orchestrator MUST build each program’s /v1/determine input via a dedicated, typed map_<program>_context(facts, policy) → Result<ProgramInput, ContextError> — not by serializing one ApplicationContext and relying on each program to deserialize-or-422 / silently-default. (Replaces orchestrator.rs:714-733/861.)

2. Orchestrator-satisfiability invariant (rule + staged enforcement)

Rule: every policy-material, required input of a program determination MUST be either (a) satisfiable from the fact source + resolved policy params, or (b) routed to the provisional / input-unsatisfiable path (Decision 4). A program MUST NOT carry a required input the mapper cannot account for.

Enforcement timeline (honest, because the ADR-027 corpus is post-UAT): pre-corpus, the satisfiability gate (Decision 6) checks the mapper against today’s canopy-persons reads + named inference shims (advisory); post-corpus (epic &56), against the fact corpus (blocking).

3. Ban silent defaults for policy-material inputs

A determination MUST NOT silently default a policy-material input to a value that yields a verdict (the age.unwrap_or(30) / countable_resources.unwrap_or(0) / has_medicare_part_a.unwrap_or(false) anti-pattern). When such a fact is absent, the outcome is provisional / input-unsatisfiable per Decision 4, naming the missing input(s). Non-material / derived inputs may still default; the rule is scoped to policy-material inputs to avoid absolutism.

4. Two distinct outcomes — a SIGNED provisional verdict vs an ORCHESTRATOR-SYNTHESIZED unsatisfiable result

These are different because one has a program signature and the other cannot.

  • Provisional verdict (signed). The mapper built the input, the program returned an approved/denied verdict, but it rested on an absent policy-material fact the orchestrator had to default or infer. The program marks it provisional, naming the missing inputs — a marker on a real, signed verdict. Representation: v1 = a signed program_extension marker + named missing inputs (the deprivation_provisional route — the only program-signable channel today, services/canopy-tanf/src/determine.rs:206-219). v2 = a first-class field on SignableDetermination (the coordinated canopy-signing + multi-service + verifier change ADR-028 specifies for snapshot_hash). The legacy empty verification_items_required is NOT this carrier.

  • Input-unsatisfiable result (orchestrator-synthesized, NOT signed, NOT called "provisional"). The mapper cannot build a program’s required input → there is no dispatch and no program signature. The orchestrator synthesizes a result — an enhanced pending_verification (orchestrator.rs:1115) that names the missing inputs. Because no program signed anything, this is explicitly NOT a provisional verdict; it is a structured, actionable "needs facts [X, Y]" that replaces today’s mis-labeled "Service unavailable: 422 …" bucket. This requires a new missing-inputs carrier on ProgramResult (it has none today, crates/canopy-contracts-eligibility/src/determine.rs:45).

5. 422-by-construction eliminated; whole-program-uncapturable → input-unsatisfiable

Because the orchestrator emits each program’s typed input, a missing required field is a build-time ContextError — never an opaque program-side 422 mis-labeled "Service unavailable." A program whose required inputs are entirely uncapturable today (CAPS / WIC pre-capture, #857 / #769) yields the input-unsatisfiable result of Decision 4 (orchestrator-synthesized, missing inputs named on ProgramResult) — not a 422, not a silent skip, and not a "provisional verdict" (nothing was signed).

6. Per-program input-requirements manifest + a satisfiability CI gate (extends ADR-031)

Each program declares its determine-input requirements (field; required|optional; policy-material?; source-class) as a single machine-checkable artifact in the ADR-031 compliance/ coverage family, checked by a cargo xtask gate (staged advisory→blocking per ADR-031). The manifest carries a version (composition Plugin.toml-style). One mechanism — the ADR-031-style declared artifact + gate — is canonical; the in-code declaration that feeds it (a linkme distributed-slice, or derivation from the contract structs) is an implementation detail deferred to the manifest slice.

Amendment (#862): for ADR-035 multi-subject contracts (CAPS children[], WIC participants[]), a per-subject row declares nested_in = "<parent array property>" and the gate drift-checks it against the parent’s resolved item schema (items.$ref, or inline items.properties) with the same two-way completeness + required-ness check as the top level. Nested checking is opt-in per parent; a parent array with no nested_in rows (Medicaid members[]) is checked as one opaque field. With the CAPS/WIC contexts exported to their OpenAPI snapshots, the drift cross-check is uniform across all five programs.

7. The mapping is ADR-028’s enrichment seam, per-program and complete

ADR-028 already requires the orchestrator to enrich the context (provenance + policy params + corpus-hash) for the signed snapshot. ADR-034 specifies that enrichment is per-program and complete: the mapper produces the exact typed, provenance-carrying input each program signs and snapshots. ADR-034 governs how the input is built; ADR-028 governs what the determination freezes.

8. Determination-subject selection (single-subject programs only)

For single-subject programs the mapper selects the determination subject (CAPS = the child; WIC = the participant) instead of the orchestrator’s hardcoded applicant = head. Per-member Medicaid subject-selection is explicitly out of scope of this ADR — it brushes ADR-002 program-ownership and is the design subject of #860; it is wholly deferred there.

9. Provisional / unsatisfiable state propagates to the combined result + EE15 — via new carriers the ADR names

A provisional verdict (or an input-unsatisfiable result) participates in the assembled combined result and the EE15 most-advantageous-group assignment (ADR-002), but its flag + named missing inputs MUST propagate so it is never presented as firm or allowed to silently win the group assignment without surfacing the verification it needs. This is not free with today’s schema: it requires adding provisional / missing-input fields to ProgramResult (crates/canopy-contracts-eligibility/src/determine.rs:45), CombinedResult (services/canopy-eligibility/src/store/models.rs:47), and the persisted rows, plus the orchestrator parsing the program_extension provisional marker (it reads only program_extension.assigned_coa today, orchestrator.rs:1069). These are named carrier-schema follow-ups.

10. Boundary preservation + restricted-source carve-out (ADR-001 / ADR-002 / ADR-004)

The mapping assembles only non-restricted worker-authored facts (ADR-027) + policy; the program still signs the determination + snapshot (ADR-002 / ADR-028); ADR-034 changes how the input is assembled, not the black-box output.

ADR-004 governs restricted sources broadly — FTI, SSA SOLQ/BINDEX, FDSH, SAVE — not just IRS FTI. FTI never enters the orchestrator. But the orchestrator today fetches SSA SOLQ pre-dispatch for Medicaid and carries it in ApplicationContext.ssa_solq (orchestrator.rs:~650, crates/canopy-contracts-eligibility/src/determine.rs:113, a pre-existing #384 mechanism). ADR-034 explicitly does not bless that as the target state: restricted-source inputs are out of scope of the mapper, and the existing orchestrator-resident SOLQ is flagged as a pre-existing ADR-004 tension to relocate into the program boundary (program-side fetch) as separate work. ADR-034 neither introduces nor ratifies orchestrator-side restricted-source handling.

Consequences

  • The orchestrator’s single .json(&ctx) dispatch becomes per-program map_* mappers plus a ProgramServiceRegistry extension (the registry is the extension point; today it gates only on "is the URL configured," services/canopy-eligibility/src/registry.rs).

  • Each program publishes a versioned input-requirements manifest; a new ADR-031 coverage gate asserts satisfiability (advisory pre-corpus, blocking post-corpus).

  • Silent policy-material defaults are removed from determine handlers; absent facts yield either a signed provisional verdict via program_extension (v1) / a first-class field (v2), or an orchestrator-synthesized input-unsatisfiable result naming the missing inputs.

  • New carrier schema is required (named follow-ups, not free): a missing-inputs field on ProgramResult + CombinedResult + the persisted rows; the orchestrator parsing the program_extension provisional marker; and (v2) a first-class signed field on SignableDetermination (a coordinated multi-service change, the ADR-028 snapshot_hash pattern).

  • Unblocks the epic &63 backlog coherently: CAPS / WIC (#857 / #769 → whole-program input-unsatisfiable-with-reason, not 422); Medicaid age (done) + resources + Medicare threaded as one mapper under this contract instead of nil-impact per-input slices; #860 (per-member); the frequency-normalization foundation (the mapper is where normalized-monthly amounts are produced); #858 untracked facts (each becomes a declared-required input the manifest surfaces as an explicit gap).

  • Full realization depends on the ADR-027 corpus (post-UAT, epic &56); pre-corpus the mapper reads canopy-persons + names the inference shims (deprivation) as provisional.

  • Migration is incremental and non-breaking: the manifest + advisory gate land first; mappers migrate one program at a time; the gate goes blocking post-corpus. Epic &63 slice 1 (Medicaid age) is retroactively the first input under this contract.

Alternatives considered

Alternative 1: Add serde defaults to CAPS / WIC so the generic context deserializes. Rejected — spreads the silent-default anti-pattern to two more programs.

Alternative 2: Each program loosens its contract to all-optional and handles missing inputs internally. Rejected — scatters "what is required" into each program opaquely; provides no central satisfiability gate; re-creates the per-program silent-default risk.

Alternative 3: Hard-fail (422 / 503) on any unsatisfiable input. Rejected — a capturable-but-uncaptured fact should yield a worker-resolvable provisional / input-unsatisfiable result, not a silent program drop. (Distinct from ADR-005’s required-peer 503, which signals deployment misconfiguration, not a missing fact.)

Alternative 4: Orchestrator-as-brain (compute determinations centrally). Rejected — violates ADR-001 / ADR-002 / ADR-004 (data isolation, FTI boundary).

Alternative 5: Status quo (generic broadcast). Rejected — the documented `422`s and confidently-wrong silent-defaults are the motivation.

References

Edit this page · default