Plan: Medicaid COA Phase C — TMA (Transitional Medical Assistance)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Publish |
Done (2026-04-19) |
2 |
Database migration: |
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.rscontainsis_tma_eligible(),build_tma_coverage(), andqrf_schedule()functions with 8 passing tests. -
services/canopy-medicaid/src/main.rslines 102-122 have a subscriber stub bound totanf.case_closedthat logs but does not create records. -
MedicaidCategory::Tmaexists in the enum and CMD cascade hierarchy. -
The
eligible_fnin determine.rs currently falls through to_ ⇒ falseforMedicaidCategory::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_closedevent publication from canopy-tanf -
tanf_tma_coveragemigration 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 withis_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,
}
}
},
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.
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:
-
Parse
household_idandperson_idsfrom the event payload. -
For each person_id, call
store::create_tma_coverage(). -
Log the created coverage records.
-
Publish
medicaid.tma_coverage_createdevent (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:
-
TMA Phase 1 eligible: had_tanf=true, termination 2 months ago → eligible (no income test)
-
TMA Phase 2 eligible: had_tanf=true, termination 8 months ago, income ≤ 205% FPL → eligible
-
TMA Phase 2 denied: had_tanf=true, termination 8 months ago, income > 205% FPL → denied
Files Touched
| File | Change |
|---|---|
|
Add tanf.case_closed event publication on case termination |
|
New migration: tanf_tma_coverage table + indexes |
|
Add create_tma_coverage, find_active_tma_coverage, update_tma_coverage_status |
|
Wire subscriber stub to create TMA coverage records |
|
Add 2 ApplicationContext fields, wire TMA in eligible_fn with Phase 1/Phase 2 gating, add denial reasons |
|
Add 2 MagiInput fields, 1 MagiOutput field |
|
Add tma_eligible expression node |
|
Add months_since() helper if not present |
Verification
-
cargo nextest run -p canopy-tanf --lib— tanf event tests pass -
cargo xtask dev restart— schema changes applied (new migration) -
cargo nextest run -p canopy-medicaid --lib— all medicaid unit tests pass (existing 8 TMA tests + 3 new) -
cargo nextest run --workspace— integration tests pass -
Verify subscriber logs TMA coverage creation on a manual
tanf.case_closedevent via RabbitMQ management UI -
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.