Plan: Medicaid/CHIP Federal Reporting — T-MSIS, CMS-64, CMS-416 (canopy-reporting)

On this page

Status

Step Description Status

1

Upstream prerequisite: add GET /v1/determinations list endpoint to canopy-medicaid

Done (2026-04-13)

2

Extend MedicaidDeterminationSummary in reporting client with enriched fields

Done (2026-04-13)

3

Add get_person client method to ServiceClients for canopy-persons demographic lookup

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 20260409000000 created all three tables (medicaid_tmsis_eligibility_extracts, medicaid_cms64_reports, medicaid_cms416_reports).

  • Domain structs MedicaidTmsisExtract, MedicaidCms64Report, MedicaidCms416Report exist in domain.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 (from assigned_coa) and chip_indicator (from assigned_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 TODO comment and returns only the count of existing rows.

  • ServiceClients::list_medicaid_determinations() calls GET /v1/determinations on 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/determinations list endpoint to canopy-medicaid (upstream prerequisite)

  • Extend MedicaidDeterminationSummary with effective_date, expiration_date, fpl_percentage, date_of_birth

  • Add get_person client 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-eligibility plan

  • 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_enrolled remains false, managed_care_plan_id remains NULL

  • 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

newborn

infant_newborn

false

false

children

pregnant_women

pregnant_women

false

false

adults

parent_caretaker

parent_caretaker_relative

false

false

adults

children_under_19

children

false

false

children

tma

tma_section_1925

false

false

adults

four_months_extended

four_month_extension

false

false

adults

former_foster_care

former_foster_care

false

false

adults

fm_medically_needy

medically_needy_family

false

false

adults

pregnant_medically_needy

medically_needy_pregnant

false

false

adults

refugee

refugee_medical

false

false

adults

foster_care

foster_care_iv_e

false

false

children

adoption

adoption_assistance

false

false

children

chafee

chafee_aging_out

false

false

adults

whm

women_health_medicaid

false

false

adults

pathways

adult_expansion_viii

false

false

adults

p4hb_fp

family_planning_1115

false

false

adults

p4hb_ipc

family_planning_1115

false

false

adults

p4hb_rm

family_planning_1115

false

false

adults

ssi_medicaid

ssi_cash_recipient

true

false

blind_disabled

pickle

pickle_amendment

true

false

blind_disabled

dac

disabled_adult_child

true

false

blind_disabled

disabled_widow

disabled_widow_er

true

false

blind_disabled

widow_60_64

widow_60_64

false

false

elderly

former_ssi_disabled_child

former_ssi_child

true

false

blind_disabled

edwp

employed_disabled

true

false

blind_disabled

now_waiver

hcbs_waiver_now

true

false

blind_disabled

comp_waiver

hcbs_waiver_comp

true

false

blind_disabled

tefra_katie_beckett

tefra_katie_beckett

true

false

children

hospice

hospice_medicaid

true

false

blind_disabled

hospital

hospital_medicaid

true

false

blind_disabled

icwp

icwp_community

true

false

blind_disabled

nursing_home

nursing_facility

true

false

elderly

qdwi

qdwi

true

true

qdwi

blind_disabled

qmb

qmb

false

true

qmb

elderly

slmb

slmb

false

true

slmb

elderly

qi_1

qi

false

true

qi

elderly

amn

aged_medically_needy

false

false

elderly

peachcare

chip_separate

false

false

chip

NOTE
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

under_1

0 to <1 year old

1_2

1 to 2 years old

3_5

3 to 5 years old

6_9

6 to 9 years old

10_14

10 to 14 years old

15_18

15 to 18 years old

19_20

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

children

Under 19 at report quarter end

adults

19-64 at report quarter end

elderly

65+ at report quarter end

blind_disabled

Any age with ABD-track COA (overrides age-based group)

chip

PeachCare CHIP enrollees (any age)

NOTE
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_extracts access restricted to canopy-reporting DB role only

  • PHI fields (name, SSN hash, DOB, address) never in logs — log only person_id and coverage_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>,
}
NOTE
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 — add pub 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:

  1. Calls clients.list_medicaid_determinations() to get all approved determinations

  2. For each determination with status == "approved":

    1. Calls clients.get_person(det.person_id) for citizenship_status and disability_status

    2. Calls clients.get_medicaid_categories(det.id) to find the assigned COA’s fpl_percentage

    3. Calls medicaid_mapping::map_coa(coa_code) for derived fields

    4. Upserts into medicaid_tmsis_eligibility_extracts

  3. 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 — add pub 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 — add pub mod cms416;)

  • services/canopy-reporting/src/api/mod.rs (modify — replace TODO in generate_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 — add pub mod medicaid_export;)

  • services/canopy-reporting/src/store.rs (modify — add list_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 — add list_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

tmsis_extract_populates_coverage_group

POST tmsis for 2026-04-01, then GET tmsis. If any extracts returned, verify every row has non-empty coverage_group that is not "unknown".

2

tmsis_extract_populates_citizenship

POST tmsis, GET list. Verify citizenship_status is not "unknown" on rows where person data was available.

3

tmsis_chip_indicator_matches_peachcare

POST tmsis, GET list. For any row with coverage_group == "chip_separate", verify chip_indicator == true. For rows with coverage_group != "chip_separate", verify chip_indicator == false.

4

tmsis_dual_eligible_set_for_q_track

POST tmsis, GET list. For rows with coverage_group in ["qmb","slmb","qi","qdwi"], verify dual_eligible_indicator == true and dual_eligible_category is non-null.

5

tmsis_disability_set_for_abd_coas

POST tmsis, GET list. For rows with coverage groups corresponding to ABD COAs (e.g., ssi_cash_recipient, pickle_amendment, employed_disabled), verify disability_indicator == true.

6

cms64_post_generates_enrollment_rows

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 expenditure_category == "enrollment" and enrolled_count > 0.

7

cms64_population_groups_are_valid

GET CMS-64 list. Verify every population_group is one of: children, adults, elderly, blind_disabled, chip.

8

cms416_generates_valid_age_groups

POST CMS-416 for year 2026. GET CMS-416 list. Verify age_group values are in ["under_1","1_2","3_5","6_9","10_14","15_18","19_20"].

9

tmsis_csv_export_has_header_and_content_type

POST tmsis for 2026-04-01, then GET /reporting/medicaid/tmsis/2026-04/csv. Verify status 200, content-type is text/csv, body starts with "person_id,".

10

cms64_csv_export_returns_csv_content_type

GET /reporting/medicaid/cms-64/2026/2/csv. Verify status 200, content-type is text/csv, body starts with "fiscal_year,".

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

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

Add list_approved_determinations function

services/canopy-medicaid/src/api/handlers.rs

Add list_determinations handler

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

Add .route("/determinations", get(handlers::list_determinations))

services/canopy-reporting/src/clients/mod.rs

Extend MedicaidDeterminationSummary with effective_date, expiration_date, benefit_type. Add MedicaidCategorySummary, PersonDetail types. Add get_person, get_medicaid_categories methods.

services/canopy-reporting/src/reporting/medicaid_mapping.rs

New: COA → CMS coverage group mapping (38 arms), cms416_age_group, cms64_population_group, default_mapped_fields

services/canopy-reporting/src/reporting/medicaid.rs

Rewrite: enriched T-MSIS extraction with all field categories

services/canopy-reporting/src/reporting/cms64.rs

New: CMS-64 enrollment aggregation from T-MSIS extracts, quarter_date_range helper

services/canopy-reporting/src/reporting/cms416.rs

New: CMS-416 EPSDT child enrollment extraction by age group

services/canopy-reporting/src/reporting/medicaid_export.rs

New: generate_tmsis_csv, generate_cms64_csv functions

services/canopy-reporting/src/reporting/mod.rs

Add pub mod medicaid_mapping;, pub mod cms64;, pub mod cms416;, pub mod medicaid_export;

services/canopy-reporting/src/domain.rs

Add GenerateCms64Request struct

services/canopy-reporting/src/store.rs

Add list_tmsis_by_month, list_cms64_by_quarter functions

services/canopy-reporting/src/api/mod.rs

Add generate_medicaid_cms64, export_tmsis_csv, export_cms64_csv handlers. Add 3 new routes. Update generate_medicaid_cms416 to call cms416::extract_cms416. Add GenerateCms64Request to OpenApi schemas.

services/canopy-reporting/tests/reporting_test.rs

Add 10 integration test functions

.claude/docs/services.md

Document new endpoints, update route count

CHANGELOG.adoc

Entry under == Unreleased

Verification

  1. cargo nextest run -p canopy-medicaid — new list_determinations endpoint compiles and existing tests pass

  2. cargo nextest run -p canopy-reporting --lib — unit tests pass (medicaid_mapping 38-COA coverage, CSV generation)

  3. cargo xtask dev restart — schema changes pick up (canopy-medicaid new route)

  4. cargo nextest run -p canopy-reporting — all 10 new integration tests pass

  5. Verify T-MSIS CSV export at GET /v1/reporting/medicaid/tmsis/2026-04/csv returns CSV with correct headers

  6. Verify CMS-64 POST → GET cycle produces enrolled_count > 0 for at least one population group

  7. Verify CMS-416 POST generates all 7 age groups

  8. Verify no PHI appears in application log output during extraction (grep logs for person names, SSNs, DOBs)

  9. Verify canopy-reporting queries canopy-medicaid and canopy-persons via HTTP API only, never direct DB (per ADR-001)

  10. cargo clippy --workspace — -D warnings — no new warnings

  11. 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_fpl column 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 enriching MedicaidDeterminationSummary with the field.

  • T-MSIS CSV omits income_as_pct_fpl and eligibility_end_date. Both fields exist on the domain struct but are skipped in CSV generation.

  • CMS-64 reuses GenerateTanfQuarterlyRequest type. Fields are identical (fiscal_year, fiscal_quarter). Should be a shared QuarterlyReportRequest or 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. Add list_determinations to canopy-medicaid route count (now 6 domain routes). Document medicaid_mapping module.

  • .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: add GET /v1/determinations list endpoint.

  • CHANGELOG.adoc — entry under == Unreleased

  • docs/modules/ROOT/pages/plans/medicaid-federal-reporting.adoc — update status table steps to reflect completion

Edit this page · default