Plan: Medicaid JDM Completion (Issue #386)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Author |
Done (2026-05-11) — deviation: inputs simplified to just |
2 |
Refactor |
Done (2026-05-11) — |
3 |
Author |
Done (2026-05-11) — deviation: ruleset takes |
4 |
Refactor |
Done (2026-05-11) — |
5 |
Author |
Done (2026-05-11) — single decisionTable, 47 rules covering every PAMMS-cited COA + predicate combination from the original closure plus a catch-all |
6 |
Refactor the |
Done (2026-05-11) — pre-resolve via new |
7 |
Tests + docs. 3 ruleset-evaluation tests under |
Done (2026-05-11) — 3 new fixtures under |
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 hardcodedVec<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_coverageat lines 40-66,is_qrf_overdueat 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-536—denial_reason_fnclosure (~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) andis_qrf_overdue(lines 69-74) stay in Rust. -
services/canopy-medicaid/src/determine.rs:478-536—denial_reason_fnclosure (~40 COA match arms). -
services/canopy-medicaid/src/rules_client.rs:275— existingMedicaidRulesClienttyped wrapper; extend with typed input/output structs for the 3 new rulesets. -
rulesets/georgia/medicaid-eligibility-hierarchy.json— EE15 ruleset (precedent forinput./context.thresholds.namespaced shape + naming). -
services/canopy-snap/src/determine.rs:355-363— canonical 5-argRulesClient::evaluateinvocation with bearer-token forwarding (post-#424). -
crates/canopy-rules-client/src/lib.rs:67—NamespacedEvalenvelope defining the namespaced shape. -
crates/canopy-signing/src/envelope.rs:66—SignableDetermination, the byte-stable signing envelope; medicaid-specific fields flow throughprogram_extension.
Scope
In scope:
-
3 new JDM rulesets using the
input./context.thresholds.namespaced shape. -
3 Rust call-site refactors to thin wrappers over
MedicaidRulesClientwith typed input/output structs (not rawserde_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_coverageoris_qrf_overdue(lines 40-66, 69-74) — these arechrono::Monthsdate arithmetic and day-21 calendar checks that JDM does not express cleanly; only theincome_test_requiredphase-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→ pushpregnant_womanfirst, 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 |
|---|---|
|
New ruleset |
|
New ruleset |
|
New ruleset |
|
Replace lines 34-88 with a |
|
Replace |
|
Replace |
|
Extend |
|
3 new ruleset-evaluation tests |
|
|
|
Update ruleset list |
Verification
-
cargo xtask rules check— all 3 new rulesets compile under zen-engine 0.55. -
cargo nextest run -p canopy-medicaid— every existing test passes (the refactor is byte-equivalent for the existing test inputs). -
cargo nextest run -p canopy-rules --test medicaid_jdm_completion_test— new evaluation tests pass. -
cargo xtask dev start && cargo nextest run -p canopy-eligibility --run-ignored only— orchestrator-level Medicaid integration tests still pass. -
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