Plan: Medicaid COA Phase C — TMA (Transitional Medical Assistance)

On this page

Status

Step Description Status

1

Publish tanf.case_closed event from canopy-tanf when a TANF case terminates

Done (2026-04-19)

2

Database migration: tanf_tma_coverage table in canopy-medicaid

Done (2026-04-19)

3

Store layer: create_tma_coverage, find_active_tma_coverage, update_tma_coverage_status

Done (2026-04-19)

4

Wire subscriber stub in main.rs to create TMA coverage records on tanf.case_closed

Done (2026-04-19)

5

Add ApplicationContext fields for TANF history

Done (2026-04-19)

6

Extend MagiInput and MagiOutput for TMA

Done (2026-04-19)

7

Update medicaid-magi.json with TMA expression

Done (2026-04-19)

8

Wire eligible_fn with Phase 1/Phase 2 income gating and add unit tests

Done (2026-04-19)

Branch: feature/medicaid-coa-phase-c

Context

Transitional Medical Assistance (TMA) provides 12 months of continued Medicaid coverage when a family loses TANF cash assistance due to increased earnings. Georgia implements TMA per 42 USC 1396r-6 and PAMMS 2166.

TMA has two phases:

  • Phase 1 (months 1-6): No income test. All former TANF recipients who had Medicaid coverage in ≥3 of the 6 months preceding TANF termination are eligible.

  • Phase 2 (months 7-12): Income must remain at or below 205% FPL. Quarterly Reporting Forms (QRFs) are due at months 7 and 10.

The existing codebase has significant TMA infrastructure already built:

  • services/canopy-medicaid/src/tma.rs contains is_tma_eligible(), build_tma_coverage(), and qrf_schedule() functions with 8 passing tests.

  • services/canopy-medicaid/src/main.rs lines 102-122 have a subscriber stub bound to tanf.case_closed that logs but does not create records.

  • MedicaidCategory::Tma exists in the enum and CMD cascade hierarchy.

  • The eligible_fn in determine.rs currently falls through to _ ⇒ false for MedicaidCategory::Tma.

The missing pieces are: (a) canopy-tanf does not yet publish tanf.case_closed events, (b) no database table stores TMA coverage periods, (c) the MAGI ruleset has no TMA expression, and (d) eligible_fn does not wire TMA output.

Scope

In scope:

  • tanf.case_closed event publication from canopy-tanf

  • tanf_tma_coverage migration in canopy-medicaid

  • Store layer (3 functions)

  • Subscriber wiring in main.rs

  • MagiInput/MagiOutput TMA fields

  • medicaid-magi.json TMA expression

  • eligible_fn Phase 1/Phase 2 income gating

  • 3 unit tests

Out of scope:

  • QRF form generation (canopy-notices handles form rendering — separate plan)

  • Automatic TMA closure at month 12 (scheduler — future feature)

  • TMA extension for families with earnings above 205% who report a decrease (rare edge case)

Dependencies

  • services/canopy-tanf/src/determine.rs — must add event publication

  • services/canopy-medicaid/src/tma.rs — existing module with is_tma_eligible, build_tma_coverage, qrf_schedule (8 tests)

  • services/canopy-medicaid/src/main.rs — existing subscriber stub (lines 102-122)

Design

tanf.case_closed event

canopy-tanf currently publishes only tanf.determined. Add event publication in the TANF determination handler when the determination result is termination or denial of an active case:

// In canopy-tanf determine.rs, after persisting a termination determination:
publisher.publish(
    "tanf.case_closed",
    &serde_json::json!({
        "household_id": ctx.household_id,
        "person_ids": ctx.members.iter().map(|m| m.person_id).collect::<Vec<_>>(),
        "reason": "earnings_increase",  // or "time_limit", "sanction", etc.
        "termination_date": Utc::now().format("%Y-%m-%d").to_string(),
        "had_medicaid_coverage": true,
    }),
).await?;

tanf_tma_coverage table

CREATE TABLE tanf_tma_coverage (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    household_id    UUID NOT NULL,
    person_id       UUID NOT NULL,
    tanf_termination_date DATE NOT NULL,
    tma_start_date  DATE NOT NULL,
    tma_end_date    DATE NOT NULL,     -- start + 12 months
    phase           TEXT NOT NULL DEFAULT 'phase_1',  -- 'phase_1' or 'phase_2'
    qrf_due_dates   JSONB NOT NULL DEFAULT '[]',
    status          TEXT NOT NULL DEFAULT 'active',   -- 'active', 'closed', 'expired'
    closure_reason  TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_tma_coverage_household ON tanf_tma_coverage (household_id);
CREATE INDEX idx_tma_coverage_person    ON tanf_tma_coverage (person_id);
CREATE INDEX idx_tma_coverage_status    ON tanf_tma_coverage (status) WHERE status = 'active';

Store functions

pub async fn create_tma_coverage(
    db: &PgPool,
    household_id: Uuid,
    person_id: Uuid,
    tanf_termination_date: NaiveDate,
) -> Result<TmaCoverage, sqlx::Error>;

pub async fn find_active_tma_coverage(
    db: &PgPool,
    person_id: Uuid,
) -> Result<Option<TmaCoverage>, sqlx::Error>;

pub async fn update_tma_coverage_status(
    db: &PgPool,
    id: Uuid,
    status: &str,
    closure_reason: Option<&str>,
) -> Result<(), sqlx::Error>;

create_tma_coverage calls tma::build_tma_coverage() to compute tma_start_date, tma_end_date, and qrf_due_dates, then inserts.

ApplicationContext fields

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

/// Whether the applicant had TANF in ≥3 of the prior 6 months (for TMA).
#[serde(default)]
pub had_tanf_in_prior_months: Option<bool>,
/// Date TANF terminated (ISO 8601 date string).
#[serde(default)]
pub tanf_termination_date: Option<String>,

MagiInput additions

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

pub had_tanf_in_prior_months: bool,
pub tanf_termination_date: Option<String>,

MagiOutput additions

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

pub tma_eligible: bool,

medicaid-magi.json expression

Add expression node:

{
  "id": "ex-tma",
  "key": "tma_eligible",
  "value": "had_tanf_in_prior_months and tanf_termination_date != null"
}

The ruleset returns a boolean indicating the applicant meets the basic TMA criteria. The Phase 1 vs Phase 2 income test is applied in Rust because it depends on the current date relative to TMA start date, which the rules engine cannot compute.

eligible_fn — Phase 1/Phase 2 gating

MedicaidCategory::Tma => {
    if !magi_out.tma_eligible {
        false
    } else {
        // Phase 1 (months 1-6): no income test
        // Phase 2 (months 7-12): income ≤ 205% FPL
        let tma_start = ctx.tanf_termination_date.as_deref()
            .and_then(|d| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok());
        match tma_start {
            Some(start) => {
                let months_since = crate::tma::months_since(start, Utc::now().date_naive());
                if months_since <= 6 {
                    true // Phase 1: no income test
                } else if months_since <= 12 {
                    // Phase 2: income ≤ 205% FPL
                    let tma_threshold = fpl_100 * Decimal::from(205) / Decimal::from(100);
                    net_magi <= tma_threshold
                } else {
                    false // TMA expired
                }
            }
            None => false,
        }
    }
},
NOTE
tma::months_since() may need to be added as a helper in tma.rs if not already present. It computes the number of calendar months between two dates.

denial_reason_fn

MedicaidCategory::Tma if !ctx.had_tanf_in_prior_months.unwrap_or(false) => "no_prior_tanf_coverage",
MedicaidCategory::Tma => "tma_income_exceeds_205_fpl_in_phase_2",

Steps

Step 1: Publish tanf.case_closed from canopy-tanf

Files: services/canopy-tanf/src/determine.rs, services/canopy-tanf/src/handlers.rs

Add event publication logic after a TANF termination determination is persisted. The event payload must include household_id, person_ids, reason, termination_date, and had_medicaid_coverage. Follow the existing pattern for tanf.determined event publication. Only publish tanf.case_closed when the determination results in case closure (not for initial denials).

Step 2: Database migration

Files: services/canopy-medicaid/migrations/{timestamp}_create_tanf_tma_coverage.sql

Create the tanf_tma_coverage table as specified in the Design section. Include the 3 indexes. Follow the existing migration naming convention.

Step 3: Store layer

Files: services/canopy-medicaid/src/store/mod.rs (or services/canopy-medicaid/src/store/tma.rs)

Implement create_tma_coverage, find_active_tma_coverage, and update_tma_coverage_status using sqlx compile-time verified queries. create_tma_coverage should call tma::build_tma_coverage() to compute dates and QRF schedule, then insert. Follow the existing store function patterns in the module.

Step 4: Wire subscriber

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

Replace the stub subscriber at lines 102-122 with real logic:

  1. Parse household_id and person_ids from the event payload.

  2. For each person_id, call store::create_tma_coverage().

  3. Log the created coverage records.

  4. Publish medicaid.tma_coverage_created event (optional but recommended for audit trail).

Step 5: ApplicationContext fields

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

Add had_tanf_in_prior_months: Option<bool> and tanf_termination_date: Option<String> with #[serde(default)] to ApplicationContext. Unwrap in the determine function body.

Step 6: MagiInput/MagiOutput

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

Add had_tanf_in_prior_months: bool and tanf_termination_date: Option<String> to MagiInput. Add tma_eligible: bool to MagiOutput. Wire in determine.rs where MagiInput is constructed.

Step 7: medicaid-magi.json

Files: rulesets/georgia/medicaid-magi.json

Add the TMA expression node and output mapping. The expression is: had_tanf_in_prior_months and tanf_termination_date != null.

Step 8: eligible_fn + denial_reason_fn + tests

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

Replace MedicaidCategory::Tma in eligible_fn (currently falls through to _ ⇒ false) with the Phase 1/Phase 2 gating logic from the Design section. Add denial reasons. Add or update tma.rs with a months_since() helper if needed.

Add 3 unit tests:

  1. TMA Phase 1 eligible: had_tanf=true, termination 2 months ago → eligible (no income test)

  2. TMA Phase 2 eligible: had_tanf=true, termination 8 months ago, income ≤ 205% FPL → eligible

  3. TMA Phase 2 denied: had_tanf=true, termination 8 months ago, income > 205% FPL → denied

Files Touched

File Change

services/canopy-tanf/src/determine.rs

Add tanf.case_closed event publication on case termination

services/canopy-medicaid/migrations/{timestamp}_create_tanf_tma_coverage.sql

New migration: tanf_tma_coverage table + indexes

services/canopy-medicaid/src/store/mod.rs

Add create_tma_coverage, find_active_tma_coverage, update_tma_coverage_status

services/canopy-medicaid/src/main.rs

Wire subscriber stub to create TMA coverage records

services/canopy-medicaid/src/determine.rs

Add 2 ApplicationContext fields, wire TMA in eligible_fn with Phase 1/Phase 2 gating, add denial reasons

services/canopy-medicaid/src/rules_client.rs

Add 2 MagiInput fields, 1 MagiOutput field

rulesets/georgia/medicaid-magi.json

Add tma_eligible expression node

services/canopy-medicaid/src/tma.rs

Add months_since() helper if not present

Verification

  1. cargo nextest run -p canopy-tanf --lib — tanf event tests pass

  2. cargo xtask dev restart — schema changes applied (new migration)

  3. cargo nextest run -p canopy-medicaid --lib — all medicaid unit tests pass (existing 8 TMA tests + 3 new)

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

  5. Verify subscriber logs TMA coverage creation on a manual tanf.case_closed event via RabbitMQ management UI

  6. cargo xtask e2e — E2E tests pass

Documentation Updates

  • .claude/docs/services.md — update canopy-medicaid event subscriptions and table lists

  • .claude/docs/services.md — update canopy-tanf event publications (add tanf.case_closed)

  • CHANGELOG.adoc — entry under == Unreleased

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

Errata

Integration tests deferred

The plan calls for 3 integration tests (Phase 1 eligible, denied voluntary, Phase 2 income check). These require the tanf_tma_coverage migration to be applied via cargo xtask dev restart. The tests were not included in the initial commit and should be added after the next devstack restart that applies the migration.

Edit this page · default