Plan: TMA Subscriber Person Lookup
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Extend the |
Done (2026-04-18) |
2 |
Update canopy-tanf publisher to include |
Done (2026-04-18) — |
3 |
Update canopy-medicaid subscriber in |
Done (2026-04-18) — subscriber now logs-and-skips on empty |
4 |
Backfill: one-shot SQL migration that splits existing placeholder rows ( |
N/A — no historical placeholder rows exist pre-UAT and every devstack is wiped on each |
5 |
Unit tests: 1-member AU, multi-member AU, stale placeholder backfill |
Done (2026-04-18) — 3 |
6 |
Delete the errata section in |
Done (2026-04-18) |
Branch: feature/tma-subscriber-person-lookup
Labels: type::bug, priority::high, program::medicaid, service::medicaid, workflow::ready, compliance::hipaa
Context
Per medicaid-coa-phase-c-tma.adoc errata, the subscriber added in Phase C uses household_id as a placeholder for person_id when creating tanf_tma_coverage rows. This was a deliberate shortcut: the tanf.case_closed event payload as defined at Phase C time only carried household_id and reason, not per-member identifiers.
Semantically this is wrong:
-
The
person_idcolumn oftanf_tma_coveragestores household UUIDs — these are never validperson_idvalues downstream. -
Subscribers that join
tanf_tma_coverageto canopy-persons onperson_idget nothing. -
A multi-person household that loses TANF produces a single TMA row, not one per eligible member. Per 42 USC 1396r-6 TMA is granted per-person.
-
T-MSIS extraction for TMA (COA code
four_months_extended) cannot attribute enrollment correctly.
The determination path itself is unaffected (TMA eligibility is evaluated from ApplicationContext.had_tanf_in_prior_months, not from the coverage table), so no active determinations are wrong today. But every downstream reporting, enrollment, and audit path that reads tanf_tma_coverage.person_id is broken.
Scope
In scope:
-
Expand the
tanf.case_closedwire contract. -
canopy-tanf side: publish the expanded payload.
-
canopy-medicaid side: consume it and persist one row per person.
-
Backfill existing rows where
person_id = household_id.
Out of scope:
-
Subscriber idempotency refactor (already deferred to the Tier 5.5 hardening sweep).
-
Renaming the
tanf_tma_coveragetable or its columns. -
Cross-program subscribers that read the same event (they continue to use
household_id, which remains correct).
Dependencies
-
crates/canopy-mq/src/envelope.rs— shared event envelope; no changes required, just an additional field in the payload body. -
canopy-persons
GET /v1/households/{id}/members— used by the backfill and (optionally) as a fallback in the subscriber. -
services/canopy-medicaid/src/store/tma.rs— already exposescreate_tma_coverage(household_id, person_id, …). -
services/canopy-tanf/src/au_composition.rs— authoritative source for AU members.
Design
Event payload
Current:
{
"household_id": "…",
"reason": "earnings_increase",
"termination_date": "2026-02-01"
}
Expanded:
{
"household_id": "…",
"person_ids": ["…", "…"],
"reason": "earnings_increase",
"termination_date": "2026-02-01",
"had_medicaid_coverage": true
}
person_ids is the list of AU members whose TANF was terminating. Consumers may intersect with their own membership view before acting; the publisher side errs on inclusion.
Subscriber loop
// services/canopy-medicaid/src/main.rs — replaces the for-household loop
for person_id in payload.person_ids {
match store::create_tma_coverage(
&pool,
payload.household_id,
person_id,
payload.termination_date,
).await {
Ok(cov) => tracing::info!(cov_id=%cov.id, "tma coverage created"),
Err(e) => tracing::error!(%e, person_id=%person_id, "tma coverage create failed"),
}
}
A single malformed person in the payload does not abort the loop; each error is logged individually.
Backfill migration
-- {timestamp}_split_placeholder_tma_coverage.sql
-- One-shot: find rows where person_id = household_id (placeholder marker),
-- replace each with N rows, one per household member, by calling a
-- server-side function that hits canopy-persons via the cross-service
-- HTTP client. See backfill runbook.
The backfill is SQL-first where possible, but row-splitting needs external data (persons members). Implement as an xtask command cargo xtask tma backfill that:
-
selects rows where
person_id = household_id -
for each, fetches
GET /v1/households/{id}/members -
inserts N−1 additional rows with the real
person_idand updates the original to match the first member -
writes a report to
.data/tma-backfill-{timestamp}.json
The xtask approach keeps the migration pure-SQL and the data-fetching code testable. See CLI Reference for the convention.
Steps
Step 1: Expand event payload contract
Files: services/canopy-tanf/src/events.rs.
Add a typed struct TanfCaseClosedEvent with the expanded fields (if a typed struct does not already exist). Include #[derive(Serialize, Deserialize)]. Document the field ordering in a rustdoc comment referencing this plan.
Step 2: Publisher wiring
Files: services/canopy-tanf/src/determine.rs (or wherever the publish currently happens).
Populate person_ids from the already-computed AU composition. The AU crate exposes AuComposition::members(). Scrub FTI fields before publishing per ADR-004.
Step 3: Subscriber refactor
Files: services/canopy-medicaid/src/main.rs.
Change the subscriber body from a single create_tma_coverage(household_id, household_id, …) call to the per-person loop in Design. Delete the // TODO: real person lookup comment at line 138.
Step 4: Backfill xtask
Files: xtask/src/cmd/tma.rs (new), xtask/src/main.rs (wire subcommand), services/canopy-medicaid/migrations/{timestamp}_split_placeholder_tma_coverage.sql (no-op migration that documents the intent and points at the xtask).
The xtask connects to the canopy-medicaid database using the same DATABASE_URL convention as other xtask commands. It emits a dry-run report unless --commit is passed.
Step 5: Tests
Files: services/canopy-medicaid/src/main.rs (test module), services/canopy-tanf/src/events.rs (test module), xtask/src/cmd/tma.rs (test module).
Three unit tests:
-
subscriber_single_member_au_creates_one_row -
subscriber_multi_member_au_creates_n_rows -
backfill_splits_placeholder_row_into_n_rows(uses a mock persons client)
Plus a wire-format round-trip test confirming publisher→subscriber payload compatibility against the expanded contract.
Step 6: Clean up plan errata and Tier 5.5
Files: docs/modules/ROOT/pages/plans/medicaid-coa-phase-c-tma.adoc, docs/modules/ROOT/pages/roadmap.adoc.
Delete the "Placeholder person_id in TMA subscriber" errata block. Remove the matching services/canopy-medicaid/src/main.rs:138 line from roadmap.adoc Tier 5.5.
Files Touched
| File | Change |
|---|---|
|
Typed TanfCaseClosedEvent with person_ids |
|
Include AU members in emitted event |
|
Per-person subscriber loop; drop placeholder comment |
|
Documentation migration pointing at xtask |
|
New backfill subcommand |
|
Wire subcommand |
|
Remove errata block |
|
Remove Tier 5.5 entry |
|
Entry under |
Verification
-
cargo nextest run -p canopy-tanf -p canopy-medicaid— unit tests pass -
cargo nextest run -p xtask— xtask tests pass -
cargo xtask tma backfill --dry-runin a devstack with seeded placeholder rows — report shows the expected row-split count -
cargo xtask tma backfill --commitin the same devstack — placeholder rows replaced -
psqlspot-check:SELECT count(*) FROM tanf_tma_coverage WHERE person_id = household_idreturns 0
Documentation Updates
-
.claude/docs/services.md— note the expandedtanf.case_closedpayload under canopy-medicaid subscribes (2026-04-18) -
CLI Reference — document
cargo xtask tma backfill(deferred with Step 4) -
CHANGELOG.adoc— entry under== Unreleased(2026-04-18)
Errata
Step 4 (backfill xtask) skipped
The original plan called for a one-shot cargo xtask tma backfill that would split existing tanf_tma_coverage rows where person_id = household_id into one row per AU member, fetching member lists from canopy-persons.
No such rows exist pre-UAT — placeholder rows were only ever written by the old subscriber, and every devstack is wiped on each cargo xtask dev restart. There is no deployed environment that carries historical placeholder data. Building the xtask in that absence risks adding untested code that sits idle until a problem scenario we cannot reproduce.
If a deployed environment is ever found with SELECT count(*) FROM tanf_tma_coverage WHERE person_id = household_id > 0, file a follow-up plan tma-backfill-xtask.adoc covering:
-
The xtask subcommand contract (
--dry-rundefault,--commitflag, report JSON to.data/tma-backfill-{timestamp}.json) -
A canopy-persons HTTP client with bearer-token forwarding
-
An operational runbook entry citing the command
Until then the backfill is paper work, not delivered code.
Potential Improvements
-
The subscriber currently logs and skips on missing
person_ids. A stricter alternative — dead-letter the event for operator review — would surface publisher regressions loudly instead of silently. This would become worthwhile once a second program subscribes to the same payload and benefits from the per-person list. -
extract_person_idsparsesctx.members[i]["person_id"]as a string. If the orchestrator ever upgradesMemberContext.person_idto a typed UUID (not stringly-typed), the helper and its tests should switch to the typed value and drop the parse step.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#325 — Switch extract_person_ids to typed UUID (from Potential Improvements)
Tracked follow-ups (filed 2026-05-04 during PI sweep):
-
#417 — Dead-letter on missing person_ids in tanf.case_closed subscriber