Plan: Medicaid Eligibility Service
On this page
- Status
- Context
- Scope
- Design
- Data Model
- COA Code Table
- COA Evaluation Priority Order (CMD Cascade per PAMMS 2052)
- MAGI Budget Group Composition Rules (per PAMMS 2610)
- MAGI Budgeting Steps (per PAMMS 2669)
- Georgia-Specific FPL Thresholds per COA
- PeachCare for Kids Premium Schedule (per PAMMS 2194)
- Pathways Work Requirement (per PAMMS 2195)
- API Endpoints
- Event Payloads (FTI/HIPAA Scrubbed per ADR-004)
- HIPAA Minimum Necessary Enforcement
- Determination Flow (Complete)
- Rulesets
- MedicaidCategory Enum
- Steps
- Step 1: Database Migration
- Step 2: FTI Store Layer with Audit Logging
- Step 3: FDSH and Clinical Data Store
- Step 4: MAGI Budget Group Composition Module
- Step 5: MAGI Budgeting Module
- Step 6: ABD Medicaid COA Evaluation
- Step 7: Family Medicaid MAGI COA Evaluation
- Step 8: PeachCare for Kids (CHIP) COA Evaluation
- Step 9: Pathways COA Evaluation
- Step 10: Family Non-MAGI COA Evaluation
- Step 11: CMD Cascade Engine
- Step 12: EE15 Hierarchy
- Step 13: Determination Endpoint
- Step 14: HIPAA Enforcement
- Step 15: Event Publishing
- Step 16: Additional API Endpoints
- Step 17: Rules Client
- Step 18: JDM Rulesets
- Step 19: Integration Tests
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Database migration: Medicaid application tables, MAGI household snapshots, income, non-MAGI factors, FDSH results, clinical assessments, eligible categories, CHIP tables, PeachCare premiums, Pathways qualifying activities, determinations |
Done (2026-04-09) |
2 |
FTI store layer with audit logging (IRC section 6103(l)(12)) |
Done (2026-04-09) |
3 |
FDSH data store (Federal Data Services Hub results from canopy-verification) |
Done (2026-04-09) |
4 |
MAGI budget group composition module (tax filer vs. non-filer per PAMMS 2610) |
Done (2026-04-09) |
5 |
MAGI budgeting module (6-step income calculation per PAMMS 2669) |
Done (2026-04-09) |
6 |
COA evaluation: ABD Medicaid COAs (FBR and non-FBR, per PAMMS 2101) |
Done (2026-04-09) |
7 |
COA evaluation: Family Medicaid MAGI COAs (Parent/Caretaker, Children Under 19, Pregnant Women, per PAMMS 2162/2182/2184) |
Done (2026-04-09) |
8 |
COA evaluation: PeachCare for Kids (CHIP) with premium schedule (per PAMMS 2194) |
Done (2026-04-09) |
9 |
COA evaluation: Pathways with qualifying activity requirement (per PAMMS 2195) |
Done (2026-04-09) |
10 |
COA evaluation: Family non-MAGI COAs (Newborn, FM-MN, WHM, P4HB, TMA, 4MEx, per PAMMS 2174/2196/2198/2186/2166/2170) |
Done (2026-04-09) |
11 |
CMD cascade engine: evaluate all COAs before denial/termination (per PAMMS 2052) |
Done (2026-04-09) |
12 |
EE15 hierarchy: most advantageous group assignment across all eligible COAs |
Done (2026-04-09) |
13 |
Determination endpoint: |
Done (2026-04-09) |
14 |
HIPAA minimum necessary enforcement on API responses and events |
Done (2026-04-09) |
15 |
Event publishing (FTI-scrubbed, HIPAA-compliant payloads per ADR-004) |
Done (2026-04-09) |
16 |
Additional API endpoints (GET determinations, explanation, categories, params) |
Done (2026-04-09) |
17 |
Rules client: four Medicaid JDM rulesets |
Done (2026-04-09) |
18 |
JDM rulesets: medicaid-magi.json, medicaid-non-magi.json, medicaid-eligibility-hierarchy.json, chip-eligibility.json |
Done (2026-04-09) |
19 |
Integration tests |
Done (2026-04-09) |
Epic: &31
Branch: feature/medicaid-eligibility
Context
canopy-medicaid is the most complex program service in Canopy. Unlike canopy-snap (single program, single determination track) or canopy-tanf (single program with time limits), Medicaid eligibility determination involves:
-
Continuing Medicaid Determination (CMD) cascade: Georgia PAMMS 2052 mandates that every Medicaid application or termination must be evaluated against all Classes of Assistance (COAs) before denial. This is architecturally unique — no other program service has a cascade requirement.
-
30+ COAs: ABD Medicaid has 15+ COAs (FBR: SSI, Pickle, DAC, DW, Widow(er), Former SSI Disabled Child; non-FBR: EDWP, NOW, COMP, TEFRA, Hospice, Hospital, ICWP, NH, AMN, QDWI, QMB, SLMB, QI-1). Family Medicaid has 15+ COAs (MAGI: Parent/Caretaker, TMA, 4MEx, Pregnant Women, Children Under 19, Pathways, P4HB, Former Foster Care; non-MAGI: Newborn, FM-MN, Pregnant MN, Refugee, Foster Care, Adoption, CHAFEE, WHM). Plus PeachCare for Kids (CHIP).
-
Dual-track budgeting: MAGI COAs use Modified Adjusted Gross Income with tax-filing-based household composition (PAMMS 2610, 2669). Non-MAGI COAs use SSI-style resource tests and income disregards. The same applicant may need both tracks.
-
MAGI household differs from SNAP/TANF: MAGI household = tax filer + spouse + tax dependents (PAMMS 2610). SNAP household = physical household. TANF household = dependent children + caretaker. Each program service builds its own household composition.
-
Three federal data compliance frameworks: FTI (IRC 6103(l)(12)), FDSH (ACA 1413), and HIPAA (clinical data). No other program service has all three.
-
PeachCare premiums: CHIP in Georgia requires sliding-scale premium collection, which no other COA requires (PAMMS 2194).
-
Pathways work requirement: Georgia’s Section 1115 waiver requires 80 hours/month of qualifying activities for adults 19-64 at or below 100% FPL (PAMMS 2195).
This service validates all four ADRs and adds HIPAA compliance as a pattern:
-
ADR-001 (isolation): own database, no cross-program access, HIPAA-scoped data stays in canopy-medicaid
-
ADR-002 (determination contract): returns signed determination, never raw clinical or FTI data
-
ADR-003 (ruleset-as-data): calls canopy-rules with four Medicaid rulesets; no eligibility logic in Rust
-
ADR-004 (data tenancy): FTI audit log, FDSH data isolated, events scrubbed of restricted data
PAMMS Source Traceability
| PAMMS Section | Coverage in This Plan |
|---|---|
|
Application intake, 24-hour registration, 3 prior months, CMD requirement |
|
CMD cascade engine (Step 11), ABD evaluation order, Family evaluation order |
|
ABD COA list, FBR vs non-FBR classification (Step 6) |
|
SSI COA eligibility criteria (Step 6) |
|
Q-Track COA criteria, Medicare supplement (Step 6) |
|
MAGI vs non-MAGI COA classification (Steps 7, 10) |
|
Parent/Caretaker eligibility criteria, MAGI income limit (Step 7) |
|
Transitional Medical Assistance 12-month extension (Step 10) |
|
Four Months Extended Medicaid for spousal support changes (Step 10) |
|
Deemed newborn eligibility, 13-month coverage period (Step 10) |
|
Age-based FPL thresholds, CMD to PeachCare cascade (Step 7) |
|
220% FPL, 12-month postpartum, presumptive eligibility, unborn count (Step 7) |
|
Planning for Healthy Babies, 211% FPL, ages 18-44, family planning services (Step 10) |
|
CHIP eligibility 134-247% FPL, premium schedule, continuous eligibility (Step 8) |
|
Section 1115 waiver, 100% FPL with 5% disregard, 80 hours/month qualifying activities (Step 9) |
|
FM-MN spenddown, SSI resource limits, children and pregnant women (Step 10) |
|
Breast/cervical cancer treatment, 200% FPL, non-MAGI (Step 10) |
|
Tax filer BG composition, non-filer BG composition, unborn count, SSI recipient exclusion (Step 4) |
|
6-step budgeting: self-employment deductions, before-tax deductions, 1040 deductions, 5% FPL disregard, FPL limit comparison (Step 5) |
Scope
In scope:
-
MAGI eligibility pathway: MAGI budget group composition (tax filer vs. non-filer per PAMMS 2610), MAGI income calculation (AGI + tax-exempt interest + foreign earned income + non-taxable Social Security), 5% FPL income disregard per ACA, MAGI budgeting 6-step procedure per PAMMS 2669
-
MAGI COAs: Parent/Caretaker with Children (2162), Pregnant Women (2184), Children Under 19 (2182), Pathways (2195), Planning for Healthy Babies (2186)
-
Non-MAGI eligibility pathway: SSI-style resource test, income disregards, medically needy spenddown
-
ABD COAs: FBR COAs (SSI, Pickle, DAC, DW, Widow(er), Former SSI Disabled Child), non-FBR COAs (EDWP, NOW, COMP, TEFRA, Hospice, Hospital, ICWP, NH, AMN, QDWI, Q-Track: QMB, SLMB, QI-1)
-
Family non-MAGI COAs: Newborn (2174), Family Medicaid Medically Needy (2196), Women’s Health Medicaid (2198), TMA (2166), 4MEx (2170)
-
CMD cascade engine: evaluate all COAs per PAMMS 2052 before denial
-
EE15 eligibility hierarchy: most advantageous group assignment when applicant qualifies for multiple COAs
-
PeachCare for Kids (CHIP): 134-247% FPL, sliding-scale premium schedule, CHIPRA mandatory Medicaid-first screening
-
Pathways: Section 1115 waiver, 80 hours/month qualifying activities, cost-effective ESI enrollment (HIPP)
-
FTI data store with audit logging (IRC section 6103(l)(12))
-
FDSH data store (income, citizenship, incarceration, SSN, MEC verification results)
-
HIPAA minimum necessary enforcement on API responses
-
JWS-signed determination (ADR-002)
-
FTI-scrubbed, HIPAA-compliant event payloads (ADR-004)
-
Four Medicaid rulesets:
medicaid-magi.json,medicaid-non-magi.json,medicaid-eligibility-hierarchy.json,chip-eligibility.json -
Georgia FPL thresholds per COA category loaded from
jurisdiction.toml(never hardcoded per ADR-006)
Out of scope:
-
FDSH connection management (canopy-verification responsibility)
-
Managed care enrollment, CMO assignment (post-eligibility workflow)
-
Prior authorization (clinical workflow, not eligibility)
-
T-MSIS reporting (canopy-reporting responsibility)
-
Medicaid estate recovery (post-eligibility)
-
SSI determination itself (SSA responsibility; canopy-medicaid consumes SSI status)
-
Presumptive eligibility provider portal (future plan; PE decisions are temporary and precede full determination)
-
Express Lane Eligibility (ELE) (future plan per PAMMS 2069)
-
Retroactive Medicaid 3 prior months (PAMMS 2053; deferred to a dedicated plan)
Design
Data Model
-- services/canopy-medicaid/migrations/20260407000000_create_medicaid_tables.sql
-- Medicaid applications received for determination.
-- One row per application submitted through canopy-eligibility.
CREATE TABLE medicaid_applications (
id UUID PRIMARY KEY,
application_id UUID NOT NULL, -- reference to canopy-applications
household_id UUID NOT NULL, -- reference to canopy-persons
applicant_person_id UUID NOT NULL, -- reference to canopy-persons
pathway TEXT NOT NULL, -- magi, non_magi, both, chip
status TEXT NOT NULL DEFAULT 'pending',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
determined_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- MAGI household composition snapshot per PAMMS 2610.
-- MAGI household rules differ from SNAP household (physical) and TANF household (deprivation).
-- MAGI uses tax filing relationships: tax filer + spouse + tax dependents.
CREATE TABLE magi_household_snapshots (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
household_id UUID NOT NULL,
person_id UUID NOT NULL, -- the individual whose BG is being determined
is_tax_filer BOOLEAN NOT NULL, -- true = tax filer rules; false = non-filer rules
tax_filer_person_id UUID, -- the tax filer (may be self, spouse, or parent)
filing_status TEXT, -- single, married_filing_jointly, head_of_household
magi_household_size INTEGER NOT NULL, -- includes unborn children per PAMMS 2610
spouse_person_id UUID, -- spouse living in the home (included even if not filing jointly)
dependents_person_ids UUID[] NOT NULL DEFAULT '{}',
pregnant_member_ids UUID[] NOT NULL DEFAULT '{}',
unborn_count INTEGER NOT NULL DEFAULT 0,
ssi_recipient_ids UUID[] NOT NULL DEFAULT '{}', -- SSI recipients in BG (income excluded per 2610)
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- MAGI income records for budget group members.
-- Per PAMMS 2669: income of all BG members required to file a tax return is counted.
-- Exception: child dependent income below IRS dependent exemption amount is excluded.
CREATE TABLE magi_income (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
income_type TEXT NOT NULL, -- wages, self_employment, tips, commissions, rental,
-- interest, dividends, social_security, ssi, pension,
-- alimony, child_support, unemployment, other
gross_amount NUMERIC(10,2) NOT NULL,
frequency TEXT NOT NULL, -- monthly, biweekly, weekly, annual
monthly_amount NUMERIC(10,2) NOT NULL, -- normalized to monthly
is_countable BOOLEAN NOT NULL DEFAULT true,
is_earned BOOLEAN NOT NULL DEFAULT false,
-- MAGI-specific adjustments (1040 deductions per PAMMS 2669 Step 3)
magi_adjustment_type TEXT, -- student_loan_interest, ira_deduction,
-- educator_expenses, health_savings, alimony_paid,
-- self_employment_tax, self_employment_health_insurance
magi_adjustment_amount NUMERIC(10,2) DEFAULT 0,
-- Before-tax deductions (per PAMMS 2669 Step 2)
before_tax_deduction_type TEXT, -- 401k, 403b, health_insurance_premium, fsa, hsa, fers, csrs
before_tax_deduction_amount NUMERIC(10,2) DEFAULT 0,
-- Self-employment cost of doing business (per PAMMS 2669 Step 1)
self_employment_expenses NUMERIC(10,2) DEFAULT 0,
-- Child dependent income exclusion (below IRS dependent exemption per PAMMS 2610)
is_child_dependent_excluded BOOLEAN NOT NULL DEFAULT false,
child_dependent_exemption_amount NUMERIC(10,2),
source TEXT NOT NULL, -- self_report, fti, fdsh, employer, collateral
verification_status TEXT NOT NULL DEFAULT 'unverified',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Non-MAGI eligibility factors for ABD COAs.
-- Used by FBR COAs (SSI income-based) and non-FBR COAs (resource test + income disregards).
CREATE TABLE non_magi_factors (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
category TEXT NOT NULL, -- ssi, pickle, dac, disabled_widow, widow_60_64,
-- former_ssi_disabled_child, edwp, now_waiver,
-- comp_waiver, tefra_katie_beckett, hospice, hospital,
-- icwp, nursing_home, amn, qdwi, qmb, slmb, qi_1
age INTEGER,
age_verified BOOLEAN DEFAULT false,
disability_status TEXT, -- ssi_recipient, ssdi_recipient, state_determined,
-- not_disabled, blind
disability_determination_date DATE,
medicare_enrolled BOOLEAN DEFAULT false,
medicare_parts TEXT, -- part_a, part_b, part_a_and_b
resource_test_passed BOOLEAN,
countable_resources NUMERIC(10,2),
resource_limit NUMERIC(10,2),
countable_income NUMERIC(10,2),
income_limit NUMERIC(10,2),
federal_benefit_rate NUMERIC(10,2), -- for FBR COAs
medical_spend_down_amount NUMERIC(10,2), -- for medically needy and AMN
level_of_care_met BOOLEAN, -- for institutional COAs (NH, EDWP, NOW, COMP, ICWP)
length_of_stay_met BOOLEAN, -- for institutional COAs
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- FTI data received from IRS (IRC section 6103(l)(12) for Medicaid/CHIP).
-- All access MUST be wrapped with FTI audit logging.
-- MAGI calculation uses: AGI + tax-exempt interest + foreign earned income + non-taxable SS.
CREATE TABLE fti_tax_data (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
tax_year INTEGER NOT NULL,
filing_status TEXT, -- single, married_filing_jointly, married_filing_separately,
-- head_of_household, qualifying_widow
adjusted_gross_income NUMERIC(10,2),
wages_salaries_tips NUMERIC(10,2),
self_employment_income NUMERIC(10,2),
social_security_benefits NUMERIC(10,2),
taxable_social_security NUMERIC(10,2), -- portion included in AGI
tax_exempt_interest NUMERIC(10,2),
foreign_earned_income NUMERIC(10,2),
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- FDSH (Federal Data Services Hub) verification results.
-- Raw data from canopy-verification, stored in canopy-medicaid per ADR-004.
CREATE TABLE fdsh_results (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
hub_service TEXT NOT NULL, -- verify_ssa, verify_dhs, verify_irs, verify_cms
verification_type TEXT NOT NULL, -- income, citizenship, incarceration, ssn, mec,
-- immigration_status, quarterly_wage
result_status TEXT NOT NULL, -- verified, not_verified, inconsistency, unavailable
result_data JSONB NOT NULL DEFAULT '{}', -- structured result from hub
requested_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- HIPAA-scoped clinical data.
-- Minimum necessary: only the eligibility-relevant outcome is stored.
-- NO clinical notes, NO diagnosis descriptions, NO treatment plans.
CREATE TABLE clinical_assessments (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
assessment_type TEXT NOT NULL, -- disability, medical_necessity, functional_limitation,
-- blindness, nursing_facility_level_of_care
assessor TEXT NOT NULL, -- provider name or agency
assessment_date DATE NOT NULL,
result TEXT NOT NULL, -- meets_criteria, does_not_meet, pending_review
-- NO clinical details stored -- only the eligibility-relevant outcome
-- per HIPAA minimum necessary standard
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- All Medicaid COAs evaluated for each application.
-- Multiple COAs may apply; CMD cascade evaluates all; EE15 hierarchy selects the best.
-- Per PAMMS 2052: "Eligibility must be reviewed under all Medicaid COAs before denying."
CREATE TABLE medicaid_eligible_categories (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL, -- individual being evaluated (each AU member gets separate rows)
coa_code TEXT NOT NULL, -- See COA code table below
coa_track TEXT NOT NULL, -- magi, non_magi, chip
eligible BOOLEAN NOT NULL,
fpl_percentage NUMERIC(5,1), -- applicant's income as % of FPL for this COA
fpl_threshold NUMERIC(5,1), -- threshold for this COA (e.g., 220% for pregnant women)
income_amount NUMERIC(10,2), -- countable income used for this COA evaluation
resource_amount NUMERIC(10,2), -- countable resources (non-MAGI only)
spend_down_amount NUMERIC(10,2), -- for medically needy COAs
denial_reason TEXT, -- if not eligible: income_over_limit, resource_over_limit,
-- age_ineligible, state_not_expanded, etc.
evaluation_order INTEGER NOT NULL, -- position in CMD cascade (lower = evaluated first)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- CHIP-specific tables (children above Medicaid, below CHIP limit).
-- Per PAMMS 2194 and CHIPRA mandatory Medicaid-first screening.
CREATE TABLE chip_applications (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
child_person_id UUID NOT NULL,
household_id UUID NOT NULL,
chip_type TEXT NOT NULL, -- standalone, medicaid_expansion
premium_tier TEXT, -- fpl_134_158, fpl_159_170, fpl_171_190, etc.
monthly_premium NUMERIC(10,2), -- per child premium amount
family_cap_premium NUMERIC(10,2), -- family maximum premium
premium_exempt BOOLEAN NOT NULL DEFAULT false, -- under 6, foster care, AI/AN
premium_exemption_reason TEXT, -- under_6, foster_care, ai_an
status TEXT NOT NULL DEFAULT 'pending',
enrollment_effective_date DATE, -- first day of month application complete + premium paid
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- PeachCare premium schedule per PAMMS 2194.
-- Loaded from jurisdiction.toml, stored for audit trail.
CREATE TABLE peachcare_premium_schedule (
id UUID PRIMARY KEY,
fpl_lower_bound NUMERIC(5,1) NOT NULL, -- e.g., 134.0
fpl_upper_bound NUMERIC(5,1) NOT NULL, -- e.g., 158.0
one_child_premium NUMERIC(10,2) NOT NULL,
family_cap_premium NUMERIC(10,2) NOT NULL,
effective_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Pathways qualifying activities per PAMMS 2195.
-- Must demonstrate 80 hours/month of qualifying activities.
CREATE TABLE pathways_qualifying_activities (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
activity_type TEXT NOT NULL, -- unsubsidized_employment, subsidized_private,
-- subsidized_public, on_the_job_training,
-- job_readiness, community_service,
-- vocational_training, higher_education,
-- gvra_vocational_rehab, snap_abawd_compliance,
-- parent_child_under_6
hours_per_month NUMERIC(5,1) NOT NULL,
verification_status TEXT NOT NULL DEFAULT 'self_attested',
verification_document TEXT,
effective_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Pathways HIPP (Health Insurance Premium Payment) program per PAMMS 2195.
-- Tracks ESI cost-effectiveness determination.
CREATE TABLE pathways_hipp_referrals (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
has_esi_access BOOLEAN NOT NULL,
cost_effective BOOLEAN, -- null = pending determination
esi_monthly_premium NUMERIC(10,2),
medicaid_capitation_rate NUMERIC(10,2),
determination_date DATE,
status TEXT NOT NULL DEFAULT 'pending', -- pending, enrolled, not_cost_effective
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Medicaid determinations produced by this service.
-- Signed per ADR-002, append-only, one per application.
CREATE TABLE medicaid_determinations (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
household_id UUID NOT NULL,
person_id UUID NOT NULL, -- individual this determination covers
status TEXT NOT NULL, -- approved, denied, pending_spenddown, pending_premium
assigned_coa TEXT, -- EE15 most advantageous COA code
assigned_coa_track TEXT, -- magi, non_magi, chip
benefit_type TEXT, -- full_medicaid, chip_standalone, qmb_supplement,
-- family_planning_only, emergency_only
benefit_amount NUMERIC(10,2), -- null for most Medicaid (no cash benefit); CHIP premium
benefit_unit TEXT DEFAULT 'monthly_usd',
effective_date DATE,
expiration_date DATE,
renewal_date DATE,
continuous_eligibility_end DATE, -- for 12-month CE periods (children, postpartum)
basis TEXT, -- human-readable narrative
denial_reason TEXT,
denial_reason_codes TEXT[], -- structured codes: income_over_limit, resource_over_limit, etc.
fmap_rate NUMERIC(5,2), -- federal matching rate for the assigned COA
program_service_version TEXT NOT NULL,
determined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
signature TEXT NOT NULL, -- JWS signature per ADR-002
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- CMD cascade audit trail.
-- Records the full evaluation path for traceability.
CREATE TABLE cmd_cascade_log (
id UUID PRIMARY KEY,
medicaid_application_id UUID NOT NULL REFERENCES medicaid_applications(id),
person_id UUID NOT NULL,
evaluation_track TEXT NOT NULL, -- abd, family
coa_code TEXT NOT NULL,
evaluation_order INTEGER NOT NULL,
eligible BOOLEAN NOT NULL,
denial_reason TEXT,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Indexes
CREATE INDEX idx_medicaid_applications_application ON medicaid_applications(application_id);
CREATE INDEX idx_medicaid_applications_household ON medicaid_applications(household_id);
CREATE INDEX idx_medicaid_applications_status ON medicaid_applications(status);
CREATE INDEX idx_magi_household_application ON magi_household_snapshots(medicaid_application_id);
CREATE INDEX idx_magi_household_person ON magi_household_snapshots(person_id);
CREATE INDEX idx_magi_income_application ON magi_income(medicaid_application_id);
CREATE INDEX idx_magi_income_person ON magi_income(person_id);
CREATE INDEX idx_non_magi_factors_application ON non_magi_factors(medicaid_application_id);
CREATE INDEX idx_non_magi_factors_person ON non_magi_factors(person_id);
CREATE INDEX idx_fti_tax_data_application ON fti_tax_data(medicaid_application_id);
CREATE INDEX idx_fti_tax_data_person ON fti_tax_data(person_id);
CREATE INDEX idx_fdsh_results_application ON fdsh_results(medicaid_application_id);
CREATE INDEX idx_fdsh_results_person ON fdsh_results(person_id);
CREATE INDEX idx_clinical_assessments_application ON clinical_assessments(medicaid_application_id);
CREATE INDEX idx_medicaid_categories_application ON medicaid_eligible_categories(medicaid_application_id);
CREATE INDEX idx_medicaid_categories_person ON medicaid_eligible_categories(person_id);
CREATE INDEX idx_medicaid_categories_coa ON medicaid_eligible_categories(coa_code);
CREATE INDEX idx_chip_applications_medicaid ON chip_applications(medicaid_application_id);
CREATE INDEX idx_chip_applications_child ON chip_applications(child_person_id);
CREATE INDEX idx_pathways_activities_application ON pathways_qualifying_activities(medicaid_application_id);
CREATE INDEX idx_pathways_hipp_application ON pathways_hipp_referrals(medicaid_application_id);
CREATE INDEX idx_medicaid_determinations_application ON medicaid_determinations(medicaid_application_id);
CREATE INDEX idx_medicaid_determinations_person ON medicaid_determinations(person_id);
CREATE INDEX idx_medicaid_determinations_status ON medicaid_determinations(status);
CREATE INDEX idx_cmd_cascade_application ON cmd_cascade_log(medicaid_application_id);
CREATE INDEX idx_cmd_cascade_person ON cmd_cascade_log(person_id);
COA Code Table
All COA codes used in medicaid_eligible_categories.coa_code and medicaid_determinations.assigned_coa:
| COA Code | Name | Track | PAMMS Section |
|---|---|---|---|
|
Parent/Caretaker with Children |
MAGI |
2162 |
|
Pregnant Women |
MAGI |
2184 |
|
Children Under 19 Years of Age |
MAGI |
2182 |
|
Georgia Pathways to Coverage |
MAGI |
2195 |
|
Planning for Healthy Babies — Family Planning (181) |
MAGI |
2186 |
|
Planning for Healthy Babies — Inter-Pregnancy Care (180) |
MAGI |
2186 |
|
Planning for Healthy Babies — Resource Mothers (182/183) |
MAGI |
2186 |
|
Transitional Medical Assistance |
MAGI |
2166 |
|
Four Months Extended Medicaid (4MEx) |
MAGI |
2170 |
|
Former Foster Care Medicaid |
MAGI |
2160 |
|
Newborn Medicaid |
Non-MAGI |
2174 |
|
Family Medicaid Medically Needy |
Non-MAGI |
2196 |
|
Pregnant Medically Needy |
Non-MAGI |
2196 |
|
Refugee Medical Assistance |
Non-MAGI |
2160 |
|
Foster Care Medicaid |
Non-MAGI |
2160 |
|
Adoption Assistance Medicaid |
Non-MAGI |
2160 |
|
Chafee Independence Program |
Non-MAGI |
2160 |
|
Women’s Health Medicaid |
Non-MAGI |
2198 |
|
SSI Medicaid |
ABD-FBR |
2111 |
|
Pickle (PL 94-566) |
ABD-FBR |
2101 |
|
Disabled Adult Child (PL 99-643) |
ABD-FBR |
2101 |
|
Disabled Widow(er) Age 50-64 |
ABD-FBR |
2101 |
|
Widow(er) 60-64 (PL 100-203) |
ABD-FBR |
2101 |
|
Former SSI Disabled Child |
ABD-FBR |
2101 |
|
Elderly and Disabled Waiver Program |
ABD-Non-FBR |
2101 |
|
New Options Waiver |
ABD-Non-FBR |
2101 |
|
Comprehensive Supports Waiver Program |
ABD-Non-FBR |
2101 |
|
TEFRA/Katie Beckett |
ABD-Non-FBR |
2101 |
|
Hospice |
ABD-Non-FBR |
2101 |
|
Hospital |
ABD-Non-FBR |
2101 |
|
Independent Care Waiver Program |
ABD-Non-FBR |
2101 |
|
Nursing Home |
ABD-Non-FBR |
2101 |
|
ABD Medically Needy |
ABD-Non-FBR |
2101 |
|
Qualified Disabled Working Individuals |
ABD-Non-FBR |
2101 |
|
Qualified Medicare Beneficiary |
ABD-Non-FBR (Q-Track) |
2143 |
|
Specified Low-Income Medicare Beneficiary |
ABD-Non-FBR (Q-Track) |
2101 |
|
Qualifying Individual 1 |
ABD-Non-FBR (Q-Track) |
2101 |
|
PeachCare for Kids (CHIP) |
CHIP |
2194 |
COA Evaluation Priority Order (CMD Cascade per PAMMS 2052)
The CMD cascade requires evaluating COAs in a defined priority order. Per PAMMS 2052, ABD and Family tracks are evaluated independently, then merged.
ABD Medicaid Evaluation Order
Per PAMMS 2052: "For ABD Medicaid, consider eligibility under all COAs in the following order."
| Order | COA Group | COAs |
|---|---|---|
1 |
FBR COAs |
|
2 |
LA-D/Medicaid CAP COAs |
|
3 |
Q-Track COAs |
|
4 |
AMN |
|
Family Medicaid Evaluation Order
Per PAMMS 2052: "For Family Medicaid, consider eligibility in the following order."
| Order | COA |
|---|---|
1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
MAGI Budget Group Composition Rules (per PAMMS 2610)
Tax Filer Budget Group
Per PAMMS 2610, the BG for tax filers consists of:
-
The tax filer
-
All persons whom the tax filer expects to claim as a tax dependent (who are not claimed by another tax filer)
-
The tax filer’s spouse living in the home, even if not filing jointly
-
Any unborn child of a pregnant individual included in the BG
Non-Tax Filer Budget Group
Per PAMMS 2610, the BG for non-tax filers consists of the following individuals living in the home:
-
The individual
-
The individual’s spouse
-
The individual’s biological, adopted, and stepchildren under age 19
-
For any child under age 19: that child’s biological, adopted, and stepparents, and biological, adopted, half, and stepsiblings under age 19
-
Any unborn child of a pregnant individual included in the BG
Special Rules
-
SSI recipients: Included in the BG for MAGI COAs, but their income is NOT counted in the budget (PAMMS 2610 note).
-
Child dependent income exclusion: Do not include taxable income of a BG child whose total taxable amount is below the allowable IRS dependent exemption amount, regardless of whether the child actually files (PAMMS 2610 exception). The exemption amount is updated yearly by IRS (e.g., $14,600 earned / $1,300 unearned for tax year 2024). Stored in
jurisdiction.tomlunder[medicaid.irs_dependent_exemption]. -
Unborn children: Pregnant woman’s BG size is increased by the number of expected fetuses per client statement. No medical verification required for fetus count (PAMMS 2610, 2184).
-
AU exclusions per PAMMS 2610 Step 4: Exclude from AU but include in BG:
-
Adult who fails to cooperate with DCSS or TPL
-
Individual who does not meet citizenship or qualified immigrant status
-
Adult who fails to cooperate with enumeration requirement
-
/// Build MAGI budget group per PAMMS 2610.
///
/// This is DATA ASSEMBLY, not eligibility logic.
/// The actual income limit comparison happens in the rules engine.
pub fn build_magi_budget_group(
person_id: Uuid,
is_tax_filer: bool,
tax_filer_id: Option<Uuid>,
spouse_id: Option<Uuid>,
dependents: Vec<Uuid>,
children_under_19: Vec<Uuid>,
parents_in_home: Vec<Uuid>,
siblings_under_19: Vec<Uuid>,
pregnant_member_ids: Vec<Uuid>,
expected_children_per_member: &std::collections::HashMap<Uuid, i32>,
ssi_recipient_ids: Vec<Uuid>,
) -> MagiBudgetGroup {
let mut bg_members: Vec<Uuid> = Vec::new();
if is_tax_filer {
// Tax filer rules (PAMMS 2610 Step 2)
bg_members.push(person_id);
bg_members.extend(&dependents);
if let Some(sid) = spouse_id {
if !bg_members.contains(&sid) {
bg_members.push(sid);
}
}
} else {
// Non-tax filer rules (PAMMS 2610 Step 3)
bg_members.push(person_id);
if let Some(sid) = spouse_id {
bg_members.push(sid);
}
bg_members.extend(&children_under_19);
bg_members.extend(&parents_in_home);
bg_members.extend(&siblings_under_19);
}
// Unborn count
let unborn_count: i32 = pregnant_member_ids.iter()
.filter(|id| bg_members.contains(id))
.map(|id| expected_children_per_member.get(id).copied().unwrap_or(1))
.sum();
bg_members.dedup();
let household_size = bg_members.len() as i32 + unborn_count;
MagiBudgetGroup {
person_id,
is_tax_filer,
tax_filer_id,
spouse_id,
bg_members,
household_size,
pregnant_member_ids: pregnant_member_ids.into_iter()
.filter(|id| bg_members.contains(id) || *id == person_id)
.collect(),
unborn_count,
ssi_recipient_ids,
}
}
pub struct MagiBudgetGroup {
pub person_id: Uuid,
pub is_tax_filer: bool,
pub tax_filer_id: Option<Uuid>,
pub spouse_id: Option<Uuid>,
pub bg_members: Vec<Uuid>,
pub household_size: i32,
pub pregnant_member_ids: Vec<Uuid>,
pub unborn_count: i32,
pub ssi_recipient_ids: Vec<Uuid>,
}
MAGI Budgeting Steps (per PAMMS 2669)
PAMMS 2669 specifies a 6-step budgeting procedure for all MAGI COAs:
MAGI Budgeting (PAMMS 2669):
Step 1: For self-employed or farming/fishing individuals,
deduct all allowable IRS deductions (cost of doing business).
→ magi_income.self_employment_expenses
Step 2: Deduct any before-tax deductions of taxable income.
→ magi_income.before_tax_deduction_amount
(401k, 403b, health insurance premiums, FSA, HSA, FERS, CSRS)
Step 3: Deduct any 1040 deductions of taxable income.
→ magi_income.magi_adjustment_amount
(student loan interest, IRA deduction, educator expenses,
health savings account, alimony paid pre-2019, SE tax, SE health insurance)
Step 4: Subtract 5% of the 100% FPL for the budget group size
from the net taxable income.
→ This is the MAGI 5% FPL disregard per ACA.
Amount varies by BG size (loaded from jurisdiction.toml).
Step 5: Select the appropriate income limit for the COA.
→ FPL threshold loaded from jurisdiction.toml per COA and age.
Step 6: Compare net taxable income to the appropriate income limit.
→ If net income ≤ limit: eligible.
→ If net income > limit: ineligible, cascade to next COA via CMD.
/// MAGI budgeting per PAMMS 2669.
/// This is DATA AGGREGATION — the rules engine performs the comparison.
pub fn assemble_magi_budget(
income_records: &[MagiIncome],
fti_data: Option<&[FtiTaxData]>,
ssi_recipient_ids: &[Uuid],
fpl_5_percent_disregard: Decimal,
) -> MagiBudgetInput {
// Exclude SSI recipient income (PAMMS 2610)
let countable_income: Vec<&MagiIncome> = income_records.iter()
.filter(|i| i.is_countable)
.filter(|i| !ssi_recipient_ids.contains(&i.person_id))
.filter(|i| !i.is_child_dependent_excluded)
.collect();
// Step 1: Self-employment cost of business
let self_employment_deductions: Decimal = countable_income.iter()
.map(|i| i.self_employment_expenses.unwrap_or_default())
.sum();
// Step 2: Before-tax deductions
let before_tax_deductions: Decimal = countable_income.iter()
.map(|i| i.before_tax_deduction_amount.unwrap_or_default())
.sum();
// Step 3: 1040 deductions
let magi_1040_deductions: Decimal = countable_income.iter()
.map(|i| i.magi_adjustment_amount.unwrap_or_default())
.sum();
// Gross taxable income
let gross_taxable: Decimal = countable_income.iter()
.map(|i| i.monthly_amount)
.sum();
// If FTI-verified MAGI is available, use it instead of self-reported
let magi_from_fti = fti_data.and_then(|fti| fti.first()).map(|fti| {
let agi = fti.adjusted_gross_income.unwrap_or_default();
let tax_exempt = fti.tax_exempt_interest.unwrap_or_default();
let foreign = fti.foreign_earned_income.unwrap_or_default();
let ss_total = fti.social_security_benefits.unwrap_or_default();
let taxable_ss = fti.taxable_social_security.unwrap_or_default();
let nontaxable_ss = ss_total - taxable_ss;
// MAGI = AGI + tax-exempt interest + foreign earned income + non-taxable SS
(agi + tax_exempt + foreign + nontaxable_ss) / Decimal::from(12) // annualize to monthly
});
let net_taxable = magi_from_fti.unwrap_or_else(|| {
gross_taxable - self_employment_deductions - before_tax_deductions - magi_1040_deductions
});
MagiBudgetInput {
gross_taxable_monthly: gross_taxable,
self_employment_deductions,
before_tax_deductions,
magi_1040_deductions,
net_taxable_monthly: net_taxable,
fpl_5_percent_disregard,
// Step 4 applied by rules engine: net_taxable - fpl_5_percent_disregard
fti_verified: magi_from_fti.is_some(),
}
}
pub struct MagiBudgetInput {
pub gross_taxable_monthly: Decimal,
pub self_employment_deductions: Decimal,
pub before_tax_deductions: Decimal,
pub magi_1040_deductions: Decimal,
pub net_taxable_monthly: Decimal,
pub fpl_5_percent_disregard: Decimal,
pub fti_verified: bool,
}
Georgia-Specific FPL Thresholds per COA
All thresholds loaded from jurisdiction.toml under [medicaid.fpl_thresholds].
Never hardcoded in Rust per ADR-006 and project conventions.
| COA | FPL % | Source (PAMMS) |
|---|---|---|
Parent/Caretaker with Children |
State-set (Georgia uses a percentage well below 100% FPL; loaded from jurisdiction.toml) |
2162 |
Pregnant Women (+ infants born to Medicaid-eligible mothers) |
220% |
2184, 2669 |
Children birth through age 1 (not Newborn-eligible) |
205% |
2669 |
Children age 1 through age 5 |
149% |
2669 |
Children age 6 through age 18 |
133% |
2669 |
PeachCare for Kids (CHIP) |
247% |
2194, 2669 |
Planning for Healthy Babies (P4HB) |
211% |
2186, 2669 |
Pathways (Section 1115 waiver) |
100% (95% after 5% disregard) |
2195 |
Women’s Health Medicaid |
200% |
2198 |
TMA (second 6 months) |
205% |
2166 |
QMB |
100% |
2143 |
SLMB |
120% |
2101 |
QI-1 |
135% |
2101 |
QDWI |
200% |
2101 |
jurisdiction.toml under [medicaid.federal_benefit_rate].
PeachCare for Kids Premium Schedule (per PAMMS 2194)
Premium schedule stored in peachcare_premium_schedule table, loaded from jurisdiction.toml:
| FPL Range | One Child | Family Cap |
|---|---|---|
134% - 158% |
$11.00 |
$16.00 |
159% - 170% |
$22.00 |
$44.00 |
171% - 190% |
$24.00 |
$49.00 |
191% - 210% |
$29.00 |
$58.00 |
211% - 231% |
$32.00 |
$64.00 |
232% - 247% |
$36.00 |
$72.00 |
Exemptions (no premium or copayment):
-
Children under age 6
-
Children in Foster Care
-
American Indians and Alaskan Natives (AI/AN)
Per PAMMS 2194: "PCK health benefits will not start until after the initial premium payment is received." 45-day initial premium payment period for new applicants; 30-day for renewals.
Per PAMMS 2194: "PCK enrollees will no longer lose coverage for non-payment of premiums after the initial premium payment is received for each new enrollment period…due to continuous eligibility restrictions."
/// Determine PeachCare premium based on FPL percentage and exemption status.
/// Premium schedule loaded from jurisdiction.toml, not hardcoded.
pub fn determine_peachcare_premium(
fpl_percentage: Decimal,
child_age: i32,
is_foster_care: bool,
is_ai_an: bool,
premium_schedule: &[PeachcarePremiumTier],
) -> PeachcarePremiumResult {
// Exemption check per PAMMS 2194
if child_age < 6 || is_foster_care || is_ai_an {
return PeachcarePremiumResult {
exempt: true,
exemption_reason: Some(if child_age < 6 {
"under_6".to_string()
} else if is_foster_care {
"foster_care".to_string()
} else {
"ai_an".to_string()
}),
one_child_premium: Decimal::ZERO,
family_cap_premium: Decimal::ZERO,
tier: None,
};
}
// Find matching tier from jurisdiction.toml schedule
let tier = premium_schedule.iter().find(|t| {
fpl_percentage >= t.fpl_lower_bound && fpl_percentage <= t.fpl_upper_bound
});
match tier {
Some(t) => PeachcarePremiumResult {
exempt: false,
exemption_reason: None,
one_child_premium: t.one_child_premium,
family_cap_premium: t.family_cap_premium,
tier: Some(t.tier_name.clone()),
},
None => PeachcarePremiumResult {
exempt: false,
exemption_reason: None,
one_child_premium: Decimal::ZERO,
family_cap_premium: Decimal::ZERO,
tier: None,
},
}
}
Pathways Work Requirement (per PAMMS 2195)
Per PAMMS 2195, Pathways eligibility requires 80 hours/month of qualifying activities:
-
Unsubsidized employment (including self-employment)
-
Subsidized private sector employment
-
Subsidized public sector employment
-
On-the-job training
-
Job Readiness (limited; see PAMMS 2256)
-
Community Service (limited; see PAMMS 2256)
-
Vocational educational training
-
Enrollment in institution of higher education
-
GVRA Vocational Rehabilitation program
-
SNAP ABAWD compliance via work activity
-
Parent/legal guardian of child under age 6
Activities may be combined to reach the 80-hour threshold.
Financial eligibility: MAGI income at or below 100% FPL (which is 95% FPL after the 5% disregard).
Non-financial: ages 19-64, not eligible for any other Medicaid COA (ABD or Family).
Coverage: effective first day of month of application (as of 10/1/2025, no prospective eligibility). No retroactive months, no Hospital Presumptive Eligibility, no Emergency Medical Assistance.
/// Validate Pathways qualifying activities per PAMMS 2195.
/// Returns total hours and whether the 80-hour threshold is met.
pub fn validate_pathways_activities(
activities: &[PathwaysQualifyingActivity],
) -> PathwaysActivityResult {
let total_hours: Decimal = activities.iter()
.map(|a| a.hours_per_month)
.sum();
PathwaysActivityResult {
total_hours_per_month: total_hours,
meets_threshold: total_hours >= Decimal::from(80),
activity_count: activities.len(),
activities_by_type: activities.iter()
.map(|a| (a.activity_type.clone(), a.hours_per_month))
.collect(),
}
}
API Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/v1/determine |
Accept ApplicationContext from canopy-eligibility, run full Medicaid/CHIP CMD cascade, return signed Determination. ADR-002 black-box endpoint. |
GET |
/v1/determinations/{id} |
Get a stored Medicaid determination by ID. HIPAA minimum necessary response. |
GET |
/v1/determinations/{id}/explanation |
Human-readable narrative explanation of determination basis. No clinical data (ADR-002 + HIPAA). |
GET |
/v1/determinations/{id}/categories |
Get all COAs evaluated in the CMD cascade for this determination with eligibility results and FPL percentages. |
GET |
/v1/determinations/{id}/cmd-cascade |
Get the full CMD cascade evaluation log showing the order and result for each COA. |
GET |
/v1/params |
Get current Medicaid parameters (FPL thresholds, FBR, premium schedule, IRS dependent exemption). Loaded from jurisdiction.toml. |
GET |
/v1/fti-audit-log |
FTI audit log query (restricted to fti_auditor role). Per fti-audit-logging plan. |
GET |
/v1/fti-audit-log/{id} |
Get a single FTI audit log entry by ID. Requires fti_auditor role. |
GET |
/v1/fti-audit-log/summary |
FTI audit log summary statistics for IRS inspection. |
Event Payloads (FTI/HIPAA Scrubbed per ADR-004)
Published to canopy.events topic exchange.
NO income amounts, NO FTI fields, NO FDSH details, NO clinical data, NO PHI.
// medicaid.determined
{
"event_type": "medicaid.determined",
"application_id": "uuid",
"household_id": "uuid",
"person_id": "uuid",
"status": "approved|denied|pending_spenddown|pending_premium",
"assigned_coa": "parent_caretaker",
"assigned_coa_track": "magi",
"benefit_type": "full_medicaid",
"determined_at": "2026-04-07T12:00:00Z"
}
// chip.determined
{
"event_type": "chip.determined",
"application_id": "uuid",
"child_person_id": "uuid",
"chip_type": "standalone",
"status": "approved|denied",
"premium_tier": "fpl_134_158",
"determined_at": "2026-04-07T12:00:00Z"
}
// medicaid.cmd_cascade_completed
{
"event_type": "medicaid.cmd_cascade_completed",
"application_id": "uuid",
"person_id": "uuid",
"coas_evaluated": 12,
"coas_eligible": 2,
"assigned_coa": "parent_caretaker",
"completed_at": "2026-04-07T12:00:00Z"
}
/// Published to canopy.events when Medicaid determination completes.
/// Contains NO FTI, NO FDSH details, NO clinical data.
#[derive(Debug, Serialize)]
pub struct MedicaidDeterminedEvent {
pub event_type: String,
pub application_id: Uuid,
pub household_id: Uuid,
pub person_id: Uuid,
pub status: String,
pub assigned_coa: Option<String>,
pub assigned_coa_track: Option<String>,
pub benefit_type: Option<String>,
pub determined_at: DateTime<Utc>,
}
/// Published to canopy.events when CHIP determination completes.
#[derive(Debug, Serialize)]
pub struct ChipDeterminedEvent {
pub event_type: String,
pub application_id: Uuid,
pub child_person_id: Uuid,
pub chip_type: String,
pub status: String,
pub premium_tier: Option<String>,
pub determined_at: DateTime<Utc>,
}
/// Published after CMD cascade completes for an individual.
#[derive(Debug, Serialize)]
pub struct CmdCascadeCompletedEvent {
pub event_type: String,
pub application_id: Uuid,
pub person_id: Uuid,
pub coas_evaluated: i32,
pub coas_eligible: i32,
pub assigned_coa: Option<String>,
pub completed_at: DateTime<Utc>,
}
HIPAA Minimum Necessary Enforcement
HIPAA’s minimum necessary standard requires that only the minimum amount of protected health information (PHI) needed for a specific purpose is disclosed. In canopy-medicaid:
-
clinical_assessmentstable stores ONLY the eligibility-relevant outcome (meets_criteria/does_not_meet), never clinical details -
API responses never include clinical details — only the assessment result and date
-
Event payloads contain no PHI — only IDs, status codes, and timestamps
-
Determination explanation endpoint returns narrative text, not clinical data
-
Inter-service API calls from canopy-eligibility receive only the signed determination — no PHI crosses the service boundary
/// Trait for HIPAA minimum necessary access control.
/// Every type exposed via API must declare its data elements.
pub trait HipaaMinimumNecessary: Serialize {
fn required_elements(&self) -> &[&str];
fn purpose(&self) -> &str;
}
/// HIPAA-restricted field names that must never appear in API responses or event payloads.
const HIPAA_RESTRICTED_FIELDS: &[&str] = &[
"diagnosis_code", "diagnosis_description", "clinical_notes",
"treatment_plan", "provider_notes", "medical_record_number",
"clinical_details", "disability_details", "medical_history",
"icd_code", "procedure_code", "lab_results",
];
/// Check a JSON value for HIPAA-restricted field names.
/// Returns a list of violations (field names found that should not be present).
pub fn check_hipaa_compliance(value: &serde_json::Value) -> Vec<String> {
let mut violations = Vec::new();
if let serde_json::Value::Object(map) = value {
for key in map.keys() {
if HIPAA_RESTRICTED_FIELDS.iter().any(|f| key.contains(f)) {
violations.push(key.clone());
}
}
for (_, v) in map.iter() {
violations.extend(check_hipaa_compliance(v));
}
}
violations
}
Determination Flow (Complete)
POST /v1/determine (from canopy-eligibility)
│
├── 1. Parse ApplicationContext, create medicaid_applications row
│ Determine pathway: magi, non_magi, both
│ For each person in the AU, a separate CMD cascade runs
│
├── 2. Build MAGI budget group (per PAMMS 2610)
│ ├── Determine tax filer vs. non-filer status for each person
│ ├── Build BG: tax filer + spouse + dependents (or non-filer equivalent)
│ ├── Count unborn children for pregnant members
│ ├── Mark SSI recipients (in BG, income excluded)
│ └── Store in magi_household_snapshots
│
├── 3. Fetch/verify income data
│ ├── 3a. Self-reported income from canopy-persons
│ ├── 3b. FTI verification (audit-logged under IRC 6103(l)(12))
│ │ MAGI = AGI + tax-exempt interest + foreign earned + non-taxable SS
│ ├── 3c. FDSH verification results from canopy-verification
│ ├── 3d. Clinical assessments for non-MAGI (disability, blindness, LOC)
│ └── Store in magi_income, non_magi_factors, fti_tax_data, fdsh_results,
│ clinical_assessments
│
├── 4. MAGI budgeting (per PAMMS 2669)
│ ├── Step 1: Deduct self-employment cost of business
│ ├── Step 2: Deduct before-tax deductions
│ ├── Step 3: Deduct 1040 deductions
│ ├── Step 4: Apply 5% FPL disregard
│ ├── Step 5: Select appropriate FPL limit per COA
│ └── Step 6: Compare → eligible or cascade to next COA
│
├── 5. CMD cascade: ABD track (per PAMMS 2052)
│ ├── FBR COAs: SSI, Pickle, DAC, DW, Widow(er), Former SSI DC
│ ├── LA-D/CAP COAs: EDWP, NOW, COMP, TEFRA, Hospice, Hospital, ICWP, NH
│ ├── Q-Track COAs: QMB, SLMB, QI-1
│ ├── AMN (spenddown)
│ └── Record each in medicaid_eligible_categories + cmd_cascade_log
│
├── 6. CMD cascade: Family track (per PAMMS 2052)
│ ├── Newborn
│ ├── Pregnant Women (220% FPL)
│ ├── Parent/Caretaker
│ ├── TMA, 4MEx
│ ├── Children Under 19 (age-tiered: 205%/149%/133% FPL)
│ ├── PeachCare (134-247% FPL; CHIPRA screen Medicaid first)
│ ├── FM-MN, Pregnant MN (spenddown)
│ ├── WHM (200% FPL, breast/cervical cancer)
│ ├── Pathways (100% FPL, 80 hr/mo qualifying activity)
│ ├── P4HB (211% FPL, ages 18-44)
│ └── Record each in medicaid_eligible_categories + cmd_cascade_log
│
├── 7. Pathways-specific: validate qualifying activities (80 hr/month)
│ Store in pathways_qualifying_activities
│ If has ESI access, create pathways_hipp_referrals record
│
├── 8. PeachCare-specific: calculate premium tier
│ Store in chip_applications with premium amounts
│ Check exemption status (under 6, foster care, AI/AN)
│
├── 9. Apply EE15 hierarchy
│ Collect all eligible COAs from both tracks
│ Call canopy-rules with medicaid-eligibility-hierarchy ruleset
│ Assign most advantageous group + determine FMAP rate
│
├── 10. Build Determination struct
│ Sign with ECDSA P-256 (DeterminationSigner)
│ Store in medicaid_determinations
│
├── 11. Publish events (FTI-scrubbed, HIPAA-compliant payload)
│ medicaid.determined — { application_id, person_id, status, assigned_coa }
│ chip.determined — { application_id, child_person_id, chip_type, status }
│ medicaid.cmd_cascade_completed — { coas_evaluated, coas_eligible }
│ NO income, NO FTI, NO FDSH details, NO clinical data
│
└── 12. Return signed Determination to canopy-eligibility
Rulesets
Four JDM ruleset files in rulesets/georgia/:
| Ruleset | Purpose |
|---|---|
|
MAGI income calculation, PAMMS 2669 budgeting steps, FPL percentage lookup by BG size, COA-specific thresholds (parent/caretaker, pregnant women, children by age, pathways, P4HB), 5% FPL disregard, child dependent income exclusion |
|
Non-MAGI COAs (ABD FBR + non-FBR), FBR income limit, resource test limits, income disregards, medically needy income level, spenddown calculation, Q-Track FPL thresholds, QDWI 200% FPL, LOC/LOS checks |
|
EE15 most advantageous group assignment: input is list of eligible COAs from CMD cascade, output is the assigned COA and FMAP rate, with Georgia-specific overrides (e.g., QI-1 mutual exclusivity exception for AMN) |
|
CHIP/PeachCare income thresholds (134-247% FPL), Medicaid-first screening rule (CHIPRA), standalone vs. expansion determination, premium tier assignment, exemption checks |
MedicaidCategory Enum
/// Medicaid categories ordered by advantageousness for EE15 hierarchy.
/// Lower advantage_rank = more advantageous (assigned first).
///
/// Per PAMMS 2052 and 42 CFR 435: assign to the group that provides
/// the most benefit to the applicant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MedicaidCategory {
// Family MAGI (most advantageous first)
Newborn,
PregnantWomen,
ParentCaretaker,
ChildrenUnder19,
Tma,
FourMonthsExtended,
FormerFosterCare,
// Family non-MAGI
FmMedicallyNeedy,
PregnantMedicallyNeedy,
Refugee,
FosterCare,
Adoption,
Chafee,
Whm,
// MAGI expansion / waiver
Pathways,
P4hbFp,
P4hbIpc,
P4hbRm,
// ABD FBR
SsiMedicaid,
Pickle,
Dac,
DisabledWidow,
Widow6064,
FormerSsiDisabledChild,
// ABD non-FBR
Edwp,
NowWaiver,
CompWaiver,
TefraKatieBeckett,
Hospice,
Hospital,
Icwp,
NursingHome,
Qdwi,
// ABD Q-Track
Qmb,
Slmb,
Qi1,
// ABD Medically Needy
Amn,
// CHIP
PeachCare,
}
impl MedicaidCategory {
pub fn coa_code(&self) -> &'static str {
match self {
Self::Newborn => "newborn",
Self::PregnantWomen => "pregnant_women",
Self::ParentCaretaker => "parent_caretaker",
Self::ChildrenUnder19 => "children_under_19",
Self::Tma => "tma",
Self::FourMonthsExtended => "four_months_extended",
Self::FormerFosterCare => "former_foster_care",
Self::FmMedicallyNeedy => "fm_medically_needy",
Self::PregnantMedicallyNeedy => "pregnant_medically_needy",
Self::Refugee => "refugee",
Self::FosterCare => "foster_care",
Self::Adoption => "adoption",
Self::Chafee => "chafee",
Self::Whm => "whm",
Self::Pathways => "pathways",
Self::P4hbFp => "p4hb_fp",
Self::P4hbIpc => "p4hb_ipc",
Self::P4hbRm => "p4hb_rm",
Self::SsiMedicaid => "ssi_medicaid",
Self::Pickle => "pickle",
Self::Dac => "dac",
Self::DisabledWidow => "disabled_widow",
Self::Widow6064 => "widow_60_64",
Self::FormerSsiDisabledChild => "former_ssi_disabled_child",
Self::Edwp => "edwp",
Self::NowWaiver => "now_waiver",
Self::CompWaiver => "comp_waiver",
Self::TefraKatieBeckett => "tefra_katie_beckett",
Self::Hospice => "hospice",
Self::Hospital => "hospital",
Self::Icwp => "icwp",
Self::NursingHome => "nursing_home",
Self::Qdwi => "qdwi",
Self::Qmb => "qmb",
Self::Slmb => "slmb",
Self::Qi1 => "qi_1",
Self::Amn => "amn",
Self::PeachCare => "peachcare",
}
}
pub fn track(&self) -> &'static str {
match self {
Self::Newborn | Self::FmMedicallyNeedy | Self::PregnantMedicallyNeedy
| Self::Refugee | Self::FosterCare | Self::Adoption | Self::Chafee
| Self::Whm => "non_magi",
Self::SsiMedicaid | Self::Pickle | Self::Dac | Self::DisabledWidow
| Self::Widow6064 | Self::FormerSsiDisabledChild | Self::Edwp
| Self::NowWaiver | Self::CompWaiver | Self::TefraKatieBeckett
| Self::Hospice | Self::Hospital | Self::Icwp | Self::NursingHome
| Self::Qdwi | Self::Qmb | Self::Slmb | Self::Qi1 | Self::Amn => "non_magi",
Self::PeachCare => "chip",
_ => "magi",
}
}
}
/// Assign the most advantageous Medicaid COA from eligible COAs.
/// The actual priority ordering is determined by the rules engine
/// (medicaid-eligibility-hierarchy ruleset) which has jurisdiction-specific
/// overrides. This function provides a fallback ordering.
pub fn assign_most_advantageous_coa(
eligible_coas: &[MedicaidCategory],
) -> Option<MedicaidCategory> {
if eligible_coas.is_empty() {
return None;
}
// The rules engine determines the final assignment.
// This is a Rust-side fallback using the PAMMS 2052 evaluation order.
let mut sorted = eligible_coas.to_vec();
sorted.sort_by_key(|c| cmd_cascade_order(c));
sorted.first().cloned()
}
fn cmd_cascade_order(cat: &MedicaidCategory) -> u32 {
match cat {
// Family track order per PAMMS 2052
MedicaidCategory::Newborn => 1,
MedicaidCategory::PregnantWomen => 2,
MedicaidCategory::ParentCaretaker => 3,
MedicaidCategory::Tma => 4,
MedicaidCategory::FourMonthsExtended => 5,
MedicaidCategory::ChildrenUnder19 => 6,
MedicaidCategory::PeachCare => 7,
MedicaidCategory::FmMedicallyNeedy => 8,
MedicaidCategory::PregnantMedicallyNeedy => 9,
MedicaidCategory::Whm => 10,
MedicaidCategory::Pathways => 11,
MedicaidCategory::P4hbFp => 12,
MedicaidCategory::P4hbIpc => 13,
MedicaidCategory::P4hbRm => 14,
MedicaidCategory::FormerFosterCare => 15,
MedicaidCategory::Refugee => 16,
MedicaidCategory::FosterCare => 17,
MedicaidCategory::Adoption => 18,
MedicaidCategory::Chafee => 19,
// ABD track order per PAMMS 2052
MedicaidCategory::SsiMedicaid => 20,
MedicaidCategory::Pickle => 21,
MedicaidCategory::Dac => 22,
MedicaidCategory::DisabledWidow => 23,
MedicaidCategory::Widow6064 => 24,
MedicaidCategory::FormerSsiDisabledChild => 25,
MedicaidCategory::Edwp => 26,
MedicaidCategory::NowWaiver => 27,
MedicaidCategory::CompWaiver => 28,
MedicaidCategory::TefraKatieBeckett => 29,
MedicaidCategory::Hospice => 30,
MedicaidCategory::Hospital => 31,
MedicaidCategory::Icwp => 32,
MedicaidCategory::NursingHome => 33,
MedicaidCategory::Qmb => 34,
MedicaidCategory::Slmb => 35,
MedicaidCategory::Qi1 => 36,
MedicaidCategory::Qdwi => 37,
MedicaidCategory::Amn => 38,
}
}
Steps
Step 1: Database Migration
Files:
-
services/canopy-medicaid/migrations/20260407000000_create_medicaid_tables.sql
Create all tables and indexes from the Design section.
The FTI audit log migration (20260326000001) already exists from the fti-audit-logging plan.
Uncomment migration runner in services/canopy-medicaid/src/main.rs.
Seed the peachcare_premium_schedule table from jurisdiction.toml values using an INSERT block in the migration with ON CONFLICT DO NOTHING.
Step 2: FTI Store Layer with Audit Logging
Files:
-
services/canopy-medicaid/src/store/mod.rs(new) -
services/canopy-medicaid/src/store/models.rs(new) -
services/canopy-medicaid/src/store/fti.rs(new)
Same pattern as canopy-tanf FTI store but with purpose codes MedicaidEligibility, MedicaidMagi, ChipEligibility per IRC section 6103(l)(12).
All FTI reads/writes wrapped with fti_audited from canopy_common::fti_audit.
Three FTI access functions:
-
read_fti_for_magi— MAGI income verification, accesses AGI + tax-exempt interest + foreign earned income + Social Security benefits + taxable Social Security. Purpose code:MEDICAID_MAGI. -
read_fti_for_eligibility— General Medicaid eligibility (non-MAGI). Accesses AGI + filing status. Purpose code:MEDICAID_ELIG. -
read_fti_for_chip— CHIP eligibility. Accesses AGI + filing status. Purpose code:CHIP_ELIG.
// services/canopy-medicaid/src/store/fti.rs
use canopy_common::fti_audit::{fti_audited, FtiPurposeCode, FtiAction};
pub async fn read_fti_for_magi(
pool: &PgPool,
medicaid_application_id: Uuid,
person_id: Uuid,
accessed_by: &str,
request_id: Option<Uuid>,
ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
fti_audited(
pool,
accessed_by,
FtiPurposeCode::MedicaidMagi,
&["adjusted_gross_income", "filing_status", "wages_salaries_tips",
"social_security_benefits", "taxable_social_security",
"tax_exempt_interest", "foreign_earned_income"],
"canopy-medicaid",
FtiAction::Read,
"fti_tax_data",
None,
request_id,
ip_address,
|| async {
sqlx::query_as::<_, FtiTaxData>(
"SELECT * FROM fti_tax_data
WHERE medicaid_application_id = $1 AND person_id = $2"
)
.bind(medicaid_application_id)
.bind(person_id)
.fetch_all(pool)
.await
.map_err(FtiAuditError::Database)
},
)
.await
}
pub async fn read_fti_for_eligibility(
pool: &PgPool,
medicaid_application_id: Uuid,
person_id: Uuid,
accessed_by: &str,
request_id: Option<Uuid>,
ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
fti_audited(
pool,
accessed_by,
FtiPurposeCode::MedicaidEligibility,
&["adjusted_gross_income", "filing_status"],
"canopy-medicaid",
FtiAction::Read,
"fti_tax_data",
None,
request_id,
ip_address,
|| async {
sqlx::query_as::<_, FtiTaxData>(
"SELECT * FROM fti_tax_data
WHERE medicaid_application_id = $1 AND person_id = $2"
)
.bind(medicaid_application_id)
.bind(person_id)
.fetch_all(pool)
.await
.map_err(FtiAuditError::Database)
},
)
.await
}
pub async fn read_fti_for_chip(
pool: &PgPool,
medicaid_application_id: Uuid,
person_id: Uuid,
accessed_by: &str,
request_id: Option<Uuid>,
ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
fti_audited(
pool,
accessed_by,
FtiPurposeCode::ChipEligibility,
&["adjusted_gross_income", "filing_status"],
"canopy-medicaid",
FtiAction::Read,
"fti_tax_data",
None,
request_id,
ip_address,
|| async {
sqlx::query_as::<_, FtiTaxData>(
"SELECT * FROM fti_tax_data
WHERE medicaid_application_id = $1 AND person_id = $2"
)
.bind(medicaid_application_id)
.bind(person_id)
.fetch_all(pool)
.await
.map_err(FtiAuditError::Database)
},
)
.await
}
Step 3: FDSH and Clinical Data Store
Files:
-
services/canopy-medicaid/src/store/fdsh.rs(new) -
services/canopy-medicaid/src/store/clinical.rs(new)
FDSH results are stored per ADR-004 — raw data from canopy-verification stays in canopy-medicaid’s database. Clinical assessments use write-once pattern (not modified after creation) per HIPAA minimum necessary.
// services/canopy-medicaid/src/store/fdsh.rs
pub async fn insert_fdsh_result(pool: &PgPool, result: &FdshResult) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO fdsh_results
(id, medicaid_application_id, person_id, hub_service, verification_type,
result_status, result_data, requested_at, received_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
)
.bind(result.id)
.bind(result.medicaid_application_id)
.bind(result.person_id)
.bind(&result.hub_service)
.bind(&result.verification_type)
.bind(&result.result_status)
.bind(&result.result_data)
.bind(result.requested_at)
.bind(result.received_at)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_fdsh_results(
pool: &PgPool,
medicaid_application_id: Uuid,
person_id: Uuid,
) -> Result<Vec<FdshResult>, sqlx::Error> {
sqlx::query_as::<_, FdshResult>(
"SELECT * FROM fdsh_results
WHERE medicaid_application_id = $1 AND person_id = $2"
)
.bind(medicaid_application_id)
.bind(person_id)
.fetch_all(pool)
.await
}
// services/canopy-medicaid/src/store/clinical.rs
pub async fn insert_clinical_assessment(
pool: &PgPool,
assessment: &ClinicalAssessment,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO clinical_assessments
(id, medicaid_application_id, person_id, assessment_type,
assessor, assessment_date, result)
VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(assessment.id)
.bind(assessment.medicaid_application_id)
.bind(assessment.person_id)
.bind(&assessment.assessment_type)
.bind(&assessment.assessor)
.bind(assessment.assessment_date)
.bind(&assessment.result)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_clinical_assessments(
pool: &PgPool,
medicaid_application_id: Uuid,
person_id: Uuid,
) -> Result<Vec<ClinicalAssessment>, sqlx::Error> {
sqlx::query_as::<_, ClinicalAssessment>(
"SELECT * FROM clinical_assessments
WHERE medicaid_application_id = $1 AND person_id = $2"
)
.bind(medicaid_application_id)
.bind(person_id)
.fetch_all(pool)
.await
}
Step 4: MAGI Budget Group Composition Module
Files:
-
services/canopy-medicaid/src/magi_household.rs(new)
Implement build_magi_budget_group and MagiBudgetGroup from the Design section.
Tax filer vs. non-filer logic per PAMMS 2610.
Unborn child counting per PAMMS 2610/2184.
SSI recipient inclusion (BG member, income excluded) per PAMMS 2610.
Child dependent income exclusion threshold from jurisdiction.toml.
Store snapshot in magi_household_snapshots via:
// services/canopy-medicaid/src/store/households.rs
pub async fn create_magi_snapshot(
pool: &PgPool,
medicaid_application_id: Uuid,
bg: &MagiBudgetGroup,
) -> Result<MagiHouseholdSnapshot, sqlx::Error> {
sqlx::query_as::<_, MagiHouseholdSnapshot>(
"INSERT INTO magi_household_snapshots
(id, medicaid_application_id, household_id, person_id, is_tax_filer,
tax_filer_person_id, filing_status, magi_household_size,
spouse_person_id, dependents_person_ids, pregnant_member_ids,
unborn_count, ssi_recipient_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *"
)
.bind(Uuid::now_v7())
.bind(medicaid_application_id)
.bind(bg.household_id)
.bind(bg.person_id)
.bind(bg.is_tax_filer)
.bind(bg.tax_filer_id)
.bind(&bg.filing_status)
.bind(bg.household_size)
.bind(bg.spouse_id)
.bind(&bg.dependents)
.bind(&bg.pregnant_member_ids)
.bind(bg.unborn_count)
.bind(&bg.ssi_recipient_ids)
.fetch_one(pool)
.await
}
Step 5: MAGI Budgeting Module
Files:
-
services/canopy-medicaid/src/magi_budget.rs(new)
Implement assemble_magi_budget and MagiBudgetInput from the Design section.
Six-step procedure per PAMMS 2669.
FTI-verified MAGI calculation: AGI + tax-exempt interest + foreign earned income + non-taxable Social Security.
Self-reported fallback when FTI is unavailable.
The 5% FPL disregard amount and FPL limits are loaded from jurisdiction.toml and injected as parameters to the rules engine. Rust assembles data; the rules engine compares.
Step 6: ABD Medicaid COA Evaluation
Files:
-
services/canopy-medicaid/src/abd.rs(new)
Evaluate all ABD COAs in the order specified by PAMMS 2052:
-
FBR COAs: Compare Federal Countable Income to the SSI Federal Benefit Rate. COAs: SSI Medicaid, Pickle, DAC, Disabled Widow(er), Widow(er) 60-64, Former SSI Disabled Child.
-
Non-FBR institutional COAs: Check Level of Care, Length of Stay, resource test, income limit. COAs: EDWP, NOW, COMP, TEFRA/Katie Beckett, Hospice, Hospital, ICWP, Nursing Home.
-
Q-Track COAs: Medicare enrollment + FPL-based income test + resource test. QMB (100% FPL), SLMB (120% FPL), QI-1 (135% FPL). Per PAMMS 2143: QMB requires Medicare Part A or Part B enrollment.
-
QDWI: Medicare Part A, 200% FPL.
-
AMN: Income above regular ABD limits but applicant has medical expenses. Spenddown = income - medically needy income level.
Each COA is evaluated via the medicaid-non-magi ruleset with the appropriate input. Results are stored in medicaid_eligible_categories and cmd_cascade_log.
// services/canopy-medicaid/src/abd.rs
pub async fn evaluate_abd_cascade(
pool: &PgPool,
application: &MedicaidApplication,
person_id: Uuid,
factors: &NonMagiFactors,
rules_client: &MedicaidRulesClient,
) -> Result<Vec<MedicaidEligibleCategory>, AbdError> {
let mut categories = Vec::new();
let mut order: i32 = 0;
// FBR COAs
for coa in &[
MedicaidCategory::SsiMedicaid, MedicaidCategory::Pickle,
MedicaidCategory::Dac, MedicaidCategory::DisabledWidow,
MedicaidCategory::Widow6064, MedicaidCategory::FormerSsiDisabledChild,
] {
order += 1;
let result = evaluate_single_abd_coa(
pool, application.id, person_id, factors, coa, order, rules_client,
).await?;
categories.push(result);
}
// Non-FBR institutional COAs
for coa in &[
MedicaidCategory::Edwp, MedicaidCategory::NowWaiver,
MedicaidCategory::CompWaiver, MedicaidCategory::TefraKatieBeckett,
MedicaidCategory::Hospice, MedicaidCategory::Hospital,
MedicaidCategory::Icwp, MedicaidCategory::NursingHome,
] {
order += 1;
let result = evaluate_single_abd_coa(
pool, application.id, person_id, factors, coa, order, rules_client,
).await?;
categories.push(result);
}
// Q-Track COAs
for coa in &[
MedicaidCategory::Qmb, MedicaidCategory::Slmb, MedicaidCategory::Qi1,
] {
order += 1;
let result = evaluate_single_abd_coa(
pool, application.id, person_id, factors, coa, order, rules_client,
).await?;
categories.push(result);
}
// QDWI
order += 1;
let qdwi_result = evaluate_single_abd_coa(
pool, application.id, person_id, factors, &MedicaidCategory::Qdwi, order, rules_client,
).await?;
categories.push(qdwi_result);
// AMN (always evaluated last in ABD track)
order += 1;
let amn_result = evaluate_single_abd_coa(
pool, application.id, person_id, factors, &MedicaidCategory::Amn, order, rules_client,
).await?;
categories.push(amn_result);
Ok(categories)
}
Step 7: Family Medicaid MAGI COA Evaluation
Files:
-
services/canopy-medicaid/src/family_magi.rs(new)
Evaluate Family MAGI COAs per PAMMS 2052 cascade order using the medicaid-magi ruleset.
Each COA has age-specific and FPL-specific criteria:
-
Parent/Caretaker (PAMMS 2162): Adult with children under 19, state-set FPL threshold.
-
Pregnant Women (PAMMS 2184): 220% FPL, includes 12-month postpartum period, unborn count in BG.
-
Children Under 19 (PAMMS 2182): Age-tiered thresholds: birth-1 = 205% FPL, 1-5 = 149% FPL, 6-18 = 133% FPL. Because limits vary by age, siblings in the same BG may have different eligibility.
-
TMA (PAMMS 2166): Must have received Parent/Caretaker in 3 of 6 prior months. Ineligibility due to earned income. No income test first 6 months; 205% FPL for second 6 months.
-
4MEx (PAMMS 2170): Must have received Parent/Caretaker in 3 of 6 prior months. Ineligibility due to spousal support. 4 months coverage.
// services/canopy-medicaid/src/family_magi.rs
pub async fn evaluate_family_magi_cascade(
pool: &PgPool,
application: &MedicaidApplication,
person_id: Uuid,
budget: &MagiBudgetInput,
household: &MagiHouseholdSnapshot,
person_age: i32,
is_pregnant: bool,
is_parent_caretaker: bool,
expected_children: i32,
rules_client: &MedicaidRulesClient,
params: &MedicaidParams,
) -> Result<Vec<MedicaidEligibleCategory>, FamilyMagiError> {
let mut categories = Vec::new();
let mut order: i32 = 0;
// Pregnant Women (PAMMS 2184)
if is_pregnant {
order += 1;
let input = MagiInput {
household_size: household.magi_household_size,
magi_income: budget.net_taxable_monthly,
fpl_5_percent_disregard: budget.fpl_5_percent_disregard,
coa_code: "pregnant_women".into(),
fpl_threshold: params.pregnant_women_fpl_threshold,
applicant_age: person_age,
is_pregnant: true,
expected_children,
};
let result = rules_client.evaluate_magi(input).await?;
let cat = store::categories::insert_eligible_category(
pool, application.id, person_id, "pregnant_women", "magi",
result.eligible, result.fpl_percentage, result.fpl_threshold,
Some(budget.net_taxable_monthly), None, None,
result.denial_reason.as_deref(), order,
).await?;
categories.push(cat);
}
// Parent/Caretaker (PAMMS 2162)
if is_parent_caretaker && person_age >= 19 {
order += 1;
let input = MagiInput {
household_size: household.magi_household_size,
magi_income: budget.net_taxable_monthly,
fpl_5_percent_disregard: budget.fpl_5_percent_disregard,
coa_code: "parent_caretaker".into(),
fpl_threshold: params.parent_caretaker_fpl_threshold,
applicant_age: person_age,
is_pregnant,
expected_children: 0,
};
let result = rules_client.evaluate_magi(input).await?;
let cat = store::categories::insert_eligible_category(
pool, application.id, person_id, "parent_caretaker", "magi",
result.eligible, result.fpl_percentage, result.fpl_threshold,
Some(budget.net_taxable_monthly), None, None,
result.denial_reason.as_deref(), order,
).await?;
categories.push(cat);
}
// Children Under 19 (PAMMS 2182) -- age-tiered thresholds
if person_age < 19 {
order += 1;
let fpl_threshold = if person_age < 1 {
params.children_birth_to_1_fpl_threshold // 205% FPL
} else if person_age < 6 {
params.children_1_to_5_fpl_threshold // 149% FPL
} else {
params.children_6_to_18_fpl_threshold // 133% FPL
};
let input = MagiInput {
household_size: household.magi_household_size,
magi_income: budget.net_taxable_monthly,
fpl_5_percent_disregard: budget.fpl_5_percent_disregard,
coa_code: "children_under_19".into(),
fpl_threshold,
applicant_age: person_age,
is_pregnant: false,
expected_children: 0,
};
let result = rules_client.evaluate_magi(input).await?;
let cat = store::categories::insert_eligible_category(
pool, application.id, person_id, "children_under_19", "magi",
result.eligible, result.fpl_percentage, result.fpl_threshold,
Some(budget.net_taxable_monthly), None, None,
result.denial_reason.as_deref(), order,
).await?;
categories.push(cat);
}
Ok(categories)
}
Step 8: PeachCare for Kids (CHIP) COA Evaluation
Files:
-
services/canopy-medicaid/src/chip.rs(new)
Per PAMMS 2194 and CHIPRA:
-
Screen for Medicaid first — if Medicaid-eligible, the child gets Medicaid, not CHIP.
-
Check CHIP income threshold: household MAGI > Medicaid child limit AND ⇐ 247% FPL.
-
Evaluate via
chip-eligibilityruleset. -
If eligible, determine premium tier from
peachcare_premium_schedule. -
Check premium exemptions (under 6, foster care, AI/AN).
-
No prior month coverage for PeachCare.
// services/canopy-medicaid/src/chip.rs
pub async fn evaluate_peachcare(
pool: &PgPool,
medicaid_application_id: Uuid,
household_id: Uuid,
child_person_id: Uuid,
child_age: i32,
household_magi: Decimal,
household_size: i32,
medicaid_child_eligible: bool,
is_foster_care: bool,
is_ai_an: bool,
rules_client: &MedicaidRulesClient,
premium_schedule: &[PeachcarePremiumTier],
) -> Result<Option<ChipApplication>, ChipError> {
// CHIPRA mandatory Medicaid-first screening
if medicaid_child_eligible {
return Ok(None);
}
let input = ChipInput {
child_person_id,
household_magi,
household_size,
child_age,
};
let output = rules_client.evaluate_chip(input).await?;
if !output.eligible {
// Record ineligible result
store::categories::insert_eligible_category(
pool, medicaid_application_id, child_person_id, "peachcare", "chip",
false, Some(output.fpl_percentage), Some(Decimal::from(247)), // 247% FPL
Some(household_magi), None, None,
output.denial_reason.as_deref(), 0,
).await?;
return Ok(None);
}
// Calculate premium
let premium = determine_peachcare_premium(
output.fpl_percentage, child_age, is_foster_care, is_ai_an, premium_schedule,
);
let chip_app = store::chip::create_chip_application(
pool,
medicaid_application_id,
child_person_id,
household_id,
&output.chip_type,
premium.tier.as_deref(),
Some(premium.one_child_premium),
Some(premium.family_cap_premium),
premium.exempt,
premium.exemption_reason.as_deref(),
).await?;
store::categories::insert_eligible_category(
pool, medicaid_application_id, child_person_id, "peachcare", "chip",
true, Some(output.fpl_percentage), Some(Decimal::from(247)),
Some(household_magi), None, None,
None, 0,
).await?;
Ok(Some(chip_app))
}
Step 9: Pathways COA Evaluation
Files:
-
services/canopy-medicaid/src/pathways.rs(new)
Per PAMMS 2195:
-
Age 19-64, Georgia resident.
-
MAGI income at or below 100% FPL (95% after 5% disregard).
-
Not eligible for any other Medicaid COA (evaluated AFTER all other COAs in CMD cascade).
-
80 hours/month qualifying activities.
-
No retroactive months, no HPE, no EMA.
-
ESI access triggers HIPP referral for cost-effectiveness determination.
// services/canopy-medicaid/src/pathways.rs
pub async fn evaluate_pathways(
pool: &PgPool,
application: &MedicaidApplication,
person_id: Uuid,
person_age: i32,
budget: &MagiBudgetInput,
household_size: i32,
other_coas_eligible: bool,
activities: &[PathwaysQualifyingActivity],
has_esi_access: bool,
rules_client: &MedicaidRulesClient,
params: &MedicaidParams,
) -> Result<MedicaidEligibleCategory, PathwaysError> {
// Age check: 19-64
if person_age < 19 || person_age > 64 {
return store::categories::insert_eligible_category(
pool, application.id, person_id, "pathways", "magi",
false, None, Some(params.pathways_fpl_threshold),
Some(budget.net_taxable_monthly), None, None,
Some("age_ineligible"), 0,
).await.map_err(PathwaysError::Database);
}
// Must not be eligible for any other Medicaid COA
if other_coas_eligible {
return store::categories::insert_eligible_category(
pool, application.id, person_id, "pathways", "magi",
false, None, Some(params.pathways_fpl_threshold),
Some(budget.net_taxable_monthly), None, None,
Some("eligible_for_other_coa"), 0,
).await.map_err(PathwaysError::Database);
}
// Validate qualifying activities (80 hours/month)
let activity_result = validate_pathways_activities(activities);
if !activity_result.meets_threshold {
return store::categories::insert_eligible_category(
pool, application.id, person_id, "pathways", "magi",
false, None, Some(params.pathways_fpl_threshold),
Some(budget.net_taxable_monthly), None, None,
Some("qualifying_activities_below_80_hours"), 0,
).await.map_err(PathwaysError::Database);
}
// Store activities
for activity in activities {
store::pathways::insert_qualifying_activity(pool, application.id, person_id, activity)
.await.map_err(PathwaysError::Database)?;
}
// HIPP referral if ESI access
if has_esi_access {
store::pathways::create_hipp_referral(pool, application.id, person_id)
.await.map_err(PathwaysError::Database)?;
}
// Evaluate via rules engine
let input = MagiInput {
household_size,
magi_income: budget.net_taxable_monthly,
fpl_5_percent_disregard: budget.fpl_5_percent_disregard,
coa_code: "pathways".into(),
fpl_threshold: params.pathways_fpl_threshold, // 100% FPL (95% after disregard)
applicant_age: person_age,
is_pregnant: false,
expected_children: 0,
};
let result = rules_client.evaluate_magi(input).await?;
store::categories::insert_eligible_category(
pool, application.id, person_id, "pathways", "magi",
result.eligible, result.fpl_percentage, result.fpl_threshold,
Some(budget.net_taxable_monthly), None, None,
result.denial_reason.as_deref(), 0,
).await.map_err(PathwaysError::Database)
}
Step 10: Family Non-MAGI COA Evaluation
Files:
-
services/canopy-medicaid/src/family_non_magi.rs(new)
Evaluate Family non-MAGI COAs:
-
Newborn (PAMMS 2174): Born to woman eligible for and receiving Medicaid on date of birth. 13 months coverage (birth month through month child reaches age 1). No application or interview required. No income test.
-
FM-MN (PAMMS 2196): Children under 19 and pregnant women whose BG income exceeds all Family Medicaid COAs and PeachCare. Resource test using SSI limits ($2,000/individual, scaling by BG size). Spenddown calculation.
-
WHM (PAMMS 2198): Women screened under CDC NBCCEDP, diagnosed with breast/cervical cancer. Under age 65, income at or below 200% FPL, no creditable health coverage. Non-MAGI budgeting.
-
P4HB (PAMMS 2186): Women ages 18-44, income up to 211% FPL, not eligible for other Medicaid/CHIP. Family planning services only.
-
TMA (PAMMS 2166): Continuation of Parent/Caretaker for up to 12 months when ineligibility is due to earned income changes. No income test for first 6 months; 205% FPL for second 6 months.
-
4MEx (PAMMS 2170): 4 months continuation when Parent/Caretaker ineligibility is due to spousal support changes.
Step 11: CMD Cascade Engine
Files:
-
services/canopy-medicaid/src/cmd_cascade.rs(new)
Per PAMMS 2052: "Eligibility must be reviewed under all Medicaid COAs before denying a Medical Assistance application."
The CMD cascade engine:
-
Receives a person and their application context.
-
Evaluates ABD track COAs in order (Step 6).
-
Evaluates Family track COAs in order (Steps 7, 8, 9, 10).
-
Records every evaluation in
medicaid_eligible_categoriesandcmd_cascade_log. -
Collects all eligible COAs for EE15 hierarchy input.
-
If no COAs are eligible, the denial is supported by the full cascade audit trail.
// services/canopy-medicaid/src/cmd_cascade.rs
pub struct CmdCascadeResult {
pub eligible_coas: Vec<MedicaidEligibleCategory>,
pub all_evaluated: Vec<MedicaidEligibleCategory>,
pub cascade_log: Vec<CmdCascadeLogEntry>,
}
pub async fn run_cmd_cascade(
pool: &PgPool,
application: &MedicaidApplication,
person_id: Uuid,
context: &PersonContext,
budget: &MagiBudgetInput,
household: &MagiHouseholdSnapshot,
factors: Option<&NonMagiFactors>,
activities: &[PathwaysQualifyingActivity],
rules_client: &MedicaidRulesClient,
params: &MedicaidParams,
) -> Result<CmdCascadeResult, CmdCascadeError> {
let mut all_categories = Vec::new();
let mut cascade_log = Vec::new();
// ABD track (if applicable: age >= 65, or disability, or blindness)
if context.may_qualify_abd() {
if let Some(f) = factors {
let abd_results = abd::evaluate_abd_cascade(
pool, application, person_id, f, rules_client,
).await?;
for cat in &abd_results {
cascade_log.push(CmdCascadeLogEntry {
track: "abd".into(),
coa_code: cat.coa_code.clone(),
order: cat.evaluation_order,
eligible: cat.eligible,
denial_reason: cat.denial_reason.clone(),
});
}
all_categories.extend(abd_results);
}
}
// Family MAGI track
let family_magi = family_magi::evaluate_family_magi_cascade(
pool, application, person_id, budget, household,
context.age, context.is_pregnant, context.is_parent_caretaker,
context.expected_children, rules_client, params,
).await?;
for cat in &family_magi {
cascade_log.push(CmdCascadeLogEntry {
track: "family".into(),
coa_code: cat.coa_code.clone(),
order: cat.evaluation_order,
eligible: cat.eligible,
denial_reason: cat.denial_reason.clone(),
});
}
all_categories.extend(family_magi);
// PeachCare (CHIP) -- only for children under 19
let medicaid_child_eligible = all_categories.iter()
.any(|c| c.coa_code == "children_under_19" && c.eligible);
if context.age < 19 {
if let Some(chip_app) = chip::evaluate_peachcare(
pool, application.id, application.household_id,
person_id, context.age,
budget.net_taxable_monthly, household.magi_household_size,
medicaid_child_eligible,
context.is_foster_care, context.is_ai_an,
rules_client, ¶ms.peachcare_premium_schedule,
).await? {
cascade_log.push(CmdCascadeLogEntry {
track: "family".into(),
coa_code: "peachcare".into(),
order: 7,
eligible: true,
denial_reason: None,
});
}
}
// Family non-MAGI COAs
let family_non_magi = family_non_magi::evaluate_family_non_magi_cascade(
pool, application, person_id, context, budget, rules_client, params,
).await?;
for cat in &family_non_magi {
cascade_log.push(CmdCascadeLogEntry {
track: "family".into(),
coa_code: cat.coa_code.clone(),
order: cat.evaluation_order,
eligible: cat.eligible,
denial_reason: cat.denial_reason.clone(),
});
}
all_categories.extend(family_non_magi);
// Pathways (evaluated LAST -- only if not eligible for any other COA)
let other_coas_eligible = all_categories.iter().any(|c| c.eligible);
if context.age >= 19 && context.age <= 64 {
let pathways_result = pathways::evaluate_pathways(
pool, application, person_id, context.age,
budget, household.magi_household_size,
other_coas_eligible, activities,
context.has_esi_access, rules_client, params,
).await?;
cascade_log.push(CmdCascadeLogEntry {
track: "family".into(),
coa_code: "pathways".into(),
order: 11,
eligible: pathways_result.eligible,
denial_reason: pathways_result.denial_reason.clone(),
});
all_categories.push(pathways_result);
}
// Persist cascade log
for entry in &cascade_log {
store::cmd_cascade::insert_cascade_entry(
pool, application.id, person_id, entry,
).await?;
}
let eligible_coas: Vec<_> = all_categories.iter()
.filter(|c| c.eligible)
.cloned()
.collect();
Ok(CmdCascadeResult {
eligible_coas,
all_evaluated: all_categories,
cascade_log,
})
}
Step 12: EE15 Hierarchy
Files:
-
services/canopy-medicaid/src/hierarchy.rs(new)
When an applicant qualifies under multiple COAs, assign the most advantageous group per 42 CFR 435. "Most advantageous" = best coverage with least cost-sharing.
Uses both:
-
Local
assign_most_advantageous_coafunction (Rust fallback using PAMMS 2052 order) -
medicaid-eligibility-hierarchyruleset for jurisdiction-specific overrides (e.g., QI-1 dual eligibility exception with AMN per PAMMS 2052)
// services/canopy-medicaid/src/hierarchy.rs
pub async fn evaluate_hierarchy(
eligible_categories: &[MedicaidEligibleCategory],
rules_client: &MedicaidRulesClient,
) -> Result<HierarchyResult, HierarchyError> {
let eligible: Vec<_> = eligible_categories.iter()
.filter(|c| c.eligible)
.collect();
if eligible.is_empty() {
return Err(HierarchyError::NoEligibleCategories);
}
let input = serde_json::json!({
"eligible_categories": eligible.iter().map(|c| {
serde_json::json!({
"coa_code": c.coa_code,
"coa_track": c.coa_track,
"fpl_percentage": c.fpl_percentage,
"income_amount": c.income_amount,
"resource_amount": c.resource_amount,
"spend_down_amount": c.spend_down_amount,
})
}).collect::<Vec<_>>(),
});
let result = rules_client.evaluate_hierarchy(input).await?;
Ok(HierarchyResult {
assigned_coa: result.assigned_coa,
assigned_coa_track: result.assigned_coa_track,
fmap_rate: result.fmap_rate,
basis: result.basis,
})
}
pub struct HierarchyResult {
pub assigned_coa: String,
pub assigned_coa_track: String,
pub fmap_rate: Decimal,
pub basis: String,
}
#[derive(Debug, thiserror::Error)]
pub enum HierarchyError {
#[error("no eligible categories to evaluate")]
NoEligibleCategories,
#[error("rules evaluation failed: {0}")]
Rules(#[from] RulesError),
}
Step 13: Determination Endpoint
Files:
-
services/canopy-medicaid/src/determine.rs(new) -
services/canopy-medicaid/src/api/mod.rs
Implement POST /v1/determine following the complete determination flow from the Design section.
Wire DeterminationSigner from canopy_signing::traits.
Load signing key from CANOPY_MEDICAID_SIGNING_KEY environment variable.
The handler orchestrates the full pipeline: application creation, MAGI household composition, income assembly, FTI retrieval (audit-logged), MAGI budgeting, CMD cascade, EE15 hierarchy, signing, persistence, and event publishing.
// services/canopy-medicaid/src/determine.rs
pub async fn handle_determine(
claims: Extension<Claims>,
State(state): State<AppState>,
Json(context): Json<ApplicationContext>,
) -> Result<Json<SignedDetermination>, ApiError> {
// 1. Create application
let pathway = determine_pathway(&context);
let app = store::applications::create_application(
state.db.inner(), context.application_id, context.household_id,
context.applicant_person_id, &pathway,
).await.map_err(|e| ApiError::internal("create application", e))?;
// 2. Build MAGI budget group (PAMMS 2610)
let magi_bg = magi_household::build_magi_budget_group(
context.applicant_person_id,
context.is_tax_filer,
context.tax_filer_id,
context.spouse_id,
context.dependents.clone(),
context.children_under_19.clone(),
context.parents_in_home.clone(),
context.siblings_under_19.clone(),
context.pregnant_member_ids.clone(),
&context.expected_children_per_member,
context.ssi_recipient_ids.clone(),
);
let snapshot = store::households::create_magi_snapshot(
state.db.inner(), app.id, &magi_bg,
).await.map_err(|e| ApiError::internal("create snapshot", e))?;
// 3. Fetch income, FTI (audit-logged), FDSH, clinical assessments
let fti_data = store::fti::read_fti_for_magi(
state.db.inner(), app.id, context.applicant_person_id,
&claims.sub, Some(context.request_id), claims.ip_address.as_deref(),
).await?;
// 4. MAGI budgeting (PAMMS 2669)
let budget = magi_budget::assemble_magi_budget(
&context.income_records, Some(&fti_data),
&magi_bg.ssi_recipient_ids,
state.params.fpl_5_percent_disregard(magi_bg.household_size),
);
// 5-10. CMD cascade
let cascade_result = cmd_cascade::run_cmd_cascade(
state.db.inner(), &app, context.applicant_person_id,
&context.person_context, &budget, &snapshot,
context.non_magi_factors.as_ref(),
&context.pathways_activities,
&state.rules_client, &state.params,
).await?;
// 11. EE15 hierarchy
let (status, hierarchy, denial_reason) = if cascade_result.eligible_coas.is_empty() {
("denied".to_string(), None, Some("ineligible_all_coas".to_string()))
} else {
let h = hierarchy::evaluate_hierarchy(
&cascade_result.eligible_coas, &state.rules_client,
).await?;
("approved".to_string(), Some(h), None)
};
// 12. Build and sign determination
let det_id = Uuid::now_v7();
let now = Utc::now();
let mut determination = MedicaidDetermination {
id: det_id,
medicaid_application_id: app.id,
household_id: context.household_id,
person_id: context.applicant_person_id,
status: status.clone(),
assigned_coa: hierarchy.as_ref().map(|h| h.assigned_coa.clone()),
assigned_coa_track: hierarchy.as_ref().map(|h| h.assigned_coa_track.clone()),
benefit_type: hierarchy.as_ref().map(|_| determine_benefit_type(&status)),
benefit_amount: None,
benefit_unit: Some("monthly_usd".to_string()),
effective_date: if status == "approved" { Some(now.date_naive()) } else { None },
expiration_date: if status == "approved" {
Some(now.date_naive() + chrono::Months::new(12))
} else { None },
renewal_date: if status == "approved" {
Some(now.date_naive() + chrono::Months::new(11))
} else { None },
continuous_eligibility_end: None,
basis: hierarchy.as_ref().map(|h| h.basis.clone()),
denial_reason,
denial_reason_codes: None,
fmap_rate: hierarchy.as_ref().map(|h| h.fmap_rate),
program_service_version: env!("CARGO_PKG_VERSION").to_string(),
determined_at: now,
signature: String::new(),
created_at: now,
};
let payload = serde_json::to_vec(&determination)
.map_err(|e| ApiError::internal("serialize determination", e))?;
let signature = state.signer.sign(&payload)
.map_err(|e| ApiError::internal("sign determination", e))?;
determination.signature = signature;
store::determinations::insert_determination(state.db.inner(), &determination)
.await.map_err(|e| ApiError::internal("persist determination", e))?;
store::applications::update_status(state.db.inner(), app.id, "determined")
.await.map_err(|e| ApiError::internal("update status", e))?;
// 13. Publish events
events::publish_medicaid_determined(
&state.publisher, &determination,
).await.map_err(|e| ApiError::internal("publish event", e))?;
events::publish_cmd_cascade_completed(
&state.publisher, app.id, context.applicant_person_id,
cascade_result.all_evaluated.len() as i32,
cascade_result.eligible_coas.len() as i32,
determination.assigned_coa.as_deref(),
).await.map_err(|e| ApiError::internal("publish cascade event", e))?;
Ok(Json(SignedDetermination::from(determination)))
}
Step 14: HIPAA Enforcement
Files:
-
services/canopy-medicaid/src/hipaa.rs(new)
Implement HipaaMinimumNecessary trait, response types, check_hipaa_compliance, and HIPAA_RESTRICTED_FIELDS constant from the Design section.
Add compile-time enforcement: clinical assessment structs do NOT derive Serialize for API response use. Separate ClinicalAssessmentResponse struct with only assessment_type, assessment_date, result (no clinical details).
Step 15: Event Publishing
Files:
-
services/canopy-medicaid/src/events.rs(new or update existing stub)
Implement FTI-scrubbed, HIPAA-compliant event publishers from the Design section.
Apply scrub_fti_fields and check_hipaa_compliance as defense-in-depth.
Three events: medicaid.determined, chip.determined, medicaid.cmd_cascade_completed.
Step 16: Additional API Endpoints
Files:
-
services/canopy-medicaid/src/api/determinations.rs(new) -
services/canopy-medicaid/src/api/categories.rs(new) -
services/canopy-medicaid/src/api/params.rs(new)
GET endpoints from the API Endpoints table.
All responses implement HipaaMinimumNecessary.
FTI audit log endpoints follow the fti-audit-logging plan.
Step 17: Rules Client
Files:
-
services/canopy-medicaid/src/rules_client.rs(new)
HTTP client for canopy-rules with four ruleset evaluations:
pub struct MedicaidRulesClient {
http: reqwest::Client,
rules_base_url: String,
}
#[derive(Debug, Serialize)]
pub struct MagiInput {
pub household_size: i32,
pub magi_income: Decimal,
pub fpl_5_percent_disregard: Decimal,
pub coa_code: String,
pub fpl_threshold: Decimal,
pub applicant_age: i32,
pub is_pregnant: bool,
pub expected_children: i32,
}
#[derive(Debug, Deserialize)]
pub struct MagiOutput {
pub eligible: bool,
pub fpl_percentage: Option<Decimal>,
pub fpl_threshold: Option<Decimal>,
pub income_disregard_applied: bool,
pub denial_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct NonMagiInput {
pub coa_code: String,
pub countable_income: Decimal,
pub countable_resources: Decimal,
pub resource_limit: Decimal,
pub income_limit: Decimal,
pub federal_benefit_rate: Option<Decimal>,
pub disability_status: Option<String>,
pub medicare_enrolled: bool,
pub medicare_parts: Option<String>,
pub age: i32,
pub level_of_care_met: Option<bool>,
pub length_of_stay_met: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct NonMagiOutput {
pub eligible: bool,
pub resource_test_passed: bool,
pub income_test_passed: bool,
pub spend_down_amount: Option<Decimal>,
pub denial_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ChipInput {
pub child_person_id: Uuid,
pub household_magi: Decimal,
pub household_size: i32,
pub child_age: i32,
}
#[derive(Debug, Deserialize)]
pub struct ChipOutput {
pub eligible: bool,
pub chip_type: String,
pub fpl_percentage: Decimal,
pub denial_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct HierarchyOutput {
pub assigned_coa: String,
pub assigned_coa_track: String,
pub fmap_rate: Decimal,
pub basis: String,
}
impl MedicaidRulesClient {
pub fn new(http: reqwest::Client, rules_base_url: String) -> Self {
Self { http, rules_base_url }
}
pub async fn evaluate_magi(&self, input: MagiInput) -> Result<MagiOutput, RulesError> {
let url = format!("{}/v1/evaluate", self.rules_base_url);
let body = serde_json::json!({
"ruleset": "medicaid-magi",
"input": input,
});
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(RulesError::EvaluationFailed { status, body: text });
}
Ok(resp.json().await?)
}
pub async fn evaluate_non_magi(&self, input: NonMagiInput) -> Result<NonMagiOutput, RulesError> {
let url = format!("{}/v1/evaluate", self.rules_base_url);
let body = serde_json::json!({
"ruleset": "medicaid-non-magi",
"input": input,
});
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(RulesError::EvaluationFailed { status, body: text });
}
Ok(resp.json().await?)
}
pub async fn evaluate_hierarchy(&self, input: serde_json::Value) -> Result<HierarchyOutput, RulesError> {
let url = format!("{}/v1/evaluate", self.rules_base_url);
let body = serde_json::json!({
"ruleset": "medicaid-eligibility-hierarchy",
"input": input,
});
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(RulesError::EvaluationFailed { status, body: text });
}
Ok(resp.json().await?)
}
pub async fn evaluate_chip(&self, input: ChipInput) -> Result<ChipOutput, RulesError> {
let url = format!("{}/v1/evaluate", self.rules_base_url);
let body = serde_json::json!({
"ruleset": "chip-eligibility",
"input": input,
});
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(RulesError::EvaluationFailed { status, body: text });
}
Ok(resp.json().await?)
}
}
#[derive(Debug, thiserror::Error)]
pub enum RulesError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Rules evaluation failed: status={status}, body={body}")]
EvaluationFailed { status: reqwest::StatusCode, body: String },
}
Step 18: JDM Rulesets
Files:
-
rulesets/georgia/medicaid-magi.json(new) -
rulesets/georgia/medicaid-non-magi.json(new) -
rulesets/georgia/medicaid-eligibility-hierarchy.json(new) -
rulesets/georgia/chip-eligibility.json(new)
Each ruleset is a JDM file evaluated by canopy-rules per ADR-003. All eligibility logic (FPL comparisons, resource tests, spenddown calculations, age thresholds) lives in rulesets, not in Rust code.
Ruleset parameters (FPL thresholds, FBR amounts, resource limits, premium tiers) are loaded from jurisdiction.toml and injected as input to the rules engine. The rulesets reference these injected parameters rather than hardcoding values.
Step 19: Integration Tests
Files:
-
services/canopy-medicaid/tests/magi_household.rs(new) -
services/canopy-medicaid/tests/magi_budget.rs(new) -
services/canopy-medicaid/tests/cmd_cascade.rs(new) -
services/canopy-medicaid/tests/hierarchy.rs(new) -
services/canopy-medicaid/tests/chip.rs(new) -
services/canopy-medicaid/tests/pathways.rs(new) -
services/canopy-medicaid/tests/hipaa.rs(new) -
services/canopy-medicaid/tests/determine.rs(new)
MAGI Household Tests
#[test]
fn tax_filer_bg_includes_spouse_even_if_not_filing_jointly() {
// PAMMS 2610: "his or her spouse living in the home even if not filing jointly"
let bg = build_magi_budget_group(
filer_id, true, Some(filer_id), Some(spouse_id),
vec![dep1_id], vec![], vec![], vec![],
vec![], &HashMap::new(), vec![],
);
assert!(bg.bg_members.contains(&spouse_id));
assert_eq!(bg.household_size, 3); // filer + spouse + dependent
}
#[test]
fn non_filer_bg_includes_children_under_19_and_parents() {
// PAMMS 2610 Step 3
let bg = build_magi_budget_group(
child_id, false, None, None,
vec![], vec![child_id, sibling_id], vec![parent_id], vec![sibling_id],
vec![], &HashMap::new(), vec![],
);
assert!(bg.bg_members.contains(&parent_id));
assert!(bg.bg_members.contains(&sibling_id));
}
#[test]
fn unborn_children_increase_bg_size() {
// PAMMS 2610: "any unborn child of an individual included in the BG who is pregnant"
let mut expected = HashMap::new();
expected.insert(mother_id, 2); // twins
let bg = build_magi_budget_group(
mother_id, true, Some(mother_id), None,
vec![], vec![], vec![], vec![],
vec![mother_id], &expected, vec![],
);
assert_eq!(bg.unborn_count, 2);
assert_eq!(bg.household_size, 3); // mother + 2 unborn
}
#[test]
fn ssi_recipients_in_bg_but_income_excluded() {
// PAMMS 2610: "SSI recipients must be included in the BG...but income is not counted"
let bg = build_magi_budget_group(
parent_id, true, Some(parent_id), None,
vec![ssi_child_id], vec![], vec![], vec![],
vec![], &HashMap::new(), vec![ssi_child_id],
);
assert!(bg.bg_members.contains(&ssi_child_id));
assert!(bg.ssi_recipient_ids.contains(&ssi_child_id));
assert_eq!(bg.household_size, 2);
}
MAGI Budgeting Tests
#[test]
fn magi_budget_applies_self_employment_deductions() {
// PAMMS 2669 Step 1
let income = vec![MagiIncome {
monthly_amount: Decimal::from(3000),
self_employment_expenses: Some(Decimal::from(500)),
before_tax_deduction_amount: Some(Decimal::ZERO),
magi_adjustment_amount: Some(Decimal::ZERO),
is_countable: true,
is_child_dependent_excluded: false,
..test_income()
}];
let budget = assemble_magi_budget(&income, None, &[], Decimal::from(100));
assert_eq!(budget.self_employment_deductions, Decimal::from(500));
assert_eq!(budget.net_taxable_monthly, Decimal::from(2500));
}
#[test]
fn magi_budget_uses_fti_when_available() {
// When FTI-verified data exists, use it instead of self-reported
let fti = vec![FtiTaxData {
adjusted_gross_income: Some(Decimal::from(36000)),
tax_exempt_interest: Some(Decimal::from(600)),
foreign_earned_income: Some(Decimal::ZERO),
social_security_benefits: Some(Decimal::from(2400)),
taxable_social_security: Some(Decimal::from(1200)),
..test_fti()
}];
let income = vec![test_income()]; // self-reported, should be ignored
let budget = assemble_magi_budget(&income, Some(&fti), &[], Decimal::from(100));
assert!(budget.fti_verified);
// MAGI = AGI + tax-exempt + foreign + non-taxable SS
// = 36000 + 600 + 0 + (2400 - 1200) = 37800 / 12 = 3150/month
assert_eq!(budget.net_taxable_monthly, Decimal::from(3150));
}
#[test]
fn magi_budget_excludes_ssi_recipient_income() {
// PAMMS 2610: SSI recipient income not counted
let income = vec![
MagiIncome { person_id: parent_id, monthly_amount: Decimal::from(2000), is_countable: true, ..test_income() },
MagiIncome { person_id: ssi_child_id, monthly_amount: Decimal::from(800), is_countable: true, ..test_income() },
];
let budget = assemble_magi_budget(&income, None, &[ssi_child_id], Decimal::from(100));
assert_eq!(budget.gross_taxable_monthly, Decimal::from(2000)); // SSI child excluded
}
CMD Cascade Tests
#[tokio::test]
async fn cmd_cascade_evaluates_all_coas_before_denial() {
// PAMMS 2052: "Eligibility must be reviewed under all Medicaid COAs before denying"
// Setup: applicant ineligible for everything
// Assert: all COAs evaluated, all recorded in cmd_cascade_log
let result = run_cmd_cascade(/* ... ineligible context ... */).await.unwrap();
assert!(result.eligible_coas.is_empty());
assert!(result.all_evaluated.len() >= 10); // at least 10 COAs evaluated
assert_eq!(result.cascade_log.len(), result.all_evaluated.len());
}
#[tokio::test]
async fn cmd_cascade_family_order_matches_pamms_2052() {
// Verify the Family Medicaid cascade follows the exact PAMMS 2052 order:
// Newborn → Pregnant Women → Parent/Caretaker → TMA/4MEx →
// Children Under 19 → PeachCare → FM-MN → WHM → Pathways → P4HB
let result = run_cmd_cascade(/* ... */).await.unwrap();
let family_entries: Vec<_> = result.cascade_log.iter()
.filter(|e| e.track == "family")
.collect();
// Verify ordering is monotonically increasing
for window in family_entries.windows(2) {
assert!(window[0].order <= window[1].order);
}
}
#[tokio::test]
async fn cmd_cascade_children_age_tiered_thresholds() {
// PAMMS 2182/2669: income limits vary by age
// Setup: 2 children, age 3 (149% FPL) and age 10 (133% FPL)
// Income at 140% FPL
// Assert: 3-year-old eligible (140% < 149%), 10-year-old ineligible (140% > 133%)
}
#[tokio::test]
async fn cmd_cascades_from_children_to_peachcare() {
// PAMMS 2182: "When eligibility is denied/closed because the MAGI income is above
// the Medicaid income limit, but at or below the PeachCare for Kids income limit,
// a system CMD will be completed to PeachCare for Kids."
// Setup: child age 10, income at 200% FPL (above 133% Medicaid, below 247% CHIP)
// Assert: children_under_19 = denied, peachcare = approved
}
Hierarchy Tests
#[tokio::test]
async fn hierarchy_selects_most_advantageous_coa() {
// Setup: applicant eligible for Parent/Caretaker AND Pathways
// Assert: Parent/Caretaker assigned (more advantageous)
let eligible = vec![
test_category("pathways", true),
test_category("parent_caretaker", true),
];
let result = evaluate_hierarchy(&eligible, &mock_rules_client).await.unwrap();
assert_eq!(result.assigned_coa, "parent_caretaker");
}
#[tokio::test]
async fn hierarchy_no_eligible_categories_returns_error() {
let eligible: Vec<MedicaidEligibleCategory> = vec![];
let result = evaluate_hierarchy(&eligible, &mock_rules_client).await;
assert!(matches!(result, Err(HierarchyError::NoEligibleCategories)));
}
#[tokio::test]
async fn qi_1_dual_eligibility_exception_with_amn() {
// PAMMS 2052: "QI-1 recipients cannot be dually eligible ongoing
// with another COA with exception of AMN."
// The rules engine handles this exception.
}
PeachCare Tests
#[tokio::test]
async fn peachcare_medicaid_first_screening() {
// CHIPRA: screen for Medicaid first
// Setup: child is Medicaid-eligible
// Assert: PeachCare not evaluated
let result = evaluate_peachcare(
/* ... */ medicaid_child_eligible: true, /* ... */
).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn peachcare_premium_tier_calculation() {
// PAMMS 2194: 134-158% FPL = $11/$16
let result = determine_peachcare_premium(
Decimal::from(150), 8, false, false, &test_premium_schedule(),
);
assert_eq!(result.one_child_premium, Decimal::new(1100, 2));
assert_eq!(result.family_cap_premium, Decimal::new(1600, 2));
}
#[tokio::test]
async fn peachcare_premium_exempt_under_6() {
// PAMMS 2194: "No premium or co-payment is charged for children under age six"
let result = determine_peachcare_premium(
Decimal::from(200), 4, false, false, &test_premium_schedule(),
);
assert!(result.exempt);
assert_eq!(result.exemption_reason, Some("under_6".to_string()));
assert_eq!(result.one_child_premium, Decimal::ZERO);
}
#[tokio::test]
async fn peachcare_no_prior_month_coverage() {
// PAMMS 2194: "PCK does NOT provide prior month coverage."
// Verify enrollment_effective_date is month of application, not retroactive.
}
Pathways Tests
#[tokio::test]
async fn pathways_requires_80_hours_qualifying_activities() {
// PAMMS 2195: "at least 80 hours per month of a qualifying activity"
let activities = vec![
PathwaysQualifyingActivity { activity_type: "unsubsidized_employment".into(), hours_per_month: Decimal::from(60), ..default() },
PathwaysQualifyingActivity { activity_type: "community_service".into(), hours_per_month: Decimal::from(10), ..default() },
];
let result = validate_pathways_activities(&activities);
assert!(!result.meets_threshold); // 70 < 80
assert_eq!(result.total_hours_per_month, Decimal::from(70));
}
#[tokio::test]
async fn pathways_ineligible_if_other_coa_eligible() {
// PAMMS 2195: "not eligible for any other Medicaid class of assistance"
let result = evaluate_pathways(
/* ... */ other_coas_eligible: true, /* ... */
).await.unwrap();
assert!(!result.eligible);
assert_eq!(result.denial_reason, Some("eligible_for_other_coa".to_string()));
}
#[tokio::test]
async fn pathways_age_range_19_to_64() {
// PAMMS 2195: "adults age 19 through 64"
let result_under = evaluate_pathways(/* ... person_age: 18 ... */).await.unwrap();
assert!(!result_under.eligible);
let result_over = evaluate_pathways(/* ... person_age: 65 ... */).await.unwrap();
assert!(!result_over.eligible);
}
#[tokio::test]
async fn pathways_hipp_referral_when_esi_access() {
// PAMMS 2195: "A/Rs with access to ESI must enroll in the Pathways HIPP program"
let result = evaluate_pathways(/* ... has_esi_access: true ... */).await.unwrap();
// Verify pathways_hipp_referrals record created
}
HIPAA Tests
#[tokio::test]
async fn hipaa_determination_response_contains_no_clinical_data() {
// GET /v1/determinations/{id} must not contain clinical details
let response = get_determination(det_id).await;
let json = serde_json::to_value(&response).unwrap();
let violations = check_hipaa_compliance(&json);
assert!(violations.is_empty());
}
#[test]
fn hipaa_compliance_check_detects_violations() {
let payload = serde_json::json!({
"status": "approved",
"clinical_notes": "Patient has...",
"diagnosis_code": "E11.9"
});
let violations = check_hipaa_compliance(&payload);
assert_eq!(violations.len(), 2);
}
#[test]
fn hipaa_compliance_clean_payload_passes() {
let payload = serde_json::json!({
"status": "approved",
"assigned_coa": "parent_caretaker",
"determined_at": "2026-04-07T14:22:33Z"
});
let violations = check_hipaa_compliance(&payload);
assert!(violations.is_empty());
}
Full Determination Tests
#[tokio::test]
async fn full_determination_magi_parent_approved() {
// Setup: testcontainers + mock rules, parent with income at 30% FPL
// Act: POST /v1/determine
// Assert: status=approved, assigned_coa=parent_caretaker, valid JWS signature
}
#[tokio::test]
async fn full_determination_denied_with_full_cascade() {
// Setup: applicant ineligible for all COAs
// Act: POST /v1/determine
// Assert: status=denied, cmd_cascade_log has entries for all evaluated COAs
}
#[tokio::test]
async fn fti_audit_logged_under_irc_6103_l_12() {
// Act: POST /v1/determine
// Assert: fti_audit_log has entry with purpose_code=MEDICAID_MAGI
}
#[tokio::test]
async fn events_contain_no_restricted_data() {
// Act: POST /v1/determine
// Assert: medicaid.determined event has NO income, NO FTI, NO clinical data
let event = capture_event("medicaid.determined").await;
let violations = check_hipaa_compliance(&event);
assert!(violations.is_empty());
let fti_violations = scrub_fti_fields_check(&event);
assert!(fti_violations.is_empty());
}
#[tokio::test]
async fn child_cascades_medicaid_to_peachcare() {
// Setup: child age 10, income at 200% FPL
// Assert: children_under_19 denied (200% > 133%), peachcare approved (200% < 247%)
// Assert: chip.determined event published
}
#[tokio::test]
async fn pregnant_woman_220_fpl_threshold() {
// Setup: pregnant woman, income at 210% FPL, BG size includes unborn
// Assert: pregnant_women COA approved (210% < 220%)
}
Files Touched
| File | Change |
|---|---|
|
New: all Medicaid/CHIP/Pathways tables and indexes |
|
Wire migrations, signing key, FTI audit logger, rules client, HIPAA enforcement, params loader |
|
New: module declarations for all store submodules |
|
New: MedicaidApplication, MagiHouseholdSnapshot, MagiIncome, NonMagiFactors, FtiTaxData, FdshResult, ClinicalAssessment, MedicaidEligibleCategory, ChipApplication, PeachcarePremiumSchedule, PathwaysQualifyingActivity, PathwaysHippReferral, MedicaidDetermination, CmdCascadeLog |
|
New: read_fti_for_magi, read_fti_for_eligibility, read_fti_for_chip (all FTI-audited) |
|
New: insert_fdsh_result, get_fdsh_results |
|
New: insert_clinical_assessment, get_clinical_assessments (write-once, HIPAA) |
|
New: create_application, update_status, get_application |
|
New: create_magi_snapshot, get_magi_snapshot |
|
New: create_magi_income, get_income_for_application |
|
New: insert_eligible_category, get_eligible_categories, get_eligible_categories_for_person |
|
New: create_chip_application, get_chip_applications |
|
New: insert_qualifying_activity, create_hipp_referral |
|
New: insert_determination, get_determination, get_determinations_by_application |
|
New: insert_cascade_entry, get_cascade_log |
|
New: get_premium_schedule, seed_premium_schedule |
|
New: build_magi_budget_group, MagiBudgetGroup (PAMMS 2610) |
|
New: assemble_magi_budget, MagiBudgetInput (PAMMS 2669) |
|
New: evaluate_abd_cascade, evaluate_single_abd_coa (PAMMS 2052/2101) |
|
New: evaluate_family_magi_cascade (PAMMS 2052/2162/2182/2184) |
|
New: evaluate_family_non_magi_cascade (PAMMS 2174/2196/2198/2186/2166/2170) |
|
New: evaluate_peachcare, determine_peachcare_premium (PAMMS 2194) |
|
New: evaluate_pathways, validate_pathways_activities (PAMMS 2195) |
|
New: run_cmd_cascade, CmdCascadeResult (PAMMS 2052) |
|
New: MedicaidCategory enum, evaluate_hierarchy, assign_most_advantageous_coa, HierarchyResult |
|
New: handle_determine (complete determination flow), ApplicationContext |
|
New: MedicaidRulesClient, MagiInput/Output, NonMagiInput/Output, ChipInput/Output, HierarchyOutput |
|
New: HipaaMinimumNecessary trait, check_hipaa_compliance, HIPAA_RESTRICTED_FIELDS |
|
New: MedicaidDeterminedEvent, ChipDeterminedEvent, CmdCascadeCompletedEvent, FTI-scrubbed publishers |
|
Wire all routes |
|
New: get_determination, get_determination_explanation, get_determination_categories, get_cmd_cascade |
|
New: get_params (FPL thresholds, FBR, premium schedule) |
|
Add canopy-signing, canopy-common (fti_audit, mq), reqwest, chrono, rust_decimal, uuid, serde, sqlx, thiserror |
|
New: MAGI eligibility ruleset with PAMMS 2669 budgeting logic |
|
New: non-MAGI eligibility ruleset with ABD COA logic |
|
New: EE15 hierarchy ruleset with QI-1/AMN dual-eligibility exception |
|
New: CHIP eligibility with premium tier assignment |
|
Add |
Verification
-
cargo nextest run -p canopy-medicaid— all unit tests pass (MAGI household, MAGI budget, premium calculation, Pathways activity validation, HIPAA compliance, hierarchy ordering) -
cargo xtask dev restart— migrations run, all 14 tables created with indexes -
cargo nextest run -p canopy-medicaid --profile integration— all CMD cascade, hierarchy, CHIP, Pathways, HIPAA, FTI audit, and event tests pass -
Manual: POST
/v1/determinewith parent/caretaker at 30% FPL, verify signed determination withassigned_coa=parent_caretaker -
Manual: POST with child age 10 at 200% FPL, verify CMD cascades from
children_under_19(denied at 133%) topeachcare(approved at 247%) with correct premium tier -
Manual: POST with pregnant woman at 210% FPL, verify
pregnant_womenCOA approved (210% < 220%), BG size includes unborn count -
Manual: POST with applicant eligible for multiple COAs, verify EE15 hierarchy selects most advantageous
-
Manual: POST with Pathways-eligible adult (19-64, 95% FPL, 80 hrs/month activities), verify Pathways COA approved
-
Manual: POST with Pathways applicant who has ESI access, verify
pathways_hipp_referralsrecord created -
Manual: Query FTI audit log (
GET /v1/fti-audit-log), verify access logged withpurpose_code=MEDICAID_MAGIandoriginating_system=canopy-medicaid -
Manual: Inspect
medicaid.determinedevent in RabbitMQ, confirm payload contains NO FTI fields, NO FDSH details, NO clinical data -
Manual: Inspect
medicaid.cmd_cascade_completedevent, verifycoas_evaluatedcount matches expected -
Manual: GET
/v1/determinations/{id}, verify response contains no clinical details (HIPAA minimum necessary) -
Manual: GET
/v1/determinations/{id}/cmd-cascade, verify complete cascade audit trail with evaluation order -
cargo clippy -p canopy-medicaid — -D warnings— no warnings -
cargo fmt --check -p canopy-medicaid— formatted
Documentation Updates
-
.claude/docs/services.md— add canopy-medicaid: 9 domain routes, 14 tables, 4 rulesets, 3 events, MAGI/non-MAGI dual track, CMD cascade, 38 COAs -
CHANGELOG.adoc— entry under== Unreleased -
.claude/docs/security.md— document Medicaid FTI handling (IRC 6103(l)(12)), HIPAA minimum necessary enforcement, FDSH data isolation per ADR-004 -
.claude/docs/architecture.md— document MAGI budget group composition (PAMMS 2610), MAGI budgeting procedure (PAMMS 2669), CMD cascade (PAMMS 2052), EE15 hierarchy, PeachCare premium schedule, Pathways qualifying activities -
rulesets/georgia/jurisdiction.toml— add complete[medicaid]configuration section