canopy-medicaid API Reference
On this page
Overview
Cross-link: canopy-medicaid Data Model (#419)
Medicaid + CHIP program service. Evaluates all 38 PAMMS-defined Categories of Assistance via the CMD (Categories of Medicaid Determination) cascade + EE15 38-COA priority hierarchy. Tracks Transitional Medicaid (TMA) cross-program coverage from canopy-tanf case closures, Express Lane Eligibility evaluations from SNAP/TANF approvals, and an IRC §6103(l)(12)-scoped FTI audit log (ADR-004 + ADR-014). HIPAA-scoped data; FDSH integration in scope.
All eligibility logic runs through the rules engine (ADR-003) — 4 JDM rulesets (medicaid-magi, medicaid-non-magi, chip-eligibility, medicaid-eligibility-hierarchy) cover every COA. 165 PAMMS citations traced via cargo xtask policy audit.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
Varies per endpoint
- Swagger UI
- Database
-
canopy_medicaid(isolated per ADR-001; FTI scope under IRC §6103(l)(12), FDSH per CMS guidance, HIPAA-scoped per ADR-004)
Receiver contract (OIDC S-medicaid, #1426 / ADR-043 §C)
canopy-medicaid is the second service on the ADR-043 receiver contract
(canopy_auth::ReceiverContract), following the canopy-tanf template —
see the tanf API page for the full
bearer-shape and guard-family description. The medicaid specifics:
-
require_service_or_exchangedonPOST /v1/determine— the orchestrator’s service token, or an exchanged user-context token (aud=canopy-medicaidexactly, allowlistedazp, caseworker-or-above role). CHIP dispatch stays service-class: the hop-2 audience derivation would mintaud=canopy-chip, which this single-audience gate rejects. -
require_user_onlyon six routes: the FTI audit log ×3 (fti_auditor), redaction (data_steward), and the two ELE ops routes —POST /v1/ele/{person_id}/revokeandPOST /v1/ele/renewals/run(adminorquality_control). Service bearers are always 403; underCANOPY_MEDICAID__ENFORCE_USER_ONLY_ROUTES=trueonly an exchanged per-target token carrying the named role passes. -
Attribution via
EffectiveUseron the determine FTIaccessed_by, the redaction event actor, and the ELE revokeactor_id— an exchanged bearer attributes its own preservedsub(noX-Canopy-Actorheader — retired fleet-wide by #1443; the middleware 401s any request carrying it).
Receiver knobs (CANOPY_MEDICAIDACCEPT_OWN_AUDIENCE /
AUTHORIZED_EXCHANGER_AZPS / __ENFORCE_USER_ONLY_ROUTES) are
documented in the configuration
reference; conformance coverage is the F4 matrix, activated for
canopy-medicaid.
Determination
POST /v1/determine
Run a Medicaid/CHIP eligibility determination. Called by canopy-eligibility orchestrator.
Minimum role: service-or-exchanged (#1426): the orchestrator’s service token, or an exchanged user-context token (aud=canopy-medicaid exactly, allowlisted azp, caseworker-or-above role). FTI access attribution (accessed_by) resolves via EffectiveUser.
Request: ApplicationContext carrying a members[] list. Medicaid is multi-subject (ADR-035): the handler runs the cascade per member, scoring each on that member’s own age + disability; an empty members[] falls back to a single applicant determination. Income stays household-level (per-member budget-group composition is deferred to #864).
The handler, per member:
-
Builds the MAGI household per 42 CFR 435.603 OR non-MAGI household per state plan
-
Runs MAGI / non-MAGI / CHIP rulesets in parallel via
tokio::try_join! -
Evaluates Q-Track (QMB / SLMB / QI-1) with resource tests
-
Applies MN spenddown for FM-MN / Pregnant-MN / AMN paths
-
Runs the EE15 38-COA hierarchy to pick the single best-fit COA
-
Records every COA evaluated (eligible or not) in
coa_evaluationsfor audit -
Assembles the determination input snapshot (ADR-028 / T2-4), per member: the CMD cascade priority order + per-COA magi/non-MAGI/CHIP/TMA results + per-COA denial-reason evals + the EE15 hierarchy result +
assigned_coa+countable_resources+ member SOLQ flags, the income/expense facts + whole-household composition, the resolved policy params, and the ruleset corpus-hash. When SOLQ was supplied it also freezes a typedcross_program_inputs.solqby-value projection of the rawSolqRecord(s) — the cross-program source behind the derived ABD flags (T2-3 #684) — and marks the snapshotschema_version: 2. Its SHA-256 (RFC 8785 canonical) becomes thesnapshot_hash -
Signs the determination with ECDSA P-256,
person_idset before signing — the signature now coverssnapshot_hash(ADR-002 / ADR-035 MR1 / ADR-028) -
Persists each member’s snapshot immutably in
determination_snapshots. Medicaid is FTI-bearing (IRC §6103(l)(12)), so each snapshot’s creation additionally joins the ADR-014fti_audit_loghash chain (resource_type='determination_snapshot', one entry per member determination) — the FTI-derived artifact at rest inherits the Pub 1075 §4 tamper-evidence + §9 breach pathway
All N per-member determinations + their COA-evaluation rows + their input snapshots + the per-member FTI chain entries persist in one transaction (all-or-nothing). An invalid or duplicate member person_id returns 422.
Bearer token is forwarded per-call (no shared RwLock race condition).
SSA SOLQ input (#384): the orchestrator may populate ApplicationContext.ssa_solq: Option<HashMap<Uuid, SolqRecord>> pre-dispatch for elderly / disabled applicants. When present, derive_abd_flags_from_solq(map, applicant_id) projects the SSA record onto the five Phase D booleans (lost_ssi_due_to_cola, is_disabled_adult_child, is_disabled_widow, is_widow_60_64, lost_ssi_as_disabled_child) consumed by the medicaid-non-magi ruleset. The pre-existing Option<bool> ApplicationContext fields stay as an override channel (test fixtures, manual worker overrides) and win when present; SOLQ derivation is the implicit default. When SOLQ is unconfigured or returns no record, all five flags fall back to false — exact match for the pre-#384 behaviour. Per ADR-004, raw SOLQ records are stored only in this service’s database and are never re-emitted to the orchestrator. As of T2-3 (#684) the raw record is additionally frozen by value into the determination snapshot’s cross_program_inputs.solq (still DB-local, never on the wire), so the verdict reproduces even if the SOLQ→flag derivation later changes.
Response (200): DeterminationList — one signed SignableDetermination envelope per member (ADR-035), each carrying its person_id; per ADR-002 (selected COA in program_extension, status, basis, benefit fields, JWS signature, and the snapshot_hash binding that member’s input snapshot per ADR-028). The orchestrator verifies each signature against this same shape and receives only outcome + hash, never the snapshot cleartext (which stays inside canopy-medicaid’s Pub 1075 boundary); the per-program MedicaidDeterminationRead rows are what the read endpoints below return.
Response (503, dormant until #1279): when the chain-v2 FTI append arm is enabled (CANOPY_MEDICAID__CHAIN_V2_APPEND_ENABLED, #1207 / ADR-014 Amendment 7) and the append hits an Environment-class refusal — topology missing, epoch not active/current, routing-version skew, or a malformed source registry — the whole multi-member determination aborts fail-closed (no Pub 1075 snapshot can persist without its chain row) with the fixed detail audit chain unavailable; the environment specifics go to logs only. With the flag off (the default), this arm is unreachable.
GET /v1/determinations
List determinations newest-first, keyset-paginated (#1195).
Minimum role: caseworker.
Query parameters: limit (default 50, max 200), after_determined_at + after_id (keyset cursor — pass the previous page’s next_cursor fields together; omit both for the first page), month (optional, any day in the month).
The list is ordered (determined_at DESC, id DESC) — newest first, with the UUID-v7 id as a stable tiebreak — and keyset-paginated over that compound cursor (there is no offset; the old hard LIMIT 200 cap that silently truncated the T-MSIS federal universe is gone; the previously-documented household_id/person_id filters were never honored by the handler and are not part of the contract — cross-program callers filter client-side). The default page rides idx_medicaid_determinations_determined_at_id, an index scan with no top-N sort. month scopes the universe to determinations whose coverage window [effective_date, expiration_date] overlaps that calendar month (the T-MSIS monthly-eligibility universe); when set, the first page carries total_in_scope (the authoritative scoped COUNT(*)) so a page-looping extractor can assert completeness. The month scope is service-caller-only (#1249, ADR-001 Amendment 1 least privilege): the completeness universe belongs to the reporting extractor — an interactive caller gets 403 and uses the unscoped list.
Response (200): MedicaidDeterminationPage — items (array of MedicaidDeterminationRead, each the MedicaidDetermination fields flattened including the required snapshot_hash) + next_cursor ({after_determined_at, after_id} while a full page may have more; null at the end) + total_in_scope (first page of a month-scoped query only; null otherwise).
GET /v1/determinations/{id}
Fetch a determination by ID.
Minimum role: caseworker.
Response (200): MedicaidDeterminationRead — the MedicaidDetermination fields flattened, including the required snapshot_hash (#911: legacy pre-snapshot rows were deleted and the ADR-028 §58 snapshot_status marker retired). Returns 404 if no determination exists for the supplied ID.
GET /v1/applications/{id}/categories
List every COA evaluated for the Medicaid application — both eligible and ineligible. Each row includes the rule-trace pointer used (which ruleset, which version) so caseworkers can answer "why this COA, why not that one." Source-of-truth for hearings. The {id} is the medicaid_application_id (#1011 moved this from /determinations/{id}/categories, where the URL implied a determination id but the handler keyed on the application id — a natural-reading caller silently got an empty list).
Minimum role: caseworker.
GET /v1/determinations/{id}/explanation
Human-readable explanation of the determination — selected COA, basis, thresholds compared. Useful for case notes and notice generation.
Minimum role: caseworker.
POST /v1/determinations/{id}/redact
Crypto-shred a determination’s frozen input snapshot (T2-6 #687, ADR-036). The per-determination DEK in redaction_keys is tombstoned (its wrapped_dek overwritten with a zero sentinel + shredded_at stamped), so every sealed leaf becomes permanently unrecoverable, while the snapshot ciphertext and the signed snapshot_hash are left untouched — the snapshot still re-hashes to the signed value and the determination’s JWS stays verifiable (hash-over-ciphertext, ADR-036 Decision B). Only the plaintext PII is destroyed. Medicaid is FTI-bearing, so the determination.redacted event is plaintext-free (IDs + actor + reason only, ADR-004).
Minimum role: data_steward only — a dedicated, privileged, irreversible role for redaction/expungement. Admins do NOT auto-hold it (separation of duties, mirroring fti_auditor). A require_user_only route (#1426): service-class bearers are always 403; under enforce_user_only_routes the bearer must be an exchanged per-target token carrying the role.
Request:
{
"reason": "..."
}
reason is mandatory; a blank reason is rejected with HTTP 400.
The shred and a plaintext-free determination.redacted audit event (carrying the steward’s sub + the reason) commit in one transaction (ADR-018); canopy-security audits it via the existing wildcard subscriber.
Response (200): { "determination_id": "…", "redacted_at": "…" }. Returns 400 on a blank reason, 403 if the caller lacks the data_steward role, and 404 for an unknown determination. Idempotent: re-redacting an already-shredded determination tombstones 0 rows and still returns 200.
The path uses the sub-resource form …/{id}/redact (mirroring snap’s reference impl), not the AIP-136 custom-method …/{id}:redact — axum/matchit 0.8 allows only one parameter per path segment.
Cross-Program Coverage
FTI Audit Log (IRC §6103(l)(12) / Pub 1075)
ADR-014 hash-chain integrity applies — same pg_advisory_xact_lock(2) + canonical-timestamp pattern as canopy-tanf. Chain breaks emit fti.audit_chain.breach_detected and force 503 from GET /v1/security/chain/status?family=fti&service=canopy-medicaid on canopy-security (#1206 MR-3; a latched legacy v1 breach surfaces there as breached / legacy_breach_latched).
GET /v1/fti-audit-log
List FTI audit entries. IRS auditor access only.
Minimum role: fti_auditor (dedicated role; admins do NOT auto-hold it). A require_user_only route (#1426): service-class bearers are always 403; under enforce_user_only_routes only an exchanged per-target token carrying the role passes.
Query parameters: from, to, actor_id, event_type, limit, offset.
Overpayment Recovery (42 CFR 433.300)
Cross-program shape — same surface on canopy-snap (/v1/overpayments) and canopy-tanf (/v1/overpayments), with per-service data isolation per ADR-001. Types shared from the canopy-overpayments crate. Ledger is the system of record; outstanding balance is derived (not stored). Claims auto-open from appeal.overpayment_assessed events (see canopy-appeals).
POST /v1/overpayments
File an overpayment claim.
Minimum role: caseworker (or service-class token).
Request:
{
"person_id": "uuid",
"household_id": "uuid",
"determination_id": "uuid",
"claim_amount_cents": 50000,
"claim_basis": "agency_error",
"error_type": "coa-misclassification",
"discovered_at": "2026-05-01",
"discovered_by": "uuid"
}
claim_basis ∈ agency_error / inadvertent_household_error / ipv. claim_amount_cents > 0.
Response 201: the persisted OverpaymentClaim row.
GET /v1/overpayments[?status=…&limit=…&after_created_at=…&after_id=…]
One keyset page of claims WITH server-side ledger totals (#1222) — same envelope and semantics as the canopy-snap page (see the SNAP API page); the three services stay byte-identical.
status ∈ open / in_repayment / closed / written_off.
POST /v1/overpayments/{id}/repayment-plans
Attach a repayment plan. HTTP 409 if claim is closed or written_off.
CMD change-report pipeline (#575, epic &77) + Determination Requeue (#448)
The CMD ingest is the head of the change-report pipeline (#1506): one
transaction commits the lifecycle row (state requested, the PAMMS 2750
10-day deadline_at) together with a determination.requested::Order
(origin ("medicaid-cmd", cmd_event_id), as_of = the change’s effective
date, the signed change-report trigger). canopy-eligibility’s durable
order substrate executes the signed re-determination; the
canopy-medicaid.cmd-settle consumer completes the row off the
origin-echoing determination.completed. Backs the #1507 two-step BFF
action (actions_medicaid::ingest_cmd_update_medicaid — the disability
fact writes through canopy-persons first).
POST /v1/cmd/ingest
Guard: ADR-043 receiver contract — service class OR an exchanged
worker bearer (caseworker+); attribution is the verified EffectiveUser
identity, never a request field.
Request:
{
"person_id": "uuid",
"household_id": "uuid",
"application_id": "uuid",
"cmd_event_type": "ssi_terminated",
"effective_date": "2026-08-01",
"notes": "DCH SSI-termination feed"
}
cmd_event_type is the closed canopy_reference::CmdEventType vocabulary.
Response (202): the lifecycle handle (state, deadline_at,
processed_at = null until settled). The response is a handle, not an
outcome — poll GET /v1/cmd/events.
GET /v1/cmd/events?household_id=
The household’s CMD lifecycle rows, newest first (bounded at the 100 most recent). Guard: service class or exchanged caseworker+. Backs the worker portal’s determination-tab status table.
GET /v1/cmd/escalations
The cross-household escalation feed (#1511): unresolved rows whose PAMMS
2750 deadline is inside the [medicaid].cmd_escalation_warning_days
window (overdue included) plus state='failed' rows, most-urgent
deadline first, bounded at 50. Guard: service class or exchanged
caseworker+. Backs the worker-dashboard CMD-escalations panel — the
worker-visible arm of the 10-day clock. Both clock values are
jurisdiction data since #1511: [medicaid].cmd_clock_days stamps
deadline_at at ingest (previously a '10 days' SQL literal) and
cmd_escalation_warning_days bounds this feed (mirrors the [appeals]
clock-pair precedent, ADR-003).
Events: medicaid.cmd_cascade_completed (per-subject cascade telemetry,
#1505 wiring), the determination.requested::Order publication above,
and — consumed, not published — determination.order_failed
(canopy-medicaid.cmd-fail): a terminal order failure marks the CMD row
state='failed' + failure_code, surfacing it on the escalation feed.
Metrics: canopy_medicaid_cmd_unresolved_depth /
_oldest_unresolved_age_seconds (alerting threshold =
cmd_escalation_warning_days before the deadline); _terminal_failed
is LIVE since #1511 (counts state='failed' rows).
POST /v1/determinations/{id}/requeue
Operator action for resolving a signature-quarantine quarantined determination. Sets medicaid_determinations.status to requeued (action=requeue) or dismissed (action=dismiss). Unknown actions return 400.
Minimum role: service-class caller.
Request:
{
"resolution_action": "requeue",
"notes": "Reviewed quarantine — was a transient JWKS mismatch",
"resolved_by": "jane.doe"
}
Response (200): { "determination_id": "uuid", "resolution_action": "requeue", "new_status": "requeued" }.
Error Codes
| Code | Meaning |
|---|---|
201 |
Resource created (overpayment claim / repayment plan / recoupment ledger entry) |
400 |
Invalid input |
401 |
Missing or invalid JWT |
403 |
Insufficient role (e.g. redact requires data_steward) |
404 |
Determination, COA evaluation, TMA record, or overpayment not found |
409 |
Status transition not allowed |
422 |
Semantically-invalid input (invalid enum value, etc.) |
503 |
FTI hash-chain breach detected |
Events Published
-
determination.completed.medicaid(IDs and status only — no PII, FTI, or HIPAA data per ADR-004) -
determination.redacted(T2-6 #687) — a data steward crypto-shredded a determination’s snapshot; carries the actorsub+ reason, no plaintext -
fti.audit_chain.breach_detected(Pub 1075 §9 reportable) -
medicaid.overpayment_claimed(#1035) — staged in the same tx as an appeal-opened overpayment claim; routes to the 42 CFR 433.300 demand notice in canopy-notices AND acknowledges the assessment back to canopy-appeals (#1105)
Subscribed Events
-
tanf.case_closed— creates TMA coverage rows (Phase 1) for AU members; logs-and-skips whenperson_idsis empty -
snap.application_approved/tanf.application_approved— Express Lane Eligibility evaluation -
appeal.overpayment_assessed(filtersprogram == "medicaid") — auto-opens anOverpaymentClaimrow in this service’s DB -
appeal.overpayment_assessment_voided(#1105, same queue) — voids the claim stamped with the event’sassessment_idvia the stamped-storevoid_for_assessment(idempotent; no-claim is a no-op)