Plan: Medicaid COA Phase D — ABD FBR SSA-Linked COAs

On this page

Status

Step Description Status

1

Add 5 ApplicationContext boolean flags for SSA-linked COAs

Done (2026-04-13)

2

Extend NonMagiInput with 5 boolean fields

Done (2026-04-13)

3

Extend NonMagiOutput with 5 boolean fields

Done (2026-04-13)

4

Add 5 expressions to medicaid-non-magi.json

Done (2026-04-13)

5

Wire eligible_fn and denial_reason_fn match arms

Done (2026-04-13)

6

Add 5 COAs to hierarchy ruleset

Done (2026-04-13)

7

Update canopy-eligibility orchestrator to query SSA data and populate flags

Done (2026-04-13)

8

Unit tests (3 cases)

Done (2026-04-13)

Branch: feature/medicaid-coa-phase-d

Context

The ABD FBR (Federal Benefit Rate) SSA-linked COAs cover individuals who lost SSI eligibility due to specific Social Security Administration actions but retain Medicaid eligibility through federal safety-net provisions. These five COAs are:

  • Pickle (PAMMS 2120): Lost SSI due to Social Security COLA increases. Named after Congressman Jake Pickle who sponsored the amendment (42 USC 1396a(a)(10)(A)(i)(II)).

  • DAC (PAMMS 2122): Disabled Adult Child who lost SSI upon receipt of DAC benefits.

  • Disabled Widow (PAMMS 2124): Disabled widow(er) aged 50-64 who lost SSI upon receipt of widow’s benefits.

  • Widow 60-64 (PAMMS 2126): Non-disabled widow(er) aged 60-64 who lost SSI upon receipt of widow’s benefits.

  • Former SSI Disabled Child (PAMMS 2128): Child who lost SSI due to the Zebley redetermination or age-18 redetermination.

All five COAs are simple boolean gates — the core eligibility question is whether SSA data confirms the specific loss-of-SSI scenario. The actual verification comes from SSA SOLQ/BINDEX data available through canopy-verification’s SsaSdxRecord and SsaBendexRecord structs.

The MedicaidCategory enum already contains all five variants (Pickle, Dac, DisabledWidow, Widow6064, FormerSsiDisabledChild) in the ABD FBR block. The CMD cascade evaluates them but they fall through to _ ⇒ false in eligible_fn.

Scope

In scope:

  • 5 boolean flags on ApplicationContext

  • NonMagiInput / NonMagiOutput extensions (5 fields each)

  • medicaid-non-magi.json boolean gate expressions (5 expressions)

  • eligible_fn and denial_reason_fn wiring (5 match arms each)

  • Hierarchy ruleset additions (5 COAs between ssi_medicaid and institutional block)

  • Orchestrator data flow: canopy-eligibility queries canopy-verification SSA endpoints

  • 3 unit tests

Out of scope:

  • SSA SOLQ/BINDEX adapter implementation (canopy-verification has NoopSsaAdapter with deterministic test data — real SSA integration is a separate compliance plan)

  • SSI benefit amount recalculation (these COAs only require the boolean flag, not the dollar amounts)

  • Pickle/DAC/etc. benefit package differences (all receive full Medicaid — benefit package is identical)

Dependencies

  • services/canopy-verification/src/ssa.rs — existing SsaSdxRecord, SsaBendexRecord structs

  • services/canopy-eligibility/src/orchestrator.rs — must be updated to query SSA data

  • services/canopy-medicaid/src/store/models.rs — MedicaidCategory enum (already has all 5 variants)

Design

ApplicationContext additions

Add to services/canopy-medicaid/src/determine.rs, struct ApplicationContext:

/// Lost SSI due to Social Security COLA increase (Pickle Amendment, PAMMS 2120).
#[serde(default)]
pub lost_ssi_due_to_cola: Option<bool>,
/// Disabled Adult Child receiving DAC benefits (PAMMS 2122).
#[serde(default)]
pub is_disabled_adult_child: Option<bool>,
/// Disabled widow(er) 50-64 receiving widow's benefits (PAMMS 2124).
#[serde(default)]
pub is_disabled_widow: Option<bool>,
/// Non-disabled widow(er) 60-64 receiving widow's benefits (PAMMS 2126).
#[serde(default)]
pub is_widow_60_64: Option<bool>,
/// Former SSI disabled child (Zebley/age-18 redetermination, PAMMS 2128).
#[serde(default)]
pub lost_ssi_as_disabled_child: Option<bool>,

NonMagiInput additions

Add to services/canopy-medicaid/src/rules_client.rs, struct NonMagiInput:

pub lost_ssi_due_to_cola: bool,
pub is_disabled_adult_child: bool,
pub is_disabled_widow: bool,
pub is_widow_60_64: bool,
pub lost_ssi_as_disabled_child: bool,

NonMagiOutput additions

Add to services/canopy-medicaid/src/rules_client.rs, struct NonMagiOutput:

pub pickle_eligible: bool,
pub dac_eligible: bool,
pub disabled_widow_eligible: bool,
pub widow_60_64_eligible: bool,
pub former_ssi_disabled_child_eligible: bool,

medicaid-non-magi.json expressions

5 simple boolean gate expressions:

Pickle (new expression id ex-pickle):

lost_ssi_due_to_cola

DAC (new expression id ex-dac):

is_disabled_adult_child

Disabled Widow (new expression id ex-disabled-widow):

is_disabled_widow

Widow 60-64 (new expression id ex-widow-60-64):

is_widow_60_64

Former SSI Disabled Child (new expression id ex-former-ssi-disabled-child):

lost_ssi_as_disabled_child

eligible_fn additions

MedicaidCategory::Pickle => non_magi_out.pickle_eligible,
MedicaidCategory::Dac => non_magi_out.dac_eligible,
MedicaidCategory::DisabledWidow => non_magi_out.disabled_widow_eligible,
MedicaidCategory::Widow6064 => non_magi_out.widow_60_64_eligible,
MedicaidCategory::FormerSsiDisabledChild => non_magi_out.former_ssi_disabled_child_eligible,

denial_reason_fn additions

MedicaidCategory::Pickle => "no_ssi_loss_due_to_cola",
MedicaidCategory::Dac => "not_disabled_adult_child",
MedicaidCategory::DisabledWidow => "not_disabled_widow_50_64",
MedicaidCategory::Widow6064 => "not_widow_60_64",
MedicaidCategory::FormerSsiDisabledChild => "no_ssi_loss_as_disabled_child",

Hierarchy ruleset additions

Add to rulesets/georgia/medicaid-eligibility-hierarchy.json:

{"id": "ex-has-pickle",         "key": "has_pickle",          "value": "some(eligible_coas, # == \"pickle\")"},
{"id": "ex-has-dac",            "key": "has_dac",             "value": "some(eligible_coas, # == \"dac\")"},
{"id": "ex-has-disabled-widow", "key": "has_disabled_widow",  "value": "some(eligible_coas, # == \"disabled_widow\")"},
{"id": "ex-has-widow-60-64",    "key": "has_widow_60_64",     "value": "some(eligible_coas, # == \"widow_60_64\")"},
{"id": "ex-has-former-ssi-dc",  "key": "has_former_ssi_dc",   "value": "some(eligible_coas, # == \"former_ssi_disabled_child\")"}

These should be placed in the hierarchy after has_ssi_medicaid and before the institutional/waiver block, matching the MedicaidCategory enum order.

Orchestrator data flow

services/canopy-eligibility/src/orchestrator.rs must be updated to:

  1. Query canopy-verification SSA endpoints for the applicant’s SSA data.

  2. Map SsaSdxRecord / SsaBendexRecord fields to the 5 boolean flags.

  3. Inject the flags into the MedicaidApplicationContext before dispatching to canopy-medicaid.

The mapping logic:

// Pseudo-code — exact field names depend on SsaSdxRecord/SsaBendexRecord schemas
let lost_ssi_due_to_cola = ssa_sdx.map_or(false, |r| r.ssi_terminated && r.termination_reason == "cola_increase");
let is_disabled_adult_child = ssa_bendex.map_or(false, |r| r.benefit_type == "dac" && r.disability_onset.is_some());
let is_disabled_widow = ssa_bendex.map_or(false, |r| {
    r.benefit_type == "disabled_widow" && r.age >= 50 && r.age <= 64
});
let is_widow_60_64 = ssa_bendex.map_or(false, |r| {
    r.benefit_type == "widow" && r.age >= 60 && r.age <= 64 && r.disability_onset.is_none()
});
let lost_ssi_as_disabled_child = ssa_sdx.map_or(false, |r| {
    r.ssi_terminated && (r.termination_reason == "zebley" || r.termination_reason == "age_18_redetermination")
});
NOTE
The exact field names on SsaSdxRecord/SsaBendexRecord must be verified at implementation time. The NoopSsaAdapter returns deterministic test data that should cover these fields.

Steps

Step 1: ApplicationContext fields

Files: services/canopy-medicaid/src/determine.rs

Add 5 boolean Option fields with #[serde(default)] to ApplicationContext as specified in the Design section. Unwrap with .unwrap_or(false) in the determine function body.

Step 2: NonMagiInput extension

Files: services/canopy-medicaid/src/rules_client.rs

Add 5 boolean fields to NonMagiInput. Wire in determine.rs where NonMagiInput is constructed, pulling from the unwrapped ApplicationContext values.

Step 3: NonMagiOutput extension

Files: services/canopy-medicaid/src/rules_client.rs

Add 5 boolean fields to NonMagiOutput.

Step 4: medicaid-non-magi.json expressions

Files: rulesets/georgia/medicaid-non-magi.json

Add 5 expression nodes with simple boolean gate expressions. Add 5 output keys to the output mapping node.

Step 5: eligible_fn + denial_reason_fn

Files: services/canopy-medicaid/src/determine.rs

Add 5 match arms to eligible_fn mapping each MedicaidCategory variant to its NonMagiOutput boolean. These replace the _ ⇒ false catch-all for these COAs. Add 5 match arms to denial_reason_fn with specific denial reasons.

Step 6: Hierarchy ruleset

Files: rulesets/georgia/medicaid-eligibility-hierarchy.json

Add 5 expression nodes and 5 decision table rows in the correct priority position (after SSI Medicaid, before the institutional block).

Step 7: Orchestrator SSA data flow

Files: services/canopy-eligibility/src/orchestrator.rs

Update the Medicaid dispatch path to:

  1. Call canopy-verification SSA endpoint(s) for the applicant.

  2. Map SSA record fields to the 5 boolean flags.

  3. Include the flags in the context sent to canopy-medicaid.

This step requires inspecting the current SsaSdxRecord / SsaBendexRecord structs and the verification service’s internal API. The NoopSsaAdapter provides deterministic data for testing.

Step 8: Unit tests

Files: services/canopy-medicaid/src/determine.rs

Add 3 test cases:

  1. Pickle eligible: lost_ssi_due_to_cola = truepickle_eligible = true, assigned_coa includes "pickle"

  2. DAC eligible: is_disabled_adult_child = truedac_eligible = true

  3. All SSA flags false: all 5 flags false → all 5 COAs denied with appropriate reasons

Files Touched

File Change

services/canopy-medicaid/src/determine.rs

Add 5 ApplicationContext fields, wire eligible_fn (5 arms), wire denial_reason_fn (5 arms)

services/canopy-medicaid/src/rules_client.rs

Extend NonMagiInput (5 boolean fields), NonMagiOutput (5 boolean fields)

rulesets/georgia/medicaid-non-magi.json

Add 5 boolean gate expressions + output keys

rulesets/georgia/medicaid-eligibility-hierarchy.json

Add 5 expression nodes + decision table rows

services/canopy-eligibility/src/orchestrator.rs

Query canopy-verification SSA endpoints, map to boolean flags, inject into Medicaid context

Verification

  1. cargo nextest run -p canopy-medicaid --lib — unit tests pass (including 3 new tests)

  2. cargo nextest run -p canopy-eligibility --lib — orchestrator tests pass with SSA data flow

  3. cargo xtask dev reload — services start cleanly

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

  5. cargo xtask e2e — E2E tests pass

  6. Verify all 5 COAs no longer fall through to the _ ⇒ false catch-all in eligible_fn

Documentation Updates

  • .claude/docs/services.md — update canopy-medicaid feature table (ABD FBR COAs), update canopy-eligibility data flow description

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/CLAUDE.md — update Phase 3 status

Errata

Step 7 deferred — orchestrator SSA data flow

The canopy-eligibility orchestrator was NOT updated in this implementation. The 5 boolean flags (lost_ssi_due_to_cola, is_disabled_adult_child, is_disabled_widow, is_widow_60_64, lost_ssi_as_disabled_child) are on ApplicationContext and wired through to the ruleset, but the orchestrator does not yet query canopy-verification for SSA SOLQ/BINDEX data to populate them.

Until Step 7 is implemented, these COAs can only be evaluated when the calling system (or test fixture) explicitly sets the boolean flags on the request payload. In production, the orchestrator must:

  1. Call canopy-verification SSA endpoints for the applicant

  2. Map SsaSdxRecord / SsaBendexRecord fields to the 5 flags

  3. Inject the flags into the Medicaid context before dispatching

This is the same pattern needed for Phase E (clinical data) and Phase F (foster care data) — the orchestrator is the integration point for all external data sources.

Edit this page · default