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}returnsmembers[]— IDs + relationships only, no expansion.GET /v1/export/personsis admin-only bulk export; not designed for inline orchestrator / income-tab use.- No
/v1/households/{id}?expand=income,assets,expensesshape. - No
POST /v1/persons:batchGet.
Acceptance criteria
- 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. - New contract type in
crates/canopy-contracts-persons:HouseholdFull { household: Household, members: Vec<MemberFull> }whereMemberFullcarries the expansion arrays. - 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
- Existing per-member endpoints stay (don't break callers we haven't audited yet).
- 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