Plan: Medicaid COA Phase B — Q-Track (QMB/SLMB/QI-1) + Family Medically Needy Spenddown
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add jurisdiction.toml keys and citations.toml entries for Q-Track and MNIL thresholds |
Done (2026-04-13) |
2 |
Extend |
Done (2026-04-13) |
3 |
Add ApplicationContext fields for Medicare Part A/B, countable resources, medical expenses |
Done (2026-04-13) |
4 |
Extend NonMagiInput with income/resource/threshold fields |
Done (2026-04-13) |
5 |
Extend NonMagiOutput with FM-MN, Pregnant-MN, and spenddown fields |
Done (2026-04-13) |
6 |
Update medicaid-non-magi.json expressions for QMB, SLMB, QI-1, FM-MN, Pregnant-MN, spenddown |
Done (2026-04-13) |
7 |
Wire eligible_fn and denial_reason_fn match arms in determine.rs |
Done (2026-04-13) |
8 |
Add hierarchy expressions for FM-MN and Pregnant-MN in medicaid-eligibility-hierarchy.json |
Done (2026-04-13) |
9 |
Unit tests (5 cases) |
Done (2026-04-13) |
Branch: feature/medicaid-coa-phase-b
Context
The current medicaid-non-magi.json ruleset has stub expressions for QMB, SLMB, and QI-1 that only gate on applicant_age >= 65 or disability_status != null. The full Q-Track evaluation per Georgia PAMMS 2143/2145/2147 requires Medicare enrollment verification, income tests against FPL-based thresholds, and countable resource tests.
Similarly, Family Medically Needy (FM-MN per PAMMS 2196) and Pregnant Medically Needy (Pregnant-MN) COAs are listed in the MedicaidCategory enum and CMD cascade but have no rules evaluation — they fall through to the _ ⇒ false catch-all in eligible_fn. These COAs require a Medically Needy Income Level (MNIL) test with a spenddown calculation.
This plan replaces the stubs with full expressions, adds the necessary jurisdiction parameters, and wires the input/output structs end-to-end. All five COAs use the "non_magi" track.
Scope
In scope:
-
Full QMB/SLMB/QI-1 income + resource evaluation expressions in medicaid-non-magi.json
-
FM-MN and Pregnant-MN spenddown calculation in the ruleset
-
7 jurisdiction.toml keys with PAMMS citations
-
ApplicationContext, NonMagiInput, NonMagiOutput struct extensions
-
eligible_fn / denial_reason_fn wiring in determine.rs
-
Hierarchy ruleset updates for FM-MN and Pregnant-MN
-
5 unit tests
Out of scope:
-
Actual SSA SOLQ/BINDEX verification of Medicare enrollment (Phase D handles SSA data flow)
-
QMB/SLMB/QI-1 premium assistance wiring to canopy-enrollment (post-UAT)
-
Spenddown tracking over time (multi-month spenddown accumulation is a future feature)
Dependencies
-
services/canopy-medicaid/src/params.rs— MedicaidParameterTable (existing, will be extended) -
rulesets/georgia/jurisdiction.toml—[medicaid]section (existing) -
rulesets/georgia/citations.toml— citation entries (existing)
Design
ApplicationContext additions
Add to services/canopy-medicaid/src/determine.rs, struct ApplicationContext:
/// Medicare Part A enrollment (for QMB eligibility).
#[serde(default)]
pub has_medicare_part_a: Option<bool>,
/// Medicare Part B enrollment (for SLMB eligibility).
#[serde(default)]
pub has_medicare_part_b: Option<bool>,
/// Countable resources for resource-tested COAs (Q-Track, ABD).
#[serde(default)]
pub countable_resources: Option<Decimal>,
/// Monthly medical expenses for spenddown COAs (FM-MN, Pregnant-MN, AMN).
#[serde(default)]
pub medical_expenses_monthly: Option<Decimal>,
jurisdiction.toml keys
Add under [medicaid] in rulesets/georgia/jurisdiction.toml:
# Q-Track thresholds (PAMMS 2143/2145/2147)
qmb_income_limit_pct_fpl = 100
slmb_income_limit_pct_fpl = 120
qi1_income_limit_pct_fpl = 135
qmb_resource_limit_individual = 9430
qmb_resource_limit_couple = 14130
# Family Medically Needy Income Level (PAMMS 2196)
mnil_income_limit_individual = 317
mnil_income_limit_couple = 367
citations.toml entries
7 entries tracing to PAMMS sections:
[medicaid.qmb_income_limit_pct_fpl]
value = 100
source = "PAMMS 2143"
description = "QMB income limit as percentage of FPL"
[medicaid.slmb_income_limit_pct_fpl]
value = 120
source = "PAMMS 2145"
description = "SLMB income limit as percentage of FPL"
[medicaid.qi1_income_limit_pct_fpl]
value = 135
source = "PAMMS 2147"
description = "QI-1 income limit as percentage of FPL"
[medicaid.qmb_resource_limit_individual]
value = 9430
source = "PAMMS 2143"
description = "QMB/SLMB/QI-1 resource limit for an individual (2025)"
[medicaid.qmb_resource_limit_couple]
value = 14130
source = "PAMMS 2143"
description = "QMB/SLMB/QI-1 resource limit for a couple (2025)"
[medicaid.mnil_income_limit_individual]
value = 317
source = "PAMMS 2196"
description = "Family Medically Needy Income Level — individual"
[medicaid.mnil_income_limit_couple]
value = 367
source = "PAMMS 2196"
description = "Family Medically Needy Income Level — couple"
MedicaidParameterTable additions
Add to services/canopy-medicaid/src/params.rs, struct MedicaidParameterTable:
// Q-Track (PAMMS 2143/2145/2147)
qmb_income_limit_pct_fpl: Decimal,
slmb_income_limit_pct_fpl: Decimal,
qi1_income_limit_pct_fpl: Decimal,
qmb_resource_limit_individual: Decimal,
qmb_resource_limit_couple: Decimal,
// Family Medically Needy (PAMMS 2196)
mnil_income_limit_individual: Decimal,
mnil_income_limit_couple: Decimal,
Accessors (follow existing pattern):
pub fn qmb_income_limit_pct_fpl(&self) -> Decimal { self.qmb_income_limit_pct_fpl }
pub fn slmb_income_limit_pct_fpl(&self) -> Decimal { self.slmb_income_limit_pct_fpl }
pub fn qi1_income_limit_pct_fpl(&self) -> Decimal { self.qi1_income_limit_pct_fpl }
pub fn qmb_resource_limit_individual(&self) -> Decimal { self.qmb_resource_limit_individual }
pub fn qmb_resource_limit_couple(&self) -> Decimal { self.qmb_resource_limit_couple }
pub fn mnil_income_limit_individual(&self) -> Decimal { self.mnil_income_limit_individual }
pub fn mnil_income_limit_couple(&self) -> Decimal { self.mnil_income_limit_couple }
NonMagiInput additions
Add to services/canopy-medicaid/src/rules_client.rs, struct NonMagiInput:
pub has_medicare_part_a: bool,
pub has_medicare_part_b: bool,
#[serde(serialize_with = "serialize_as_number")]
pub countable_resources: Decimal,
#[serde(serialize_with = "serialize_as_number")]
pub medical_expenses_monthly: Decimal,
pub household_size: i32,
#[serde(serialize_with = "serialize_as_number")]
pub net_countable_income: Decimal,
// Absolute thresholds injected from MedicaidParameterTable
#[serde(serialize_with = "serialize_as_number")]
pub qmb_income_threshold: Decimal,
#[serde(serialize_with = "serialize_as_number")]
pub slmb_income_threshold: Decimal,
#[serde(serialize_with = "serialize_as_number")]
pub qi1_income_threshold: Decimal,
#[serde(serialize_with = "serialize_as_number")]
pub qmb_resource_limit: Decimal,
#[serde(serialize_with = "serialize_as_number")]
pub mnil: Decimal,
NonMagiOutput additions
Add to services/canopy-medicaid/src/rules_client.rs, struct NonMagiOutput:
pub fm_medically_needy_eligible: bool,
pub pregnant_medically_needy_eligible: bool,
pub spend_down_amount: Option<f64>,
medicaid-non-magi.json expression updates
Replace the existing stub expressions with full evaluation logic:
QMB (expression id ex-qmb):
(applicant_age >= 65 or disability_status != null) and has_medicare_part_a and net_countable_income <= qmb_income_threshold and countable_resources <= qmb_resource_limit
SLMB (expression id ex-slmb):
(applicant_age >= 65 or disability_status != null) and (has_medicare_part_a or has_medicare_part_b) and net_countable_income > qmb_income_threshold and net_countable_income <= slmb_income_threshold and countable_resources <= qmb_resource_limit
QI-1 (expression id ex-qi1):
(applicant_age >= 65 or disability_status != null) and (has_medicare_part_a or has_medicare_part_b) and net_countable_income > slmb_income_threshold and net_countable_income <= qi1_income_threshold and countable_resources <= qmb_resource_limit
FM-MN (new expression id ex-fm-mn):
net_countable_income > mnil and (net_countable_income - mnil) <= medical_expenses_monthly
Pregnant-MN (new expression id ex-pregnant-mn):
net_countable_income > mnil and (net_countable_income - mnil) <= medical_expenses_monthly
eligible_fn checks is_pregnant) — the ruleset evaluates the financial formula only.
Spenddown (new expression id ex-spenddown):
net_countable_income > mnil ? net_countable_income - mnil : null
eligible_fn additions
Add 5 match arms in determine.rs eligible_fn:
MedicaidCategory::Qmb => non_magi_out.qmb_eligible, // replaces catch-all
MedicaidCategory::Slmb => non_magi_out.slmb_eligible, // replaces catch-all
MedicaidCategory::Qi1 => non_magi_out.qi1_eligible, // replaces catch-all
MedicaidCategory::FmMedicallyNeedy => non_magi_out.fm_medically_needy_eligible,
MedicaidCategory::PregnantMedicallyNeedy => {
is_pregnant && non_magi_out.pregnant_medically_needy_eligible
},
_ ⇒ false catch-all.
denial_reason_fn additions
Replace existing Q-Track catch-all and add FM-MN/Pregnant-MN:
MedicaidCategory::Qmb => "qmb_income_or_resources_over_limit_or_no_medicare_part_a",
MedicaidCategory::Slmb => "slmb_income_or_resources_over_limit_or_no_medicare",
MedicaidCategory::Qi1 => "qi1_income_or_resources_over_limit_or_no_medicare",
MedicaidCategory::FmMedicallyNeedy => "fm_mn_spenddown_exceeds_medical_expenses",
MedicaidCategory::PregnantMedicallyNeedy if !is_pregnant => "not_pregnant",
MedicaidCategory::PregnantMedicallyNeedy => "pregnant_mn_spenddown_exceeds_medical_expenses",
Hierarchy ruleset additions
Add to rulesets/georgia/medicaid-eligibility-hierarchy.json expression nodes:
{"id": "ex-has-fm-mn", "key": "has_fm_mn", "value": "some(eligible_coas, # == \"fm_medically_needy\")"},
{"id": "ex-has-pregnant-mn", "key": "has_pregnant_mn", "value": "some(eligible_coas, # == \"pregnant_medically_needy\")"}
Update assigned_coa and rationale decision table rows to include FM-MN and Pregnant-MN in their appropriate hierarchy position (after Refugee, before QMB in the family non-MAGI block).
Steps
Step 1: jurisdiction.toml + citations.toml
Files: rulesets/georgia/jurisdiction.toml, rulesets/georgia/citations.toml
Add the 7 keys under [medicaid] in jurisdiction.toml as shown in the Design section. Add the 7 citation entries in citations.toml following the existing pattern (e.g., the SNAP citations format). Run cargo xtask policy audit to verify completeness.
Step 2: MedicaidParameterTable
Files: services/canopy-medicaid/src/params.rs
Add the 7 struct fields. In MedicaidParameterTable::load(), read from the [medicaid] table using the get_decimal() / get_i64() helper (follow the existing pattern for magi_pregnant_pct_fpl). Add the 7 accessor methods. For FPL-based thresholds (qmb/slmb/qi1), store as integer percentage; the caller computes fpl_100_monthly * pct / 100 at determine time (matching the existing MAGI pattern). For fixed-dollar amounts (resource limits, MNIL), store as Decimal directly.
Step 3: ApplicationContext fields
Files: services/canopy-medicaid/src/determine.rs
Add 4 fields to ApplicationContext: has_medicare_part_a, has_medicare_part_b, countable_resources, medical_expenses_monthly — all Option<T> with #[serde(default)]. Use Option<bool> for the Medicare flags and Option<Decimal> for the amounts. Unwrap with .unwrap_or(false) / .unwrap_or(Decimal::ZERO) in the determine function body, following the existing pattern for is_institutionalized.
Step 4: NonMagiInput extension
Files: services/canopy-medicaid/src/rules_client.rs
Add 11 fields to NonMagiInput as shown in the Design section. All Decimal fields use #[serde(serialize_with = "serialize_as_number")]. Wire the new fields in determine.rs where NonMagiInput is constructed (around line 220), computing thresholds as:
let pct100 = Decimal::from(100);
let qmb_income_threshold = fpl_100 * params.qmb_income_limit_pct_fpl() / pct100;
let slmb_income_threshold = fpl_100 * params.slmb_income_limit_pct_fpl() / pct100;
let qi1_income_threshold = fpl_100 * params.qi1_income_limit_pct_fpl() / pct100;
let qmb_resource_limit = if ctx.household_size <= 1 {
params.qmb_resource_limit_individual()
} else {
params.qmb_resource_limit_couple()
};
let mnil = if ctx.household_size <= 1 {
params.mnil_income_limit_individual()
} else {
params.mnil_income_limit_couple()
};
Step 5: NonMagiOutput extension
Files: services/canopy-medicaid/src/rules_client.rs
Add 3 fields to NonMagiOutput: fm_medically_needy_eligible: bool, pregnant_medically_needy_eligible: bool, spend_down_amount: Option<f64>. The spend_down_amount uses Option<f64> because zen-engine returns null for non-applicable cases.
Step 6: medicaid-non-magi.json expressions
Files: rulesets/georgia/medicaid-non-magi.json
Replace the 3 existing stub expressions (ex-qmb, ex-slmb, ex-qi1) with the full expressions from the Design section. Add 3 new expression nodes (ex-fm-mn, ex-pregnant-mn, ex-spenddown) with their corresponding output keys. Add the new output keys (fm_medically_needy_eligible, pregnant_medically_needy_eligible, spend_down_amount) to the output mapping node.
Step 7: eligible_fn + denial_reason_fn
Files: services/canopy-medicaid/src/determine.rs
Update eligible_fn closure: the QMB/SLMB/QI-1 arms already exist (lines 248-250) and will now return real evaluations from the updated ruleset. Add 2 new arms for FmMedicallyNeedy and PregnantMedicallyNeedy. The PregnantMedicallyNeedy arm gates on is_pregnant in Rust.
Update denial_reason_fn closure: replace the MedicaidCategory::Qmb | MedicaidCategory::Slmb | MedicaidCategory::Qi1 catch-all (line 281-283) with separate arms per COA. Add arms for FmMedicallyNeedy and PregnantMedicallyNeedy.
Step 8: Hierarchy ruleset
Files: rulesets/georgia/medicaid-eligibility-hierarchy.json
Add has_fm_mn and has_pregnant_mn expression nodes. Update the assigned_coa decision table to include these COAs in priority order (after refugee, before qmb). Update rationale table correspondingly.
Step 9: Unit tests
Files: services/canopy-medicaid/src/determine.rs (or a tests/ module)
Add 5 test cases:
-
QMB eligible: age 67, disability, has_medicare_part_a=true, income below 100% FPL, resources below individual limit →
qmb_eligible = true -
QMB denied — no Medicare: age 67, disability, has_medicare_part_a=false →
qmb_eligible = false -
SLMB boundary: income exactly at 120% FPL →
slmb_eligible = true; income at 121% →slmb_eligible = false -
FM-MN spenddown eligible: income $500/month, MNIL $317, medical expenses $250/month → spenddown $183 ≤ $250 → eligible
-
FM-MN spenddown denied: income $800/month, MNIL $317, medical expenses $100/month → spenddown $483 > $100 → denied
Files Touched
| File | Change |
|---|---|
|
Add 7 keys under |
|
Add 7 citation entries (PAMMS 2143/2145/2147/2196) |
|
Add 7 fields + accessors to MedicaidParameterTable, load from jurisdiction.toml |
|
Add 4 ApplicationContext fields, wire NonMagiInput construction, update eligible_fn (5 arms), update denial_reason_fn (5 arms) |
|
Extend NonMagiInput (11 fields), NonMagiOutput (3 fields) |
|
Replace 3 stub expressions, add 3 new expressions (FM-MN, Pregnant-MN, spenddown) |
|
Add has_fm_mn, has_pregnant_mn expression nodes + decision table rows |
Verification
-
cargo xtask policy audit— all 7 new jurisdiction.toml keys have citations -
cargo nextest run --workspace --lib— unit tests pass (including 5 new tests) -
cargo xtask dev reload— service starts with new params loaded -
cargo nextest run --workspace— integration tests pass -
cargo xtask e2e— E2E tests pass -
Verify QMB stub expressions in medicaid-non-magi.json are fully replaced (no "pending" descriptions remain for Q-Track)
Documentation Updates
-
.claude/docs/services.md— update canopy-medicaid route/feature table with Q-Track and FM-MN -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update Phase 3 status for medicaid COA expansion