Plan: Medicaid JDM Completion (Issue #386)

On this page

Status

Step Description Status

1

Author rulesets/georgia/medicaid-cmd-cascade-priority.json. Follows the input. / context.thresholds. namespaced shape from rulesets/georgia/medicaid-eligibility-hierarchy.json and NamespacedEval in crates/canopy-rules-client/src/lib.rs. input.*: track: "abd"|"family", applicant_age: int, has_child_under_19: bool, pregnant: bool, is_cha: bool, has_disability: bool. Output: { priority_order: [string] } (ordered list of COA codes). Encodes the same priority order currently hardcoded in services/canopy-medicaid/src/cmd_cascade.rs:34-88. ABD priority and Family priority are encoded as two table-decisions selected by the input.track value. Verified by cargo xtask rules check.

Done (2026-05-11) — deviation: inputs simplified to just input.track since the underlying priority orders are static-per-track in PAMMS 2052 (no per-applicant variation). applicant_age / has_child_under_19 / etc. would be needed only if the priority varied per applicant; today’s PAMMS doesn’t. switchNode on input.track routes to one of two expressionNode`s emitting the ordered COA-code array. Compiles + evaluates clean via `cargo xtask rules check.

2

Refactor services/canopy-medicaid/src/cmd_cascade.rs:34-88 to call canopy-rules via the existing MedicaidRulesClient (services/canopy-medicaid/src/rules_client.rs:275). Extend that wrapper with typed CmdCascadeInput / CmdCascadeOutput structs rather than passing raw serde_json::Value. The Rust Vec<Coa> constants get deleted; the function becomes a thin wrapper that builds the typed input, evaluates, parses the output. Rules-client invocation uses the canonical 5-arg evaluate(&ruleset_name, "application", app_id.into(), rules_input, bearer_token) signature from services/canopy-snap/src/determine.rs:355-363 (bearer-token forwarding per #424).

Done (2026-05-11) — cmd_cascade::run_cascade signature changes from sync fn(closure) → CmdCascadeResult to sync fn(&[MedicaidCategory], closure) → CmdCascadeResult. The two hardcoded Vec<MedicaidCategory> constants are deleted from cmd_cascade.rs. determine.rs pre-resolves both track orders in parallel via tokio::try_join! against MedicaidRulesClient::evaluate_cmd_cascade_priority and passes the concatenated priority_order into run_cascade. Round-trips per determination: 2 (one per track). New typed CmdCascadePriorityInput/Output structs in rules_client.rs.

3

Author rulesets/georgia/medicaid-tma-phase.json. Scope is only the phase-decision predicate (current_date >= coverage_start + phase_1_months) currently at services/canopy-medicaid/src/tma.rs:77-84 (income_test_required). Date arithmetic in build_tma_coverage (tma.rs:40-66) and the day-of-month QRF check in is_qrf_overdue (tma.rs:69-74) stay in Rust — chrono::Months + day-21 calendar logic is not naturally expressed in JDM. Namespaced shape: input.coverage_start_date, input.current_date; context.thresholds.phase_1_months. Output: { phase: "phase_1"|"phase_2", income_test_required: bool }.

Done (2026-05-11) — deviation: ruleset takes input.months_since_coverage_start (computed by the Rust caller via chrono::Months) instead of input.coverage_start_date + input.current_date. JDM date arithmetic is awkward; passing the pre-computed month delta keeps the calendar math in Rust and the policy decision (months >= phase_1_months → phase_2) in JDM. Single 2-rule decisionTable; output unchanged.

4

Refactor services/canopy-medicaid/src/tma.rs:77-84 (income_test_required) to call canopy-rules via MedicaidRulesClient with a typed TmaPhaseInput / TmaPhaseOutput. build_tma_coverage and is_qrf_overdue keep their existing Rust implementations. Existing TMA unit tests should still pass.

Done (2026-05-11) — income_test_required deleted from tma.rs (the function had a single caller in determine.rs). The phase-decision now happens once per determination in determine.rs block 3c via MedicaidRulesClient::evaluate_tma_phase, threading the resolved booleans (tma_phase_has_start, tma_phase_income_test_required) into the eligible_fn closure. The 2 deleted tma.rs tests (phase_1_no_income_test, phase_2_income_test_required) are replaced by the new ruleset fixture; build_tma_coverage / is_qrf_overdue tests stay. Round-trips per determination: 0-1 (only when tma_start_date.is_some()).

5

Author rulesets/georgia/medicaid-denial-reasons.json. Encodes the per-COA denial-reason synthesis at services/canopy-medicaid/src/determine.rs:478-536 (a denial_reason_fn closure with ~40 PAMMS-cited COA match arms — verified by reading the closure). Namespaced shape: input.coa: string plus the closure’s predicate inputs (input.age, input.is_pregnant, input.is_parent_caretaker, input.had_tanf_in_prior_months, input.has_medicare_part_a, input.has_medicare_part_b, input.is_institutionalized). Output: { denial_reason: string }. The output flows into SignableDetermination.program_extension.denial_reason (see invariants below).

Done (2026-05-11) — single decisionTable, 47 rules covering every PAMMS-cited COA + predicate combination from the original closure plus a catch-all r-catchall for unknown COAs. hitPolicy: "first" matches the closure’s match semantics; rule ordering preserves the original closure’s guard-order. All 7 predicate inputs from the closure are exposed as decision-table input columns.

6

Refactor the denial_reason_fn closure at services/canopy-medicaid/src/determine.rs:478-536 to call canopy-rules via MedicaidRulesClient with a typed DenialReasonsInput / DenialReasonsOutput. The closure shrinks from ~60 lines to ~10. Existing tests (especially the denial_reason_for_… test family) must still pass.

Done (2026-05-11) — pre-resolve via new resolve_denial_reasons helper (~30 lines) that issues N parallel ruleset calls via futures::future::join_all and builds a HashMap<MedicaidCategory, String>. The inline closure inside run_cascade stays sync, looking up reasons from the map. Round-trips per determination: N (= length of priority_order, ~38). Follow-up optimisation noted in CHANGELOG: redesign ruleset to take an array input + return an array output, dropping to one round-trip per determination — not done now because it would diverge from the existing per-row decision-table shape used by every other Medicaid ruleset.

7

Tests + docs. 3 ruleset-evaluation tests under crates/canopy-rules/tests/medicaid_jdm_completion_test.rs exercising each ruleset’s golden cases. All existing canopy-medicaid tests pass unchanged. CHANGELOG entry under === Changed (ADR-003 compliance). Plan moves to plans/archive/ post-merge.

Done (2026-05-11) — 3 new fixtures under crates/canopy-test-lib/fixtures/rulesets/ (medicaid-cmd-cascade-priority.json, medicaid-tma-phase.json, medicaid-denial-reasons.json) run via the existing cargo xtask rules check fixture path (16/16 fixtures pass). All 76 canopy-medicaid tests pass unchanged. Byte-stability invariant for SignableDetermination.program_extension.denial_reason is preserved by construction: the ruleset returns the exact same snake_case reason strings the closure did (verified by spot-check of r-pw-not-preg"not_pregnant", matching the closure’s MedicaidCategory::PregnantWomen if !is_pregnant ⇒ "not_pregnant" arm). CHANGELOG === Changed entry covers ADR-003 compliance + the deviations + the parallel round-trip note.

Issue: #386
Branch: feat/medicaid-jdm-completion
Labels: type::chore, priority::medium, service::medicaid, program::medicaid, workflow::ready

Context

Three pieces of Medicaid logic live in Rust and violate ADR-003 (ruleset-as-data):

  • services/canopy-medicaid/src/cmd_cascade.rs:34-88 — ABD priority order + Family priority order encoded as hardcoded Vec<Coa> constants. Changing the priority requires a code deploy.

  • services/canopy-medicaid/src/tma.rs:77-84 — the TMA phase-decision predicate (income_test_required). Sibling functions in the same module (build_tma_coverage at lines 40-66, is_qrf_overdue at lines 69-74) perform date arithmetic / day-of-month QRF logic that is not naturally expressed in JDM and stay in Rust.

  • services/canopy-medicaid/src/determine.rs:478-536denial_reason_fn closure (~40 PAMMS-cited COA match arms) that synthesises a per-COA denial reason string.

Medicaid’s 38-COA expansion is otherwise complete and runs through canopy-rules. These three are the last Rust holdouts. Moving them to JDM rulesets:

  • Lets policy changes ship as ruleset edits rather than code deploys.

  • Makes the priority + phase + reason logic auditable as data.

  • Keeps the canopy-medicaid Rust code focused on data marshalling and persistence.

The orchestrator-level eligibility hierarchy (EE15) already lives in rulesets/georgia/medicaid-eligibility-hierarchy.json and stays unchanged.

Code references

  • services/canopy-medicaid/src/cmd_cascade.rs:34-88 — hardcoded priority orders.

  • services/canopy-medicaid/src/tma.rs:77-84 — TMA phase-decision predicate (income_test_required). build_tma_coverage (lines 40-66) and is_qrf_overdue (lines 69-74) stay in Rust.

  • services/canopy-medicaid/src/determine.rs:478-536denial_reason_fn closure (~40 COA match arms).

  • services/canopy-medicaid/src/rules_client.rs:275 — existing MedicaidRulesClient typed wrapper; extend with typed input/output structs for the 3 new rulesets.

  • rulesets/georgia/medicaid-eligibility-hierarchy.json — EE15 ruleset (precedent for input. / context.thresholds. namespaced shape + naming).

  • services/canopy-snap/src/determine.rs:355-363 — canonical 5-arg RulesClient::evaluate invocation with bearer-token forwarding (post-#424).

  • crates/canopy-rules-client/src/lib.rs:67NamespacedEval envelope defining the namespaced shape.

  • crates/canopy-signing/src/envelope.rs:66SignableDetermination, the byte-stable signing envelope; medicaid-specific fields flow through program_extension.

  • ADR-003 — Ruleset as data

Scope

In scope:

  • 3 new JDM rulesets using the input. / context.thresholds. namespaced shape.

  • 3 Rust call-site refactors to thin wrappers over MedicaidRulesClient with typed input/output structs (not raw serde_json::Value).

  • Ruleset-evaluation tests under canopy-rules.

Out of scope:

  • Changing the actual priority / phase / reason logic — this plan is purely a relocation. Output for any given input is byte-equivalent to the pre-refactor Rust path.

  • Relocating build_tma_coverage or is_qrf_overdue (lines 40-66, 69-74) — these are chrono::Months date arithmetic and day-21 calendar checks that JDM does not express cleanly; only the income_test_required phase-decision predicate moves to JDM.

  • Compressing the EE15 ruleset further — already JDM, no work needed.

  • Cross-program cascade unification — SNAP / TANF / Medicaid each have their own cascades and should keep them separate.

Dependencies

  • cargo xtask rules check (existing) validates ruleset compilation. No prerequisite plans.

Design

All three rulesets follow the input. / context.thresholds. namespaced shape used by every existing georgia ruleset (precedent: rulesets/georgia/medicaid-eligibility-hierarchy.json). The rules-client envelope (crates/canopy-rules-client/src/lib.rs:67, NamespacedEval) serialises into that shape; flat-input rulesets fail evaluation.

Each Rust call site shrinks to a thin wrapper around the existing MedicaidRulesClient (services/canopy-medicaid/src/rules_client.rs:275), extended with typed input/output structs per ruleset (e.g., CmdCascadeInput / CmdCascadeOutput, TmaPhaseInput / TmaPhaseOutput, DenialReasonsInput / DenialReasonsOutput). Raw serde_json::Value plumbing stays inside the wrapper.

Call-site sketch (uses the 5-arg RulesClient::evaluate signature from services/canopy-snap/src/determine.rs:355-363 post-#424; bearer-token forwarding is mandatory):

pub async fn cmd_cascade_priority(
    track: CmdTrack,
    person: &Person,
    ctx: &CaseContext,
    app_id: ApplicationId,
    rules: &MedicaidRulesClient,
    bearer_token: &str,
) -> Result<Vec<Coa>, MedicaidError> {
    let input = CmdCascadeInput {
        track: track.to_str(),
        applicant_age: person.age(),
        has_child_under_19: ctx.has_child_under_19,
        pregnant: person.pregnant,
        is_cha: ctx.is_cha,
        has_disability: person.has_disability,
    };
    let thresholds = CmdCascadeThresholds { /* PAMMS-traced */ };
    let rules_input = build_namespaced_eval(input, thresholds)?;
    let output: CmdCascadeOutput = rules
        .evaluate(
            "medicaid-cmd-cascade-priority",
            "application",
            app_id.into(),
            rules_input,
            bearer_token,
        )
        .await?;
    output.priority_order.into_iter().map(|s| s.parse()).collect()
}

The JDM table-decision encoding for cmd-cascade-priority:

  • Two top-level branches keyed on input.track.

  • Each branch is a sequence of conditions (e.g., input.pregnant == true && input.applicant_age >= 18 → push pregnant_woman first, etc.).

  • Output array is built incrementally per existing PAMMS priority rules.

For medicaid-tma-phase, the rule logic is a single-predicate decision (input.current_date >= input.coverage_start_date + context.thresholds.phase_1_months → phase_2 with income_test_required = true; else phase_1). For medicaid-denial-reasons, the rule logic is a multi-row decision table — one row per input.coa value paired with the appropriate predicate inputs (~40 rows mirroring the existing denial_reason_fn match arms).

The exact zen-engine DSL forms follow the working precedents in medicaid-eligibility-hierarchy.json.

SignableDetermination.program_extension invariant

The denial-reason refactor must preserve the SignableDetermination.program_extension shape (crates/canopy-signing/src/envelope.rs:66). Medicaid-specific fields (assigned_coa, assigned_coa_track, fmap_rate, continuous_eligibility_end, denial_reason, person_id) flow through program_extension — see services/canopy-medicaid/src/determine.rs:665-692 for the current construction. The ruleset’s denial_reason output replaces only the synthesised string value; the surrounding program_extension map and its canonical_signing_payload serialisation must round-trip byte-identical across the refactor. Add a regression test that captures the pre-refactor signing payload for at least one denied-case golden input and asserts the post-refactor bytes match.

Files Touched

File Change

rulesets/georgia/medicaid-cmd-cascade-priority.json

New ruleset

rulesets/georgia/medicaid-tma-phase.json

New ruleset

rulesets/georgia/medicaid-denial-reasons.json

New ruleset

services/canopy-medicaid/src/cmd_cascade.rs

Replace lines 34-88 with a MedicaidRulesClient invocation (typed CmdCascadeInput / CmdCascadeOutput, 5-arg evaluate with bearer token)

services/canopy-medicaid/src/tma.rs

Replace income_test_required (lines 77-84) with a MedicaidRulesClient invocation (typed TmaPhaseInput / TmaPhaseOutput). build_tma_coverage (40-66) and is_qrf_overdue (69-74) remain unchanged.

services/canopy-medicaid/src/determine.rs

Replace denial_reason_fn closure at lines 478-536 with a MedicaidRulesClient invocation (typed DenialReasonsInput / DenialReasonsOutput); preserve program_extension byte-stability

services/canopy-medicaid/src/rules_client.rs

Extend MedicaidRulesClient with 3 typed wrapper methods + input/output structs (CmdCascadeInput/Output, TmaPhaseInput/Output, DenialReasonsInput/Output)

crates/canopy-rules/tests/medicaid_jdm_completion_test.rs

3 new ruleset-evaluation tests

CHANGELOG.adoc

=== Changed entry citing ADR-003

docs/modules/ROOT/pages/services/canopy-medicaid.adoc

Update ruleset list

Verification

  1. cargo xtask rules check — all 3 new rulesets compile under zen-engine 0.55.

  2. cargo nextest run -p canopy-medicaid — every existing test passes (the refactor is byte-equivalent for the existing test inputs).

  3. cargo nextest run -p canopy-rules --test medicaid_jdm_completion_test — new evaluation tests pass.

  4. cargo xtask dev start && cargo nextest run -p canopy-eligibility --run-ignored only — orchestrator-level Medicaid integration tests still pass.

  5. cargo xtask validate — full battery green.

Documentation Updates

  • CHANGELOG.adoc — entry under == Unreleased / === Changed (ADR-003 compliance)

  • docs/modules/ROOT/pages/services/canopy-medicaid.adoc — list the 3 new rulesets

  • docs/modules/ROOT/pages/architecture.adoc — update the "Rust holdouts" tally if any such count exists

  • Plan archive: move to plans/archive/ post-merge

Edit this page · default