Plan: Medicaid/CHIP Federal Reporting — T-MSIS, CMS-64, CMS-416 (canopy-reporting)
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Steps
- Step 1: Add
GET /v1/determinationslist endpoint to canopy-medicaid - Step 2: Extend
MedicaidDeterminationSummaryin reporting client - Step 3: Add
get_personclient method for demographic lookup - Step 4: COA-to-T-MSIS mapping module
- Step 5: Enrich T-MSIS extraction pipeline
- Step 6: CMS-64 enrollment aggregation
- Step 7: CMS-416 EPSDT child enrollment extraction
- Step 8: T-MSIS CSV export endpoint
- Step 9: CMS-64 CSV export endpoint
- Step 10: Integration tests
- Step 1: Add
- Files Touched
- Verification
- Errata
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Upstream prerequisite: add |
Done (2026-04-13) |
2 |
Extend |
Done (2026-04-13) |
3 |
Add |
Done (2026-04-13) |
4 |
COA-to-T-MSIS mapping module: coverage group, disability, dual-eligible, population group derivation |
Done (2026-04-13) — 38-COA mapping; see errata for limitations |
5 |
Enrich T-MSIS extraction pipeline with all field categories |
Done (2026-04-13) |
6 |
Implement CMS-64 enrollment aggregation from T-MSIS extracts |
Done (2026-04-13) — expenditure data from MMIS out of scope (Tier 5.5) |
7 |
Implement CMS-416 EPSDT child enrollment extraction |
Done (2026-04-13) — enrollment by age band |
8 |
T-MSIS CSV export endpoint |
Done (2026-04-13) |
9 |
CMS-64 POST endpoint and CMS-64 CSV export endpoint |
Done (2026-04-13) |
10 |
Integration tests (10 scenarios) |
Done (2026-04-12) — structural content tests added per roadmap Tier 1 |
Epic: &31
Branch: feature/medicaid-federal-reporting-v2
Labels: type::feature, priority::medium, program::medicaid, program::chip, service::reporting, service::medicaid, workflow::ready, federal-partner::cms
Context
CMS requires states to submit three Medicaid/CHIP reports:
-
T-MSIS (Transformed Medicaid Statistical Information System) — Monthly person-level eligibility extract. CMS uses T-MSIS data for the T-MSIS Analytic Files (TAF) and the Outcome Based Assessment (OBA) that measures state data quality. The eligibility file is the component Canopy produces — one row per enrolled person per month covering demographics, coverage group, citizenship, disability, dual-eligible status, CHIP indicator, and income as percent of FPL.
-
CMS-64 (Quarterly Statement of Expenditures) — Quarterly federal financial participation report. Expenditure dollar amounts come from the state accounting system (MMIS), which is out of scope. Canopy contributes enrollment counts and member months by population group and expenditure category, derived from T-MSIS extracts.
-
CMS-416 (Annual EPSDT Report) — Annual report on children enrolled in Medicaid by age group. Screening counts come from clinical/claims systems (MMIS), which is out of scope. Canopy contributes the denominator: unduplicated enrolled children and member months by CMS-defined age groups.
Current state
The skeleton infrastructure exists:
-
Migration
20260409000000created all three tables (medicaid_tmsis_eligibility_extracts,medicaid_cms64_reports,medicaid_cms416_reports). -
Domain structs
MedicaidTmsisExtract,MedicaidCms64Report,MedicaidCms416Reportexist indomain.rs. -
Five API routes exist: POST+GET T-MSIS, GET CMS-64, POST+GET CMS-416.
-
T-MSIS extraction (
reporting/medicaid.rs) populates only 2 of 8 field categories:coverage_group(fromassigned_coa) andchip_indicator(fromassigned_coa_track == "chip"). The remaining fields (citizenship_status,disability_indicator,dual_eligible_indicator,dual_eligible_category,income_as_pct_fpl,eligibility_end_date) are hardcoded to defaults or NULL. -
CMS-64 has no POST endpoint and no aggregation logic.
-
CMS-416 POST handler contains a
TODOcomment and returns only the count of existing rows. -
ServiceClients::list_medicaid_determinations()callsGET /v1/determinationson canopy-medicaid, but that endpoint does not exist — the call silently returns an empty vec via.or_else(|_| Ok(Vec::new())).
Regulatory basis
-
T-MSIS — CMS mandatory submission; TAF quality standards; CMS MSIS State Data Certification
-
42 CFR 430.30 — CMS-64 quarterly expenditure report requirements
-
42 USC §1396a(a)(43) — EPSDT requirements
-
42 CFR 441.56 — CMS-416 EPSDT screening and reporting requirements
-
HIPAA — All PHI in T-MSIS data subject to HIPAA Privacy and Security Rules
Scope
In scope:
-
Add
GET /v1/determinationslist endpoint to canopy-medicaid (upstream prerequisite) -
Extend
MedicaidDeterminationSummarywitheffective_date,expiration_date,fpl_percentage,date_of_birth -
Add
get_personclient method for demographic lookup (citizenship, disability) -
COA-to-CMS coverage group mapping for all 38 COAs
-
Enrich T-MSIS extraction with all 8 field categories
-
CMS-64 POST endpoint: aggregate enrollment counts from T-MSIS extracts by population group
-
CMS-416 POST endpoint: extract child enrollment from T-MSIS extracts by age group
-
T-MSIS CSV export endpoint (
GET /reporting/medicaid/tmsis/{month}/csv) -
CMS-64 CSV export endpoint (
GET /reporting/medicaid/cms-64/{fy}/{quarter}/csv) -
10 content-level integration tests
Out of scope:
-
Medicaid/CHIP eligibility determination logic — covered in
medicaid-eligibilityplan -
T-MSIS claims, managed care, and provider files — requires MMIS integration
-
CMS-416 screening numerator — requires claims/encounter data from MMIS
-
CMS-64 expenditure dollar amounts — requires state accounting system
-
FTI handling — canopy-reporting never accesses FTI (per ADR-004)
-
Managed care enrollment data — external MCO system;
managed_care_enrolledremainsfalse,managed_care_plan_idremainsNULL -
Automated monthly scheduling — this plan delivers on-demand generation
-
Georgia Pathways 1115 waiver monitoring reports (separate CMS template)
Dependencies
-
medicaid-eligibility (complete): canopy-medicaid stores determinations with
assigned_coa,assigned_coa_track,effective_date,expiration_date,fpl_percentage -
persons-household-model (complete): canopy-persons stores
citizenship_status,disability_status,date_of_birth -
snap-federal-reporting (complete): establishes the reporting module pattern (assembly function → store → CSV export)
Design
COA → T-MSIS Coverage Group Mapping
Every COA code from canopy-medicaid’s MedicaidCategory enum maps to a CMS coverage group code for T-MSIS reporting. The mapping also derives disability_indicator, dual_eligible_indicator, dual_eligible_category, chip_indicator, and CMS-64 population_group.
| COA Code | CMS Coverage Group | Disability | Dual-Eligible | Dual Category | CMS-64 Pop Group |
|---|---|---|---|---|---|
|
|
false |
false |
— |
children |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
children |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
children |
|
|
false |
false |
— |
children |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
false |
false |
— |
adults |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
false |
false |
— |
elderly |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
children |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
blind_disabled |
|
|
true |
false |
— |
elderly |
|
|
true |
true |
|
blind_disabled |
|
|
false |
true |
|
elderly |
|
|
false |
true |
|
elderly |
|
|
false |
true |
|
elderly |
|
|
false |
false |
— |
elderly |
|
|
false |
false |
— |
chip |
disability_indicator is true for ABD-track COAs (SSI, Pickle, DAC, DW, FSC, EDWP, NOW, COMP, TEFRA, Hospice, Hospital, ICWP, QDWI) and false otherwise. dual_eligible_indicator is true only for Q-Track COAs (QMB, SLMB, QI-1, QDWI). Managed care fields always false/NULL (external MCO system).
CMS-416 Age Groups
Per CMS-416 instructions, children are segmented into these age groups for the reporting year:
| Code | Age Range |
|---|---|
|
0 to <1 year old |
|
1 to 2 years old |
|
3 to 5 years old |
|
6 to 9 years old |
|
10 to 14 years old |
|
15 to 18 years old |
|
19 to 20 years old |
Age is calculated as of December 31 of the report year. A person qualifies as a child if they are 20 or younger on that date.
CMS-64 Population Groups
| Code | Definition |
|---|---|
|
Under 19 at report quarter end |
|
19-64 at report quarter end |
|
65+ at report quarter end |
|
Any age with ABD-track COA (overrides age-based group) |
|
PeachCare CHIP enrollees (any age) |
blind_disabled overrides the age-based population group. A 70-year-old in ssi_medicaid is blind_disabled, not elderly. CHIP enrollees are always chip regardless of age.
CMS-64 Expenditure Categories
Canopy populates only enrollment (the single category where Canopy has data). All other CMS-64 expenditure categories (acute_care, managed_care_capitation, long_term_care, dsh, chip_allotment, waiver, administrative, medicare_cost_sharing, premium_assistance, health_homes, community_first_choice, other) have enrolled_count and member_months set to NULL because they require MMIS expenditure data to be meaningful.
HIPAA
T-MSIS eligibility extracts contain PHI. Controls:
-
medicaid_tmsis_eligibility_extractsaccess restricted to canopy-reporting DB role only -
PHI fields (name, SSN hash, DOB, address) never in logs — log only
person_idandcoverage_group -
PHI never in event payloads on
canopy.events -
CSV exports use encrypted transport (TLS) and respect RBAC (supervisor-only)
-
canopy-reporting does not store raw clinical data — disability is a coded boolean
Steps
Step 1: Add GET /v1/determinations list endpoint to canopy-medicaid
Files:
-
services/canopy-medicaid/src/store/mod.rs(modify) -
services/canopy-medicaid/src/api/handlers.rs(modify) -
services/canopy-medicaid/src/api/mod.rs(modify)
This is the upstream prerequisite. Without it, ServiceClients::list_medicaid_determinations() silently returns an empty vec.
Add a store function:
/// List all approved Medicaid determinations (for reporting extraction).
/// Returns only determinations with status = 'approved'.
pub async fn list_approved_determinations(
pool: &PgPool,
) -> Result<Vec<MedicaidDetermination>, sqlx::Error> {
sqlx::query_as::<_, MedicaidDetermination>(
"SELECT * FROM medicaid_determinations WHERE status = 'approved' ORDER BY determined_at DESC LIMIT 10000",
)
.fetch_all(pool)
.await
}
Add a handler:
/// GET /v1/determinations — List approved determinations (reporting use).
#[utoipa::path(
get,
path = "/determinations",
tag = "Determination",
security(("bearer" = [])),
responses(
(status = 200, description = "List of approved determinations", body = Vec<MedicaidDetermination>),
)
)]
pub async fn list_determinations(
Extension(claims): Extension<Claims>,
Extension(db): Extension<PgPool>,
) -> Result<Json<Vec<MedicaidDetermination>>, ApiError> {
claims.require_supervisor_or_above()?;
let dets = store::list_approved_determinations(&db)
.await
.map_err(|e| ApiError::internal("list determinations", e))?;
Ok(Json(dets))
}
Register the route in api/mod.rs:
.route("/determinations", get(handlers::list_determinations))
Step 2: Extend MedicaidDeterminationSummary in reporting client
Files:
-
services/canopy-reporting/src/clients/mod.rs(modify)
The current MedicaidDeterminationSummary only has 6 fields. The T-MSIS extraction needs effective_date, expiration_date, and FPL percentage from the upstream determination. The canopy-medicaid MedicaidDetermination struct already has these fields, so they will be deserialized from the same GET /v1/determinations response.
#[derive(Debug, Deserialize)]
pub struct MedicaidDeterminationSummary {
pub id: uuid::Uuid,
pub household_id: uuid::Uuid,
pub person_id: uuid::Uuid,
pub status: String,
pub assigned_coa: Option<String>,
pub assigned_coa_track: Option<String>,
// New fields for T-MSIS enrichment:
pub effective_date: Option<NaiveDate>,
pub expiration_date: Option<NaiveDate>,
pub benefit_type: Option<String>,
}
fpl_percentage is on MedicaidEligibleCategory, not on MedicaidDetermination. To get it, we would need a second call to GET /v1/determinations/{id}/categories. To avoid N+1 queries for every enrollee, add an optional batch endpoint or accept the N+1 cost since reporting runs are offline. This plan uses the N+1 approach with a new client method.
Add to ServiceClients:
/// Fetch eligible categories for a determination (for FPL percentage).
pub async fn get_medicaid_categories(
&self,
determination_id: uuid::Uuid,
) -> anyhow::Result<Vec<MedicaidCategorySummary>> {
self.medicaid
.get(&format!("/v1/determinations/{determination_id}/categories"))
.await
.or_else(|_| Ok(Vec::new()))
}
Add the response type:
#[derive(Debug, Deserialize)]
pub struct MedicaidCategorySummary {
pub coa_code: String,
pub coa_track: String,
pub eligible: bool,
pub fpl_percentage: Option<Decimal>,
}
Step 3: Add get_person client method for demographic lookup
Files:
-
services/canopy-reporting/src/clients/mod.rs(modify)
canopy-persons GET /v1/persons/{id} returns Person with citizenship_status, disability_status, and date_of_birth. Add a client method and response type.
/// Fetch a person's demographics from canopy-persons.
pub async fn get_person(&self, person_id: uuid::Uuid) -> anyhow::Result<PersonDetail> {
self.persons
.get(&format!("/v1/persons/{person_id}"))
.await
}
#[derive(Debug, Deserialize)]
pub struct PersonDetail {
pub id: uuid::Uuid,
pub date_of_birth: NaiveDate,
pub citizenship_status: Option<String>,
pub disability_status: Option<String>,
}
Step 4: COA-to-T-MSIS mapping module
Files:
-
services/canopy-reporting/src/reporting/medicaid_mapping.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod medicaid_mapping;)
Create a pure-function mapping module. No I/O, no async — just deterministic lookups.
// SPDX-License-Identifier: AGPL-3.0-or-later
//! COA → T-MSIS field derivation.
//! Maps canopy-medicaid's 38 COA codes to CMS coverage groups and derived indicators.
/// T-MSIS fields derived from a single COA code.
pub struct CoaMappedFields {
pub cms_coverage_group: &'static str,
pub disability_indicator: bool,
pub dual_eligible_indicator: bool,
pub dual_eligible_category: Option<&'static str>,
pub chip_indicator: bool,
pub population_group: &'static str,
}
/// Map a COA code string to CMS T-MSIS fields.
/// Returns `None` if the COA code is unrecognized.
pub fn map_coa(coa_code: &str) -> Option<CoaMappedFields> {
Some(match coa_code {
"newborn" => CoaMappedFields {
cms_coverage_group: "infant_newborn",
disability_indicator: false,
dual_eligible_indicator: false,
dual_eligible_category: None,
chip_indicator: false,
population_group: "children",
},
"pregnant_women" => CoaMappedFields {
cms_coverage_group: "pregnant_women",
disability_indicator: false,
dual_eligible_indicator: false,
dual_eligible_category: None,
chip_indicator: false,
population_group: "adults",
},
// ... one arm per COA code (38 total, per the mapping table in Design) ...
"peachcare" => CoaMappedFields {
cms_coverage_group: "chip_separate",
disability_indicator: false,
dual_eligible_indicator: false,
dual_eligible_category: None,
chip_indicator: true,
population_group: "chip",
},
_ => return None,
})
}
/// CMS-416 age group code from age in years.
pub fn cms416_age_group(age_years: i32) -> Option<&'static str> {
match age_years {
0 => Some("under_1"),
1..=2 => Some("1_2"),
3..=5 => Some("3_5"),
6..=9 => Some("6_9"),
10..=14 => Some("10_14"),
15..=18 => Some("15_18"),
19..=20 => Some("19_20"),
_ => None,
}
}
/// Determine CMS-64 population group.
/// `blind_disabled` overrides age. `chip` overrides everything.
pub fn cms64_population_group(coa_code: &str, age_years: i32) -> &'static str {
if let Some(mapped) = map_coa(coa_code) {
// chip and blind_disabled from the COA take priority
if mapped.chip_indicator {
return "chip";
}
if mapped.disability_indicator {
return "blind_disabled";
}
}
match age_years {
0..=18 => "children",
19..=64 => "adults",
_ => "elderly",
}
}
The implementer MUST include all 38 match arms from the mapping table. A #[test] must verify all 38 COA codes return Some.
Step 5: Enrich T-MSIS extraction pipeline
Files:
-
services/canopy-reporting/src/reporting/medicaid.rs(rewrite)
Replace the current extract_tmsis function. The new version:
-
Calls
clients.list_medicaid_determinations()to get all approved determinations -
For each determination with
status == "approved":-
Calls
clients.get_person(det.person_id)forcitizenship_statusanddisability_status -
Calls
clients.get_medicaid_categories(det.id)to find the assigned COA’sfpl_percentage -
Calls
medicaid_mapping::map_coa(coa_code)for derived fields -
Upserts into
medicaid_tmsis_eligibility_extracts
-
-
Logs extraction count (no PHI)
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Medicaid T-MSIS monthly eligibility extraction.
//! Per ADR-001: all data fetched via HTTP from canopy-medicaid and canopy-persons APIs.
//! HIPAA: No PHI in logs. T-MSIS extracts contain PHI — access restricted.
use chrono::NaiveDate;
use sqlx::PgPool;
use tracing::{info, warn};
use crate::clients::ServiceClients;
use crate::reporting::medicaid_mapping;
pub struct TmsisExtractionResult {
pub extracts_inserted: i64,
pub extracts_skipped: i64,
}
pub async fn extract_tmsis(
db: &PgPool,
clients: &ServiceClients,
report_month: NaiveDate,
) -> anyhow::Result<TmsisExtractionResult> {
let determinations = clients.list_medicaid_determinations().await?;
let mut inserted: i64 = 0;
let mut skipped: i64 = 0;
for det in &determinations {
if det.status != "approved" {
skipped += 1;
continue;
}
let coa_code = det.assigned_coa.as_deref().unwrap_or("unknown");
let mapped = medicaid_mapping::map_coa(coa_code).unwrap_or_else(|| {
warn!(coa_code = coa_code, person_id = %det.person_id, "unrecognized COA — using defaults");
medicaid_mapping::default_mapped_fields()
});
// Person demographics (citizenship, disability, DOB)
let person = clients.get_person(det.person_id).await.ok();
let citizenship = person
.as_ref()
.and_then(|p| p.citizenship_status.clone())
.unwrap_or_else(|| "unknown".to_string());
// Use COA-derived disability as primary; person-level as fallback
let disability = mapped.disability_indicator
|| person
.as_ref()
.and_then(|p| p.disability_status.as_deref())
.map(|ds| ds == "disabled" || ds == "disabled_veteran")
.unwrap_or(false);
// FPL percentage from the assigned COA's eligible category
let fpl_pct = if let Ok(cats) = clients.get_medicaid_categories(det.id).await {
cats.iter()
.find(|c| c.coa_code == coa_code && c.eligible)
.and_then(|c| c.fpl_percentage)
} else {
None
};
sqlx::query(
r#"INSERT INTO medicaid_tmsis_eligibility_extracts
(id, report_month, person_id, enrollment_id, eligibility_status,
coverage_group, eligibility_start_date, eligibility_end_date,
income_as_pct_fpl, citizenship_status,
disability_indicator, dual_eligible_indicator, dual_eligible_category,
managed_care_enrolled, managed_care_plan_id,
chip_indicator, restricted_benefits_indicator, extracted_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14, $15, $16, now())
ON CONFLICT (report_month, enrollment_id) DO UPDATE
SET eligibility_status = EXCLUDED.eligibility_status,
coverage_group = EXCLUDED.coverage_group,
eligibility_end_date = EXCLUDED.eligibility_end_date,
income_as_pct_fpl = EXCLUDED.income_as_pct_fpl,
citizenship_status = EXCLUDED.citizenship_status,
disability_indicator = EXCLUDED.disability_indicator,
dual_eligible_indicator = EXCLUDED.dual_eligible_indicator,
dual_eligible_category = EXCLUDED.dual_eligible_category,
chip_indicator = EXCLUDED.chip_indicator,
extracted_at = now()"#,
)
.bind(report_month) // $1
.bind(det.person_id) // $2
.bind(det.id) // $3 enrollment proxy
.bind(&det.status) // $4
.bind(mapped.cms_coverage_group) // $5
.bind(det.effective_date.unwrap_or(report_month)) // $6
.bind(det.expiration_date) // $7
.bind(fpl_pct) // $8
.bind(&citizenship) // $9
.bind(disability) // $10
.bind(mapped.dual_eligible_indicator) // $11
.bind(mapped.dual_eligible_category) // $12
.bind(false) // $13 managed_care always false
.bind(None::<String>) // $14 managed_care_plan_id always NULL
.bind(mapped.chip_indicator) // $15
.bind(false) // $16 restricted_benefits default
.execute(db)
.await?;
inserted += 1;
}
info!(
report_month = %report_month,
extracts = inserted,
skipped = skipped,
"T-MSIS extraction complete"
);
Ok(TmsisExtractionResult {
extracts_inserted: inserted,
extracts_skipped: skipped,
})
}
Step 6: CMS-64 enrollment aggregation
Files:
-
services/canopy-reporting/src/reporting/cms64.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod cms64;) -
services/canopy-reporting/src/store.rs(modify — add CMS-64 insert/upsert) -
services/canopy-reporting/src/api/mod.rs(modify — add POST handler)
Aggregate enrollment counts from existing T-MSIS extracts in the reporting database. No upstream HTTP calls — pure SQL aggregation.
// SPDX-License-Identifier: AGPL-3.0-or-later
//! CMS-64 quarterly enrollment aggregation from T-MSIS extracts.
//! Expenditure amounts remain NULL (state accounting system, out of scope).
use chrono::NaiveDate;
use sqlx::PgPool;
use tracing::info;
pub struct Cms64AggregationResult {
pub rows_inserted: i64,
}
/// Aggregate enrollment counts by population group for a fiscal quarter.
/// Fiscal year quarters: Q1 = Oct-Dec, Q2 = Jan-Mar, Q3 = Apr-Jun, Q4 = Jul-Sep.
pub async fn aggregate_cms64(
db: &PgPool,
fiscal_year: i32,
fiscal_quarter: i32,
) -> anyhow::Result<Cms64AggregationResult> {
let (start_month, end_month) = quarter_date_range(fiscal_year, fiscal_quarter)?;
// Delete existing rows for this quarter (upsert via delete+insert for simplicity)
sqlx::query(
"DELETE FROM medicaid_cms64_reports WHERE fiscal_year = $1 AND fiscal_quarter = $2 AND expenditure_category = 'enrollment'"
)
.bind(fiscal_year)
.bind(fiscal_quarter)
.execute(db)
.await?;
// Aggregate from T-MSIS extracts.
// Population group is derived from coverage_group using the mapping logic.
// For now, use a simplified SQL mapping:
let rows = sqlx::query_as::<_, (String, i64, i64)>(
r#"SELECT
CASE
WHEN chip_indicator THEN 'chip'
WHEN disability_indicator THEN 'blind_disabled'
WHEN coverage_group IN ('infant_newborn','children','foster_care_iv_e',
'adoption_assistance','tefra_katie_beckett') THEN 'children'
WHEN coverage_group IN ('nursing_facility','qmb','slmb','qi',
'aged_medically_needy','widow_60_64') THEN 'elderly'
ELSE 'adults'
END AS pop_group,
COUNT(DISTINCT person_id) AS enrolled,
COUNT(*) AS member_months
FROM medicaid_tmsis_eligibility_extracts
WHERE report_month >= $1 AND report_month <= $2
GROUP BY pop_group"#,
)
.bind(start_month)
.bind(end_month)
.fetch_all(db)
.await?;
let mut inserted: i64 = 0;
for (pop_group, enrolled, member_months) in &rows {
sqlx::query(
r#"INSERT INTO medicaid_cms64_reports
(id, fiscal_year, fiscal_quarter, expenditure_category, population_group,
enrolled_count, member_months)
VALUES (gen_random_uuid(), $1, $2, 'enrollment', $3, $4, $5)"#,
)
.bind(fiscal_year)
.bind(fiscal_quarter)
.bind(pop_group)
.bind(*enrolled as i32)
.bind(*member_months as i32)
.execute(db)
.await?;
inserted += 1;
}
info!(
fiscal_year = fiscal_year,
fiscal_quarter = fiscal_quarter,
population_groups = inserted,
"CMS-64 aggregation complete"
);
Ok(Cms64AggregationResult {
rows_inserted: inserted,
})
}
/// Convert federal fiscal year + quarter to calendar date range.
/// FFY Q1 = Oct 1 - Dec 31, Q2 = Jan 1 - Mar 31, Q3 = Apr 1 - Jun 30, Q4 = Jul 1 - Sep 30.
fn quarter_date_range(
fiscal_year: i32,
quarter: i32,
) -> anyhow::Result<(NaiveDate, NaiveDate)> {
let (cal_year, start_month, end_month) = match quarter {
1 => (fiscal_year - 1, 10, 12),
2 => (fiscal_year, 1, 3),
3 => (fiscal_year, 4, 6),
4 => (fiscal_year, 7, 9),
_ => anyhow::bail!("invalid fiscal quarter: {quarter}"),
};
let start = NaiveDate::from_ymd_opt(cal_year, start_month, 1)
.ok_or_else(|| anyhow::anyhow!("invalid start date"))?;
let end = NaiveDate::from_ymd_opt(cal_year, end_month, 1)
.ok_or_else(|| anyhow::anyhow!("invalid end date"))?;
Ok((start, end))
}
Add the CMS-64 domain request type (currently missing) to domain.rs:
/// Request to generate CMS-64 quarterly enrollment report.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct GenerateCms64Request {
pub fiscal_year: i32,
pub fiscal_quarter: i32,
}
Add POST handler in api/mod.rs:
/// POST /v1/reporting/medicaid/cms-64 — Generate CMS-64 quarterly enrollment aggregation.
async fn generate_medicaid_cms64(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Json(req): Json<GenerateCms64Request>,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
claims.require_supervisor_or_above()?;
let result = crate::reporting::cms64::aggregate_cms64(
state.db.inner(), req.fiscal_year, req.fiscal_quarter,
)
.await
.map_err(|e| ApiError::internal("CMS-64 aggregation failed", e))?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"fiscal_year": req.fiscal_year,
"fiscal_quarter": req.fiscal_quarter,
"population_groups": result.rows_inserted,
"status": "generated"
})),
))
}
Register the route: .route("/reporting/medicaid/cms-64", post(generate_medicaid_cms64)) (add to existing GET route).
Step 7: CMS-416 EPSDT child enrollment extraction
Files:
-
services/canopy-reporting/src/reporting/cms416.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod cms416;) -
services/canopy-reporting/src/api/mod.rs(modify — replace TODO ingenerate_medicaid_cms416)
Extract child enrollment from T-MSIS extracts, grouped by CMS-416 age groups. Requires date_of_birth from canopy-persons to calculate age.
Strategy: query T-MSIS extracts for the report year. For each distinct person_id, call clients.get_person(person_id) to get DOB. Calculate age as of Dec 31 of report year. Count member months (number of monthly extracts for that person in the year).
// SPDX-License-Identifier: AGPL-3.0-or-later
//! CMS-416 annual EPSDT child enrollment extraction.
//! Screening numerator fields remain NULL (MMIS claims data, out of scope).
use std::collections::HashMap;
use chrono::NaiveDate;
use sqlx::PgPool;
use tracing::{info, warn};
use crate::clients::ServiceClients;
use crate::reporting::medicaid_mapping;
pub struct Cms416ExtractionResult {
pub age_groups_inserted: i64,
}
pub async fn extract_cms416(
db: &PgPool,
clients: &ServiceClients,
report_year: i32,
) -> anyhow::Result<Cms416ExtractionResult> {
let year_start = NaiveDate::from_ymd_opt(report_year, 1, 1).unwrap();
let year_end = NaiveDate::from_ymd_opt(report_year, 12, 1).unwrap();
let dec_31 = NaiveDate::from_ymd_opt(report_year, 12, 31).unwrap();
// Get distinct person_ids and their monthly extract count for the year
let person_months: Vec<(uuid::Uuid, i64)> = sqlx::query_as(
r#"SELECT person_id, COUNT(*) AS months
FROM medicaid_tmsis_eligibility_extracts
WHERE report_month >= $1 AND report_month <= $2
GROUP BY person_id"#,
)
.bind(year_start)
.bind(year_end)
.fetch_all(db)
.await?;
// Accumulate by age group: (enrolled_count, member_months)
let mut age_groups: HashMap<&str, (i32, i32)> = HashMap::new();
for code in &["under_1", "1_2", "3_5", "6_9", "10_14", "15_18", "19_20"] {
age_groups.insert(code, (0, 0));
}
for (person_id, months) in &person_months {
let person = match clients.get_person(*person_id).await {
Ok(p) => p,
Err(e) => {
warn!(person_id = %person_id, error = %e, "skipping — person fetch failed");
continue;
}
};
// Age as of Dec 31 of report year
let age = (dec_31 - person.date_of_birth).num_days() / 365;
if let Some(group) = medicaid_mapping::cms416_age_group(age as i32) {
let entry = age_groups.entry(group).or_insert((0, 0));
entry.0 += 1; // enrolled count
entry.1 += *months as i32; // member months
}
// age > 20: not a child, skip
}
// Delete existing rows for this year (idempotent regeneration)
sqlx::query("DELETE FROM medicaid_cms416_reports WHERE report_year = $1")
.bind(report_year)
.execute(db)
.await?;
let mut inserted: i64 = 0;
for (age_group, (enrolled, member_months)) in &age_groups {
// eligible_for_screening: children enrolled >= 90 continuous days
// Simplified: use enrolled count (full screening eligibility requires
// continuous enrollment analysis, which is a future enhancement)
sqlx::query(
r#"INSERT INTO medicaid_cms416_reports
(id, report_year, age_group, total_enrolled_children,
total_member_months, eligible_for_screening)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5)"#,
)
.bind(report_year)
.bind(age_group)
.bind(enrolled)
.bind(member_months)
.bind(enrolled) // simplified: all enrolled = eligible for screening
.execute(db)
.await?;
inserted += 1;
}
info!(
report_year = report_year,
age_groups = inserted,
"CMS-416 extraction complete"
);
Ok(Cms416ExtractionResult {
age_groups_inserted: inserted,
})
}
Update generate_medicaid_cms416 handler in api/mod.rs:
async fn generate_medicaid_cms416(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Extension(clients): Extension<std::sync::Arc<crate::clients::ServiceClients>>,
headers: HeaderMap,
Json(req): Json<GenerateCms416Request>,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
claims.require_supervisor_or_above()?;
let scoped = clients.scoped(bearer_token(&headers)?);
let result = crate::reporting::cms416::extract_cms416(
state.db.inner(), &scoped, req.report_year,
)
.await
.map_err(|e| ApiError::internal("CMS-416 extraction failed", e))?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"report_year": req.report_year,
"age_groups": result.age_groups_inserted,
"status": "generated"
})),
))
}
Step 8: T-MSIS CSV export endpoint
Files:
-
services/canopy-reporting/src/reporting/medicaid_export.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod medicaid_export;) -
services/canopy-reporting/src/store.rs(modify — addlist_tmsis_by_month) -
services/canopy-reporting/src/api/mod.rs(modify — add route)
Add a store function to fetch T-MSIS extracts for a specific month:
/// List T-MSIS extracts for a specific reporting month.
pub async fn list_tmsis_by_month(
pool: &PgPool,
report_month: NaiveDate,
) -> Result<Vec<crate::domain::MedicaidTmsisExtract>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM medicaid_tmsis_eligibility_extracts WHERE report_month = $1 ORDER BY person_id",
)
.bind(report_month)
.fetch_all(pool)
.await
}
CSV generation follows the same pattern as generate_fns_7176_csv in reporting/snap.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Medicaid report CSV export — T-MSIS and CMS-64.
use crate::domain::{MedicaidTmsisExtract, MedicaidCms64Report};
/// Generate T-MSIS CSV from extract rows.
pub fn generate_tmsis_csv(extracts: &[MedicaidTmsisExtract]) -> String {
let mut csv = String::new();
csv.push_str("person_id,enrollment_id,report_month,eligibility_status,coverage_group,");
csv.push_str("eligibility_start_date,eligibility_end_date,income_as_pct_fpl,");
csv.push_str("citizenship_status,disability_indicator,dual_eligible_indicator,");
csv.push_str("dual_eligible_category,managed_care_enrolled,chip_indicator,");
csv.push_str("restricted_benefits_indicator,extracted_at\n");
for e in extracts {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
e.person_id,
e.enrollment_id,
e.report_month,
e.eligibility_status,
e.coverage_group,
e.eligibility_start_date,
e.eligibility_end_date.map(|d| d.to_string()).unwrap_or_default(),
e.income_as_pct_fpl.map(|d| d.to_string()).unwrap_or_default(),
e.citizenship_status,
e.disability_indicator,
e.dual_eligible_indicator,
e.dual_eligible_category.as_deref().unwrap_or(""),
e.managed_care_enrolled,
e.chip_indicator,
e.restricted_benefits_indicator,
e.extracted_at,
));
}
csv
}
/// Generate CMS-64 CSV from report rows.
pub fn generate_cms64_csv(reports: &[MedicaidCms64Report]) -> String {
let mut csv = String::new();
csv.push_str("fiscal_year,fiscal_quarter,expenditure_category,population_group,");
csv.push_str("enrolled_count,member_months,federal_expenditure,state_expenditure,total_expenditure\n");
for r in reports {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{}\n",
r.fiscal_year,
r.fiscal_quarter,
r.expenditure_category,
r.population_group,
r.enrolled_count.map(|v| v.to_string()).unwrap_or_default(),
r.member_months.map(|v| v.to_string()).unwrap_or_default(),
r.federal_expenditure.map(|v| v.to_string()).unwrap_or_default(),
r.state_expenditure.map(|v| v.to_string()).unwrap_or_default(),
r.total_expenditure.map(|v| v.to_string()).unwrap_or_default(),
));
}
csv
}
Export handler (follows export_qc_csv pattern):
/// GET /v1/reporting/medicaid/tmsis/{month}/csv — T-MSIS CSV export.
async fn export_tmsis_csv(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Path(month): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
claims.require_supervisor_or_above()?;
let report_month =
NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d")
.map_err(|_| ApiError::BadRequest(format!("invalid month: {month}")))?;
let extracts = store::list_tmsis_by_month(state.db.inner(), report_month)
.await
.map_err(ApiError::from)?;
let csv = crate::reporting::medicaid_export::generate_tmsis_csv(&extracts);
let filename = format!("tmsis-eligibility-{month}.csv");
Ok((
StatusCode::OK,
[
("content-type".to_owned(), "text/csv".to_owned()),
("content-disposition".to_owned(), format!("attachment; filename=\"{filename}\"")),
],
csv,
))
}
Route: .route("/reporting/medicaid/tmsis/{month}/csv", get(export_tmsis_csv))
Step 9: CMS-64 CSV export endpoint
Files:
-
services/canopy-reporting/src/store.rs(modify — addlist_cms64_by_quarter) -
services/canopy-reporting/src/api/mod.rs(modify — add route + handler)
Store function:
/// List CMS-64 rows for a specific fiscal year and quarter.
pub async fn list_cms64_by_quarter(
pool: &PgPool,
fiscal_year: i32,
fiscal_quarter: i32,
) -> Result<Vec<crate::domain::MedicaidCms64Report>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM medicaid_cms64_reports WHERE fiscal_year = $1 AND fiscal_quarter = $2 ORDER BY population_group",
)
.bind(fiscal_year)
.bind(fiscal_quarter)
.fetch_all(pool)
.await
}
Handler:
/// GET /v1/reporting/medicaid/cms-64/{fy}/{quarter}/csv — CMS-64 CSV export.
async fn export_cms64_csv(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Path((fy, quarter)): Path<(i32, i32)>,
) -> Result<impl IntoResponse, ApiError> {
claims.require_supervisor_or_above()?;
let reports = store::list_cms64_by_quarter(state.db.inner(), fy, quarter)
.await
.map_err(ApiError::from)?;
let csv = crate::reporting::medicaid_export::generate_cms64_csv(&reports);
let filename = format!("cms-64-fy{fy}-q{quarter}.csv");
Ok((
StatusCode::OK,
[
("content-type".to_owned(), "text/csv".to_owned()),
("content-disposition".to_owned(), format!("attachment; filename=\"{filename}\"")),
],
csv,
))
}
Route: .route("/reporting/medicaid/cms-64/{fy}/{quarter}/csv", get(export_cms64_csv))
Step 10: Integration tests
Files:
-
services/canopy-reporting/tests/reporting_test.rs(modify — add 10 test functions)
All tests follow the existing pattern: check infrastructure_available, authenticate as supervisor, call API. Tests that require seeded data use the POST endpoints to generate extracts first, then verify content.
| # | Test function | Verification |
|---|---|---|
1 |
|
POST tmsis for 2026-04-01, then GET tmsis. If any extracts returned, verify every row has non-empty |
2 |
|
POST tmsis, GET list. Verify |
3 |
|
POST tmsis, GET list. For any row with |
4 |
|
POST tmsis, GET list. For rows with |
5 |
|
POST tmsis, GET list. For rows with coverage groups corresponding to ABD COAs (e.g., |
6 |
|
POST tmsis for 3 months (2026-01, 2026-02, 2026-03), then POST CMS-64 for FY2026 Q2. GET CMS-64 list. Verify at least one row has |
7 |
|
GET CMS-64 list. Verify every |
8 |
|
POST CMS-416 for year 2026. GET CMS-416 list. Verify |
9 |
|
POST tmsis for 2026-04-01, then GET |
10 |
|
GET |
Each test follows this template:
#[tokio::test]
async fn tmsis_extract_populates_coverage_group() {
if !canopy_test_lib::infrastructure_available().await {
return;
}
let Some(c) = TestClient::authenticated("http://localhost:8011").await else {
return;
};
if !c.is_healthy().await {
return;
}
// Generate extract
let body = serde_json::json!({ "report_month": "2026-04-01" });
let gen = c.post_json("/v1/reporting/medicaid/tmsis", &body).await;
assert!(gen.status == 201 || gen.status == 200, "tmsis POST: {}", gen.text());
// Fetch extracts
let resp = c.get("/v1/reporting/medicaid/tmsis").await;
resp.assert_status(200);
let data = resp.json::<Vec<serde_json::Value>>();
for row in &data {
let cg = row["coverage_group"].as_str().unwrap_or("");
assert!(!cg.is_empty(), "coverage_group should be non-empty: {row}");
}
}
Files Touched
| File | Change |
|---|---|
|
Add |
|
Add |
|
Add |
|
Extend |
|
New: COA → CMS coverage group mapping (38 arms), |
|
Rewrite: enriched T-MSIS extraction with all field categories |
|
New: CMS-64 enrollment aggregation from T-MSIS extracts, |
|
New: CMS-416 EPSDT child enrollment extraction by age group |
|
New: |
|
Add |
|
Add |
|
Add |
|
Add |
|
Add 10 integration test functions |
|
Document new endpoints, update route count |
|
Entry under |
Verification
-
cargo nextest run -p canopy-medicaid— newlist_determinationsendpoint compiles and existing tests pass -
cargo nextest run -p canopy-reporting --lib— unit tests pass (medicaid_mapping 38-COA coverage, CSV generation) -
cargo xtask dev restart— schema changes pick up (canopy-medicaid new route) -
cargo nextest run -p canopy-reporting— all 10 new integration tests pass -
Verify T-MSIS CSV export at
GET /v1/reporting/medicaid/tmsis/2026-04/csvreturns CSV with correct headers -
Verify CMS-64 POST → GET cycle produces
enrolled_count > 0for at least one population group -
Verify CMS-416 POST generates all 7 age groups
-
Verify no PHI appears in application log output during extraction (grep logs for person names, SSNs, DOBs)
-
Verify canopy-reporting queries canopy-medicaid and canopy-persons via HTTP API only, never direct DB (per ADR-001)
-
cargo clippy --workspace — -D warnings— no new warnings -
cargo xtask test— full test battery passes
Errata
Implementation notes (2026-04-13)
Fixed in hardening pass (2026-04-13):
-
Citizenship status now fetched from canopy-persons via
get_person(). -
CMS-416 age grouping now uses real DOB from canopy-persons via
get_person(). -
income_as_pct_fpl now computed by canopy-reporting via cross-service assembly (person income from canopy-persons + household size + federal FPL). Preserves ADR-002 boundary — canopy-medicaid is not modified.
Remaining (fixable, deferred for scope):
-
income_as_pct_fplcolumn never written. The column exists in the migration and domain struct but the T-MSIS INSERT does not bind it. canopy-medicaid stores this in MAGI determinations; extracting it requires enrichingMedicaidDeterminationSummarywith the field. -
T-MSIS CSV omits
income_as_pct_fplandeligibility_end_date. Both fields exist on the domain struct but are skipped in CSV generation. -
CMS-64 reuses
GenerateTanfQuarterlyRequesttype. Fields are identical (fiscal_year, fiscal_quarter). Should be a sharedQuarterlyReportRequestor a dedicated Medicaid type.
Blocked externally (permanent limitations):
-
Managed care fields are permanently false/None. Georgia’s MCO data lives in an external system not integrated with Canopy.
-
CMS-64 expenditure columns are NULL. Requires MMIS/state accounting integration. Enrollment counts and member months ARE populated from T-MSIS extracts.
-
CMS-416 screening fields are NULL. Screening data lives in clinical systems (immunization registries, provider EMRs) not integrated with Canopy.
-
COA mapping covers all 38 COAs. Codes aligned to
MedicaidCategory::coa_code()output. P4HB subtypes (fp/ipc/rm) all map to "P4HB". Unknown COAs map to "OTH".
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#316 — Close 3 Medicaid T-MSIS / CMS-64 reporting gaps (from Errata)
Documentation Updates
-
.claude/docs/services.md— add 3 new canopy-reporting routes (POST CMS-64, GET TMSIS CSV, GET CMS-64 CSV), update route count to 9 domain routes. Addlist_determinationsto canopy-medicaid route count (now 6 domain routes). Documentmedicaid_mappingmodule. -
.claude/CLAUDE.md— update canopy-reporting row: "Medicaid T-MSIS enriched extraction (all 8 field categories), CMS-64 enrollment aggregation, CMS-416 EPSDT child enrollment, CSV exports". Update canopy-medicaid row: addGET /v1/determinationslist endpoint. -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/medicaid-federal-reporting.adoc— update status table steps to reflect completion