Plan: TMA Subscriber Person Lookup

On this page

Status

Step Description Status

1

Extend the tanf.case_closed event payload to carry person_ids: Vec<Uuid> (all AU members whose TANF coverage is ending)

Done (2026-04-18)

2

Update canopy-tanf publisher to include person_ids — source from the AU composition

Done (2026-04-18) — extract_person_ids helper parses ctx.members

3

Update canopy-medicaid subscriber in main.rs to iterate person_ids and create one tanf_tma_coverage row per person

Done (2026-04-18) — subscriber now logs-and-skips on empty person_ids rather than regressing to a household_id placeholder

4

Backfill: one-shot SQL migration that splits existing placeholder rows (person_id = household_id) into one row per member by querying canopy-persons

N/A — no historical placeholder rows exist pre-UAT and every devstack is wiped on each cargo xtask dev restart. File as a follow-up plan only if a deployed environment is ever found with person_id = household_id in tanf_tma_coverage (the trigger is a SELECT count(*) on the deployed DB; nothing to do until that count is non-zero).

5

Unit tests: 1-member AU, multi-member AU, stale placeholder backfill

Done (2026-04-18) — 3 extract_person_ids unit tests + 3 publish_tanf_case_closed payload tests + TSNAP round-trip guard + multi-member AU integration test

6

Delete the errata section in medicaid-coa-phase-c-tma.adoc and the Tier 5.5 entry in roadmap.adoc

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_id column of tanf_tma_coverage stores household UUIDs — these are never valid person_id values downstream.

  • Subscribers that join tanf_tma_coverage to canopy-persons on person_id get 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_closed wire 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_coverage table 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 exposes create_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:

  1. selects rows where person_id = household_id

  2. for each, fetches GET /v1/households/{id}/members

  3. inserts N−1 additional rows with the real person_id and updates the original to match the first member

  4. 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:

  1. subscriber_single_member_au_creates_one_row

  2. subscriber_multi_member_au_creates_n_rows

  3. 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

services/canopy-tanf/src/events.rs

Typed TanfCaseClosedEvent with person_ids

services/canopy-tanf/src/determine.rs

Include AU members in emitted event

services/canopy-medicaid/src/main.rs

Per-person subscriber loop; drop placeholder comment

services/canopy-medicaid/migrations/{ts}_split_placeholder_tma_coverage.sql

Documentation migration pointing at xtask

xtask/src/cmd/tma.rs

New backfill subcommand

xtask/src/main.rs

Wire subcommand

docs/modules/ROOT/pages/plans/medicaid-coa-phase-c-tma.adoc

Remove errata block

docs/modules/ROOT/pages/roadmap.adoc

Remove Tier 5.5 entry

CHANGELOG.adoc

Entry under == Unreleased

Verification

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

  2. cargo nextest run -p xtask — xtask tests pass

  3. cargo xtask tma backfill --dry-run in a devstack with seeded placeholder rows — report shows the expected row-split count

  4. cargo xtask tma backfill --commit in the same devstack — placeholder rows replaced

  5. psql spot-check: SELECT count(*) FROM tanf_tma_coverage WHERE person_id = household_id returns 0

Documentation Updates

  • .claude/docs/services.md — note the expanded tanf.case_closed payload 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-run default, --commit flag, 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_ids parses ctx.members[i]["person_id"] as a string. If the orchestrator ever upgrades MemberContext.person_id to 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

Edit this page · default