Plan: Medicaid Orchestrator EE15 Hierarchy Wiring
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add |
Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 ( |
2 |
Capture |
Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 ( |
3 |
Populate |
Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 ( |
4 |
Unit tests — ProgramDeterminationResponse round-trips |
Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 ( |
5 |
Integration test — POST to |
Done (2026-04-28) — |
6 |
Update |
Done (2026-04-28) — |
Branch: feature/medicaid-ee15-orchestrator-wiring
Labels: type::feature, priority::critical, program::medicaid, service::eligibility, workflow::ready, compliance::hipaa
Context
Per ADR-002, canopy-eligibility receives a signed Medicaid determination. Federal rules (Medicaid State Plan EE15 / PAMMS 2052) require the most-advantageous coverage group when an applicant qualifies under more than one Class of Assistance (COA).
The hierarchy logic is delivered as rulesets/georgia/medicaid-eligibility-hierarchy.json and canopy-medicaid already evaluates it inside its determination pipeline (services/canopy-medicaid/src/determine.rs, Step 7 "EE15 hierarchy"). The resulting COA code is stored on MedicaidDetermination.assigned_coa and included in the signed determination payload that is returned to the orchestrator.
The orchestrator, however, deserialises responses into a smaller ProgramDeterminationResponse struct that does not carry assigned_coa. The field is therefore silently dropped at the orchestrator boundary, and CombinedResult.medicaid_assigned_group is hard-coded None:
// services/canopy-eligibility/src/orchestrator.rs:565
medicaid_assigned_group: None, // TODO: EE15 hierarchy when Medicaid is implemented
Downstream subscribers (T-MSIS extractor, enrollment, notices) therefore see NULL for the coverage-group assignment and fall back to heuristics that do not match the State Plan hierarchy.
Design choice: the original plan contemplated having the orchestrator call canopy-rules a second time with the hierarchy ruleset. That would duplicate work canopy-medicaid already performs and require additional data plumbing (the list of eligible COAs) that is not on the orchestrator’s boundary. The simpler, correct fix is to widen the orchestrator’s response view just enough to read the assigned_coa that canopy-medicaid has already produced and signed.
Scope
In scope:
-
One new field on
ProgramDeterminationResponse, gated by#[serde(default, skip_serializing_if = "Option::is_none")]so the re-serialisation used for signature verification stays byte-compatible for programs that do not emitassigned_coa(SNAP, TANF, CAPS, WIC). -
Orchestrator capture logic that extracts
assigned_coafrom Medicaid determinations that pass signature verification. -
Assignment into
CombinedResult.medicaid_assigned_group. -
Unit + integration tests.
-
Documentation sync.
Out of scope:
-
Changes to
canopy-medicaid— it already emits the field. -
Changes to
medicaid-eligibility-hierarchy.json— ruleset is correct. -
Re-running the hierarchy in the orchestrator — canopy-medicaid already does this.
-
Fixing the longstanding signature-verification byte mismatch between
MedicaidDeterminationandProgramDeterminationResponse(see Errata below).
Dependencies
-
services/canopy-eligibility/src/orchestrator.rs—ProgramDeterminationResponse, collection loop,assemble_combined_resultcall site at line 565. -
services/canopy-eligibility/src/store/models.rs—CombinedResult.medicaid_assigned_group: Option<String>(already present). -
services/canopy-medicaid/src/store/models.rs—MedicaidDetermination.assigned_coa: Option<String>(already present).
No new crates, no new env vars, no new HTTP clients.
Design
Response-type widening
// services/canopy-eligibility/src/orchestrator.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgramDeterminationResponse {
pub id: DeterminationId,
pub application_id: ApplicationId,
pub household_id: HouseholdId,
pub status: String,
pub benefit_amount: Option<Decimal>,
pub benefit_unit: Option<String>,
pub effective_date: Option<String>,
pub expiration_date: Option<String>,
pub renewal_date: Option<String>,
pub basis: Option<String>,
/// Medicaid-only. Set by canopy-medicaid's internal EE15 hierarchy evaluation;
/// other program services leave it absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_coa: Option<String>,
pub signature: String,
pub program_service_version: String,
pub determined_at: String,
}
skip_serializing_if = "Option::is_none" is critical: it keeps re-serialisation byte-identical for SNAP/TANF/CAPS/WIC responses (which never have the field), so existing signature verification continues to work unchanged.
Orchestrator capture
Inside the existing results loop (around orchestrator.rs:395), after signature_verified = true, track the Medicaid determination’s assigned_coa separately:
let mut medicaid_assigned_group: Option<String> = None;
for handle in handles {
match handle.await {
Ok(Ok((program_name, program_enum, det))) => {
// … existing signature verification …
if sig_verified {
// existing persistence path, plus:
if program_enum == Program::Medicaid {
medicaid_assigned_group = det.assigned_coa.clone();
}
// … existing approved/denied/pending bucketing …
}
}
// …
}
}
If the Medicaid determination fails signature verification or is unreachable, medicaid_assigned_group stays None — the quarantined path must not leak into combined results.
Steps
Step 1: Response-type widening
Files: services/canopy-eligibility/src/orchestrator.rs.
Add assigned_coa per Design. Keep the field between basis and signature so serialisation order matches the surrounding fields alphabetically (SNAP’s SnapDetermination does not have it, so field order between them is irrelevant for SNAP signatures).
Step 2: Capture on verify
Files: services/canopy-eligibility/src/orchestrator.rs.
Introduce let mut medicaid_assigned_group: Option<String> = None; above the results loop. Inside the "signature verified" branch, copy det.assigned_coa into it when program_enum == Program::Medicaid.
Step 3: Assemble combined result
Files: services/canopy-eligibility/src/orchestrator.rs line 565.
Replace the None, // TODO: … line with the captured variable. Delete the comment.
Step 4: Unit tests
Files: services/canopy-eligibility/src/orchestrator.rs test module.
Two tests:
-
program_determination_response_round_trips_without_assigned_coa— deserialise a SNAP-shaped payload (no field), assertassigned_coa.is_none(), re-serialise, assert the JSON does not containassigned_coa(provesskip_serializing_ifworks and signature verification stays compatible). -
program_determination_response_round_trips_with_assigned_coa— deserialise a Medicaid-shaped payload with"assigned_coa":"pregnant_women", assertSome("pregnant_women"), re-serialise, assert the JSON does contain"assigned_coa":"pregnant_women".
Step 5: Integration test
Files: services/canopy-eligibility/tests/ee15_orchestrator_test.rs (new).
Using canopy_test_lib::TestConfig::from_env(), drive a real POST /v1/eligibility/determine for a seeded household that qualifies for Medicaid under ≥1 COA. Assert the response row in combined_results.medicaid_assigned_group is Some(_) (exact value depends on seed data; presence is the invariant).
Skip if infrastructure is unavailable per existing integration-test pattern.
Step 6: Documentation sync
Files: docs/modules/ROOT/pages/plans/eligibility-orchestrator.adoc, docs/modules/ROOT/pages/roadmap.adoc, .claude/CLAUDE.md, CHANGELOG.adoc.
-
eligibility-orchestrator.adoc— Step 4 row: replace "Partial" with "Complete (ruleset: Phase F 2026-04-13; orchestrator propagation: this MR)". -
roadmap.adocTier 5.5 — remove theorchestrator.rs:565row. -
.claude/CLAUDE.md— Medicaid table entry: "EE15 38-COA hierarchy" → "EE15 38-COA hierarchy (orchestrator-propagated)". -
CHANGELOG.adoc— entry under== Unreleased.
Files Touched
| File | Change |
|---|---|
|
Add |
|
New integration test |
|
Step 4 status |
|
Remove Tier 5.5 entry |
|
Flip EE15 note |
|
Entry under |
Verification
-
cargo nextest run -p canopy-eligibility --lib— 2 new unit tests pass -
cargo xtask test --integration— new integration test passes; existing integration tests unchanged -
cargo xtask validate— full pre-push battery green -
Manually drive a Medicaid applicant through
/v1/eligibility/determineagainst a full-profile devstack; inspect the persisted combined_results row:medicaid_assigned_group IS NOT NULL -
Repeat with a SNAP-only applicant (no Medicaid):
medicaid_assigned_group IS NULL, SNAP signature verification still passes (regression check)
Documentation Updates
-
eligibility-orchestrator.adocStep 4 status -
roadmap.adoc— drop Tier 5.5 entry -
.claude/CLAUDE.md— Medicaid note flipped to orchestrator-propagated -
CHANGELOG.adocentry
Errata
Preexisting Medicaid signature-verification byte mismatch — RESOLVED 2026-05-05
Original wording (preserved for historical traceability):
The orchestrator’s signature-verification step re-serialises ProgramDeterminationResponse and verifies against the persisted JWS. MedicaidDetermination (what canopy-medicaid signs) carries additional fields not present on ProgramDeterminationResponse — notably assigned_coa_track, benefit_type, denial_reason, denial_reason_codes, fmap_rate, continuous_eligibility_end, medicaid_application_id, person_id, created_at. The re-serialised bytes therefore differ from the original signed bytes, and Medicaid signatures are expected to verify as invalid on the orchestrator side today.
This plan does not fix that. Adding assigned_coa keeps the new field safe (via skip_serializing_if) for non-Medicaid programs, but the Medicaid path has been broken since before Phase F and remains broken after this MR. Follow-up work — a normalised "determination envelope" distinct from the per-program internal record — belongs in a separate plan. File as determination-envelope-normalisation.adoc when the orchestrator signature-verification pipeline is reworked.
Until that plan lands, Medicaid determinations that reach the orchestrator are quarantined (status signature_quarantined) and excluded from combined results regardless of this change. The capture logic in Step 2 deliberately sits inside the sig_verified branch so no unverified data can flow into medicaid_assigned_group.
Resolution (2026-05-05 — issue #387):
crates/canopy-signing/src/envelope.rs introduces the universal SignableDetermination envelope with byte-stable build() constructor (truncate_to_micros on timestamps + rescale(2) on decimals). All five program services emit the envelope; the orchestrator’s ProgramDeterminationResponse collapses to a pub type alias for SignableDetermination. Medicaid-specific fields (assigned_coa, assigned_coa_track, benefit_type, fmap_rate, continuous_eligibility_end, denial_reason, person_id) move into program_extension; EE15 propagation reads them from there. The quarantine band-aid stays in the orchestrator as defence-in-depth but is no longer the load-bearing path for medicaid determinations.
See determination-envelope-normalisation for the full plan + verification.