Plan: CMS-416 EPSDT Pipeline (Issue #380)

On this page

Status

Step Description Status

1

canopy-medicaid schema. New migration services/canopy-medicaid/migrations/20260506000003_create_epsdt_screenings.sql adding epsdt_screenings(id UUID PK, child_person_id UUID NOT NULL, screening_type TEXT NOT NULL, screened_at TIMESTAMPTZ NOT NULL, referred BOOLEAN NOT NULL DEFAULT false, referred_for_treatment BOOLEAN NOT NULL DEFAULT false, provider_id TEXT, ffy TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()) with index on (ffy, screening_type).

Not started

2

canopy-medicaid store + API. New services/canopy-medicaid/src/store/screenings.rs (record, list_by_ffy, aggregate_by_age_bucket). New services/canopy-medicaid/src/api/screenings.rs exposing POST /v1/medicaid/screenings (record), GET /v1/medicaid/screenings/aggregations?ffy=…. Register routes.

Not started

3

canopy-reporting CMS-416 rewrite. Replace lines 269-349 in services/canopy-reporting/src/reporting/medicaid.rs with real aggregation: read screenings from canopy-medicaid, group by age bucket (1, 2, 3-5, 6-9, 10-14, 15-18, 19-20) × screening type. Drop the hardcoded eligible_for_screening at line 332. CSV emits the CMS-416 line items (one row per age bucket × screening category).

Not started

4

Tests + docs. Unit tests on age-bucket boundary cases (1, 2, 3-5, 6-9, 10-14, 15-18, 19-20), zero rows produces zero-CSV, multiple referral paths roll up correctly. CSV format matches the CMS template exactly. Update Service Catalog. CHANGELOG === Changed. Plan archives.

Not started

5

OpenAPI sync. cargo xtask api-docs regenerates snapshots.

Not started

6

Document HEDIS gap. New section in docs/modules/ROOT/pages/services/canopy-reporting.adoc noting that screenings are populated via POST /v1/medicaid/screenings (manual ingest or future HEDIS bridge). Until that bridge lands, the report yields zero rows.

Not started

Issue: #380
Branch: feat/cms-416-epsdt-pipeline
Labels: type::feature, priority::medium, service::reporting, service::medicaid, program::medicaid, federal-partner::cms, workflow::ready

Context

CMS-416 is the annual EPSDT (Early and Periodic Screening, Diagnostic, and Treatment) participation report — children’s preventive care under Medicaid. States must submit by April 1 for the prior federal fiscal year. Today services/canopy-reporting/src/reporting/medicaid.rs:269-349 is a stub: eligible_for_screening is hardcoded at line 332, screening counts come from nowhere, and no screening data exists in any canopy DB.

This plan adds the table, the ingest endpoint, and a real aggregator. The upstream HEDIS bridge (where actual screening events arrive from external pediatric systems) stays out of scope; ingest is via HTTP POST and can be driven manually for UAT or wired to a future external feed.

Code references

  • services/canopy-reporting/src/reporting/medicaid.rs:269-349 — stub region.

  • services/canopy-reporting/src/reporting/medicaid.rs:332 — hardcoded eligible_for_screening.

  • services/canopy-medicaid/migrations/ — directory to extend.

  • ADR-001 — screenings live in canopy-medicaid.

Scope

In scope:

  • epsdt_screenings table + CRUD endpoints in canopy-medicaid.

  • CMS-416 aggregator rewrite in canopy-reporting.

  • Age-bucket boundary tests.

Out of scope:

  • External HEDIS / pediatric-system bridge.

  • CMS-416 amendment / corrections workflow.

  • Periodicity-schedule encoding (when each child should be screened) — that’s downstream of this plan.

  • EPSDT-specific notice generation when a child is overdue — separate.

Dependencies

  • No prerequisite plans on disk.

Design

epsdt_screenings schema:

CREATE TABLE epsdt_screenings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    child_person_id UUID NOT NULL,
    screening_type TEXT NOT NULL,
    screened_at TIMESTAMPTZ NOT NULL,
    referred BOOLEAN NOT NULL DEFAULT false,
    referred_for_treatment BOOLEAN NOT NULL DEFAULT false,
    provider_id TEXT,
    ffy TEXT NOT NULL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX epsdt_screenings_by_ffy
    ON epsdt_screenings (ffy, screening_type);

Age-bucket aggregator (sketch):

fn age_bucket(age: u32) -> Option<&'static str> {
    match age {
        0..=1 => Some("0-1"),
        2 => Some("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,
    }
}

The aggregator joins epsdt_screenings against canopy-persons' birthdate via the existing person lookup (mirrors how T-MSIS already pulls demographic context).

Files Touched

File Change

services/canopy-medicaid/migrations/20260506000003_create_epsdt_screenings.sql

New migration

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

New store module

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

New API module

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

Register routes

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

Replace lines 269-349 with real aggregation

services/canopy-reporting/src/reporting/medicaid.rs (test module)

Age-bucket boundary tests

docs/modules/ROOT/openapi/canopy-medicaid.json

Regenerated

docs/modules/ROOT/openapi/canopy-reporting.json

Regenerated

Service Catalog

Route + table updates

CHANGELOG.adoc

=== Changed entry

Verification

  1. cargo nextest run -p canopy-medicaid -p canopy-reporting --lib — unit tests pass.

  2. cargo xtask api-docs — snapshots regenerate clean.

  3. cargo xtask dev start && cargo nextest run -p canopy-reporting --test cms416_test --run-ignored only — integration test passes.

  4. Manual smoke: POST 5 screenings (2 children, mixed referral), GET aggregation, confirm age buckets line up.

  5. cargo xtask validate — full battery green.

Documentation Updates

  • Service Catalog — canopy-medicaid + canopy-reporting routes + tables

  • CHANGELOG.adoc — entry under == Unreleased / === Changed

  • docs/modules/ROOT/pages/services/canopy-reporting.adoc — CMS-416 path + HEDIS gap

  • docs/modules/ROOT/pages/federal-requirements.adoc — CMS-416 row update

  • Plan archive: move to plans/archive/ post-merge

Edit this page · default