feat: GET /v1/households/{id}/full batch endpoint — eliminate 3 N+1 callsites against canopy-persons

Surfaced

External audit re-verified 2026-05-27 — all 3 N+1 callsites confirmed.

Three callsites

1. Worker portal income tab

services/canopy-web/src/api/case_detail.rs:1436-1551. For each household member: 1× resolve_name(person_id) + 1× list_income_for_person(person_id). Per-render fan-out = 2N+1 sequential HTTPS calls. Surfaces on every Income tab open.

2. CMS-416 reporter

services/canopy-reporting/src/reporting/medicaid.rs:269-348. For each beneficiary on the state Medicaid roll: 1× GET /v1/persons/{id}. Fan-out = N calls. State-scale Medicaid roll is hundreds of thousands of rows — this is the killer use case. Regenerating the report sequentially is hours of HTTPS round-trips.

3. Eligibility orchestrator

services/canopy-eligibility/src/orchestrator.rs:143-219. Per member: 1× GET /v1/persons/{id} + 3× GET /v1/persons/{id}/{income|assets|expenses}. Fan-out = 4N sequential HTTPS calls. All .await on the for-loop — no try_join_all, no batching. This is on the synchronous determination path (every POST /v1/eligibility/determine pays the cost).

Current canopy-persons surface (no fix available)

  • GET /v1/households/{id} returns members[] — IDs + relationships only, no expansion.
  • GET /v1/export/persons is admin-only bulk export; not designed for inline orchestrator / income-tab use.
  • No /v1/households/{id}?expand=income,assets,expenses shape.
  • No POST /v1/persons:batchGet.

Acceptance criteria

  1. New endpoint on canopy-persons: GET /v1/households/{id}/full?as_of={date} returns the household with embedded members, each member with embedded income / assets / expenses / addresses. Single SQL query with JOINs and JSON aggregation.
  2. New contract type in crates/canopy-contracts-persons: HouseholdFull { household: Household, members: Vec<MemberFull> } where MemberFull carries the expansion arrays.
  3. Refactor all 3 callsites to call the new endpoint instead of the N+1 loop:
    • canopy-web case_detail.rs income tab → 1 call instead of 2N+1
    • canopy-reporting medicaid.rs CMS-416 → bulk-fetch in pages of X (e.g. 100) instead of 1-per-beneficiary
    • canopy-eligibility orchestrator.rs → 1 call instead of 4N
  4. Existing per-member endpoints stay (don't break callers we haven't audited yet).
  5. Bench: CMS-416 generation time drops from ~hours to ~minutes for a 50k-beneficiary roll.

Why priority::high

  • CMS-416 is a regulatory submission (federal Medicaid utilization). Hours-long generation blocks the monthly submission cycle.
  • Orchestrator fan-out cost compounds at scale — every Run Determination today does 4N round-trips.
  • The fix unblocks scale-up significantly more than it costs to build (~1 endpoint + 3 caller refactors).

Labels

type::feature, priority::high, workflow::ready, service::persons, service::web, service::reporting, service::eligibility, program::cross-program