Plan: Medicaid Orchestrator EE15 Hierarchy Wiring

On this page

Status

Step Description Status

1

Add assigned_coa: Option<String> to ProgramDeterminationResponse in the orchestrator

Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 (orchestrator.rs:259)

2

Capture assigned_coa from verified Medicaid determinations during the collection loop

Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 (orchestrator.rs:565)

3

Populate CombinedResult.medicaid_assigned_group in assemble_combined_result, replacing the None TODO at orchestrator.rs:565

Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 (orchestrator.rs:613)

4

Unit tests — ProgramDeterminationResponse round-trips assigned_coa both for canopy-snap-shaped payloads (no field) and canopy-medicaid-shaped payloads (field present)

Done (2026-04-18) — commit ec3dcf1; verified 2026-04-26 (orchestrator.rs:685+)

5

Integration test — POST to /v1/eligibility/determine for a household with Medicaid and assert medicaid_assigned_group is non-null

Done (2026-04-28) — medicaid_ee15_assigned_group_propagates_through_orchestrator un-ignored in services/canopy-eligibility/tests/eligibility_test.rs; passes against devstack. Closure required closing #338 (MR !138): per-program signing-key infrastructure (.keys/ mount, VerifyingKeyRegistry::from_env_or_keys_dir fallback, all 5 programs have keys), raw-bytes signature verification (orchestrator was re-serialising through a subset struct, dropping fields the program had signed), and DB-roundtrip-safe Decimal/timestamp normalisation in canopy-snap. The earlier authoring round’s gaps #1 (program URLs), #2 (bearer-token forwarding), #3 (income alias) all landed in prior MRs; gap #4 (signing keys) + gap #5 (subset-struct mismatch) + gap #6 (DB roundtrip) all in MR !138.

6

Update eligibility-orchestrator.adoc Step 4 row, remove the Tier 5.5 entry from roadmap.adoc

Done (2026-04-28) — eligibility-orchestrator.adoc Step 4 updated 2026-04-18; this plan’s Status flipped to Done in this MR; plan archived to plans/archive/medicaid-orchestrator-ee15-wiring.adoc; Tier 9 active-plans table loses the row.

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 emit assigned_coa (SNAP, TANF, CAPS, WIC).

  • Orchestrator capture logic that extracts assigned_coa from 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 MedicaidDetermination and ProgramDeterminationResponse (see Errata below).

Dependencies

  • services/canopy-eligibility/src/orchestrator.rsProgramDeterminationResponse, collection loop, assemble_combined_result call site at line 565.

  • services/canopy-eligibility/src/store/models.rsCombinedResult.medicaid_assigned_group: Option<String> (already present).

  • services/canopy-medicaid/src/store/models.rsMedicaidDetermination.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.

Assemble combined result

Replace the existing line:

medicaid_assigned_group: None, // TODO: EE15 hierarchy when Medicaid is implemented

with

medicaid_assigned_group,

using the variable captured above.

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:

  1. program_determination_response_round_trips_without_assigned_coa — deserialise a SNAP-shaped payload (no field), assert assigned_coa.is_none(), re-serialise, assert the JSON does not contain assigned_coa (proves skip_serializing_if works and signature verification stays compatible).

  2. program_determination_response_round_trips_with_assigned_coa — deserialise a Medicaid-shaped payload with "assigned_coa":"pregnant_women", assert Some("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.adoc Tier 5.5 — remove the orchestrator.rs:565 row.

  • .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

services/canopy-eligibility/src/orchestrator.rs

Add assigned_coa field; capture on verify; populate combined result

services/canopy-eligibility/tests/ee15_orchestrator_test.rs

New integration test

docs/modules/ROOT/pages/plans/eligibility-orchestrator.adoc

Step 4 status

docs/modules/ROOT/pages/roadmap.adoc

Remove Tier 5.5 entry

.claude/CLAUDE.md

Flip EE15 note

CHANGELOG.adoc

Entry under == Unreleased

Verification

  1. cargo nextest run -p canopy-eligibility --lib — 2 new unit tests pass

  2. cargo xtask test --integration — new integration test passes; existing integration tests unchanged

  3. cargo xtask validate — full pre-push battery green

  4. Manually drive a Medicaid applicant through /v1/eligibility/determine against a full-profile devstack; inspect the persisted combined_results row: medicaid_assigned_group IS NOT NULL

  5. Repeat with a SNAP-only applicant (no Medicaid): medicaid_assigned_group IS NULL, SNAP signature verification still passes (regression check)

Documentation Updates

  • eligibility-orchestrator.adoc Step 4 status

  • roadmap.adoc — drop Tier 5.5 entry

  • .claude/CLAUDE.md — Medicaid note flipped to orchestrator-propagated

  • CHANGELOG.adoc entry

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.

Edit this page · default