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 MedicaidParameterTable with 7 new fields and accessors

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
NOTE
The pregnancy gate is applied in Rust (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
},
NOTE
QMB/SLMB/QI-1 already have match arms (lines 248-250) returning the boolean fields. This step upgrades those from stub booleans (always false from stub expressions) to real evaluations. FM-MN and Pregnant-MN are net-new arms replacing the _ ⇒ 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:

  1. QMB eligible: age 67, disability, has_medicare_part_a=true, income below 100% FPL, resources below individual limit → qmb_eligible = true

  2. QMB denied — no Medicare: age 67, disability, has_medicare_part_a=false → qmb_eligible = false

  3. SLMB boundary: income exactly at 120% FPL → slmb_eligible = true; income at 121% → slmb_eligible = false

  4. FM-MN spenddown eligible: income $500/month, MNIL $317, medical expenses $250/month → spenddown $183 ≤ $250 → eligible

  5. FM-MN spenddown denied: income $800/month, MNIL $317, medical expenses $100/month → spenddown $483 > $100 → denied

Files Touched

File Change

rulesets/georgia/jurisdiction.toml

Add 7 keys under [medicaid] for Q-Track thresholds and MNIL

rulesets/georgia/citations.toml

Add 7 citation entries (PAMMS 2143/2145/2147/2196)

services/canopy-medicaid/src/params.rs

Add 7 fields + accessors to MedicaidParameterTable, load from jurisdiction.toml

services/canopy-medicaid/src/determine.rs

Add 4 ApplicationContext fields, wire NonMagiInput construction, update eligible_fn (5 arms), update denial_reason_fn (5 arms)

services/canopy-medicaid/src/rules_client.rs

Extend NonMagiInput (11 fields), NonMagiOutput (3 fields)

rulesets/georgia/medicaid-non-magi.json

Replace 3 stub expressions, add 3 new expressions (FM-MN, Pregnant-MN, spenddown)

rulesets/georgia/medicaid-eligibility-hierarchy.json

Add has_fm_mn, has_pregnant_mn expression nodes + decision table rows

Verification

  1. cargo xtask policy audit — all 7 new jurisdiction.toml keys have citations

  2. cargo nextest run --workspace --lib — unit tests pass (including 5 new tests)

  3. cargo xtask dev reload — service starts with new params loaded

  4. cargo nextest run --workspace — integration tests pass

  5. cargo xtask e2e — E2E tests pass

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

Edit this page · default