Plan: canopy-persons Batch Expansion Endpoint — Eliminate the N+1 Fan-Out
On this page
Authored from a code-grounded investigation. Two parts of #626’s acceptance criteria are wrong and one callsite does not fit the household endpoint — read Resolved Decisions (user, 2026-06-04) and Batch core (Step 1) before implementing:
-
"Single SQL query with JOINs and JSON aggregation" is unsafe and incorrect. Person SSN is decrypted in Rust via
PersonRow::into_person(encryption_key)to derivessn_last_four(services/canopy-persons/src/store/models.rs:71). A SQLJSON_AGGof person rows would either leak rawssn_encryptedciphertext into the response or silently drop the last-4 derivation, and JOIN-aggregating income×assets×expenses×addresses in one query produces a cartesian row explosion. The correct shape is the existing set-basedANY($1)idiom (store/addresses.rs:42 list_by_persons) — ~5 batched queries + Rust assembly. -
?as_of={date}has no backing valid-time model. Valid-time fact versioning is ADR-027/epic &56 (ratified, not yet coded). Decision (2026-06-04): accept the param but reject any value other than absent/"now" with a501documenting "valid-time not yet supported" — advertises the eventual capability without silently returning current data for a historical date. See Resolved Decisions (user, 2026-06-04). -
Callsite #2 (CMS-416) is person-keyed, not household-keyed — it iterates beneficiary person IDs on the Medicaid roll, so
GET /v1/households/{id}/fullstructurally cannot serve it. Decision (2026-06-04): build a second person-keyed endpoint (POST /v1/persons:batchGet) over the shared batch core so CMS-416 is closed in this plan. See Resolved Decisions (user, 2026-06-04).
Status
| Step | Description | Status |
|---|---|---|
1 |
canopy-persons store: shared set-based batch core ( |
Done (2026-06-04) — |
2 |
canopy-persons API + contract: |
Done (2026-06-04) — |
3 |
Refactor household-keyed callsites: canopy-web income tab + canopy-eligibility orchestrator → single |
Done (2026-06-04) — |
4 |
Person-keyed batch endpoint ( |
Done (2026-06-04) — |
5 |
Tests (SSN last-4 correct + no ciphertext leak; query-count assertion; parity with the per-member endpoints) + CMS-416 bench. |
Done (2026-06-04) — pure unit tests (order/dedup/grouping + no-ssn_encrypted serialization guard) in |
6 |
Docs + CHANGELOG + GitLab issue update. |
Done (2026-06-04) — CHANGELOG Added entry; Antora |
Issues: #626
Branch: feat/626-persons-batch-expansion
Context
Three callsites fan out to canopy-persons one member at a time, each call a separate HTTPS round-trip (ADR-001 service isolation — no shared DB):
-
canopy-web income tab —
services/canopy-web/src/api/case_detail.rs(resolve_name+list_income_for_personper member; symbols at ~lines 1762/1765 onmain— anchor on the symbols, the issue’s line range 1436-1551 has drifted). 2N+1 calls on every Income-tab open. -
canopy-eligibility orchestrator —
services/canopy-eligibility/src/orchestrator.rs:143+(for member in &raw_members→ per member: persons + income + assets + expenses). 4N sequential calls on the synchronous determination path — everyPOST /v1/eligibility/determinepays it. -
CMS-416 reporter —
services/canopy-reporting/src/reporting/medicaid.rs:269-348(per beneficiary:GET /v1/persons/{id}). N calls over a state-scale Medicaid roll (hundreds of thousands) — the issue’s headline use case (hours → minutes).
canopy-persons exposes no batch/expansion shape today (GET /v1/households/{id} returns member IDs only; GET /v1/export/persons is admin bulk export). This plan adds the missing batch surface.
Scope
In scope:
-
A set-based batch core in canopy-persons that fetches persons + income + assets + expenses + addresses for a set of person IDs in a fixed number of queries, decrypting SSN in Rust.
-
GET /v1/households/{id}/fullreturningHouseholdFull(serves the two household-keyed callsites: income tab + orchestrator). -
Refactoring those two callsites to the single call.
-
A person-keyed batch endpoint (
POST /v1/persons:batchGet) reusing the same core, to serve CMS-416, + the reporter refactor (resolved decision 1). -
The
?as_of=param on the contract as a rejector (501 for non-now values; resolved decision 2). -
Tests + bench.
Out of scope:
-
Real
?as_of=time-travel semantics (no valid-time model until epic &56 — this plan only ships the param as a 501 rejector). -
Removing the existing per-member endpoints (acceptance criterion #4 — they stay for un-audited callers).
-
Any change to encryption-at-rest or the SSN-last-4 contract.
-
Caching / read-through layers — this is a batching fix, not a cache.
Resolved Decisions (user, 2026-06-04)
-
CMS-416 (callsite #2) — build both endpoints in this plan. CMS-416 is person-keyed;
/v1/households/{id}/fullcannot serve it. Because the batch core (Step 1) is shared, this plan exposes a second, person-keyed endpoint (POST /v1/persons:batchGet) over the same core (Step 4), closing all three callsites including the regulatory CMS-416 win the issue ratespriority::high. -
?as_of={date}— accept the param but reject non-now values with501. No valid-time model backs real time-travel until ADR-027/epic &56 lands. The endpoint accepts?as_of=so the contract is forward-stable, but any value other than absent/"now"/today returns501 Not Implementedwith a problem-detail explaining "valid-time queries not yet supported (epic &56)". This advertises the eventual capability without silently returning current data for a historical date.
Design
Batch core (Step 1)
Mirror the existing list_by_persons set-based idiom (services/canopy-persons/src/store/addresses.rs:42: WHERE person_id = ANY($1) AND active = true). For a &[PersonId]:
-
SELECT … FROM persons WHERE id = ANY($1)→Vec<PersonRow>→ map each throughPersonRow::into_person(encryption_key)(Rust-side SSN decrypt →ssn_last_four). This is the load-bearing reason not to JSON-aggregate persons in SQL. -
One
… WHERE person_id = ANY($1)query each for income, assets, expenses, addresses (income/assets/expenses already have per-person store fns to model the columns on; addlist_*by_personssiblings next to the existinglist*_for_person, following theaddresses::list_by_personsshape). -
Assemble in Rust: group the child rows by
person_id(e.g.HashMap<PersonId, Vec<_>>) and fold intoMemberFull { person, income, assets, expenses, addresses }.
Total = ~5 queries for any N, replacing 4N HTTPS calls. No cartesian product (each child set is fetched and grouped independently).
// services/canopy-persons/src/store/… (new batch module or extend household store)
pub async fn expand_persons(
pool: &PgPool,
person_ids: &[PersonId],
encryption_key: &EncryptionKey,
) -> sqlx::Result<Vec<MemberFull>> {
let persons = persons::list_by_ids(pool, person_ids).await?
.into_iter().map(|row| row.into_person(encryption_key)).collect::<Vec<_>>();
let income = income::list_by_persons(pool, person_ids).await?; // group by person_id
let assets = assets::list_by_persons(pool, person_ids).await?;
let expenses = expenses::list_by_persons(pool, person_ids).await?;
let addresses = addresses::list_by_persons(pool, person_ids).await?;
Ok(assemble_members(persons, income, assets, expenses, addresses))
}
Household-full endpoint (Step 2)
GET /v1/households/{id}/full:
-
If
as_ofis present and is not absent/"now"/today, return501 Not Implemented(ApiError-mapped problem detail "valid-time queries not yet supported (epic &56)") before touching the store — resolved decision 2. Otherwise ignore it (current-time read). -
Load the household + its member rows (existing
GET /v1/households/{id}store path) → member `PersonId`s + relationships. -
expand_persons(member_ids)→Vec<MemberFull>. -
Return
HouseholdFull { household, members }.
New contract types in crates/canopy-contracts-persons (acceptance criterion #2):
pub struct HouseholdFull { pub household: Household, pub members: Vec<MemberFull> }
pub struct MemberFull {
pub person: Person, // carries ssn_last_four, never ssn_encrypted
pub relationship: String,
pub income: Vec<IncomeRecord>,
pub assets: Vec<AssetRecord>,
pub expenses: Vec<ExpenseRecord>,
pub addresses: Vec<Address>,
}
Use the existing API response types (Person, Address, income/asset/expense DTOs) so the batch response is field-identical to the per-member endpoints — callers swap N calls for 1 with no shape translation. #[utoipa::path] + a responsesstatus = 200, body = HouseholdFull annotation; 404 when the household is unknown.
Person-keyed batch endpoint (Step 4)
POST /v1/persons:batchGet (body { "person_ids": […] }) → Vec<MemberFull> over the same expand_persons core. POST (not GET) because the ID list can be large (CMS-416 pages of ~100). The reporter pages the roll and calls this per page instead of per beneficiary. Cap the per-request ID-list length (e.g. 500) and 422 on overflow so a caller can’t request the whole roll in one shot.
Callsite refactors (Step 3)
-
income tab (
case_detail.rs): replace the per-memberresolve_name/list_income_for_personloop with oneGET /v1/households/{id}/full; read names + income from the returnedmembers[]. -
orchestrator (
orchestrator.rs:143+): replace thefor memberfan-out with one/fullcall; the loop body now reads fromHouseholdFull.membersinstead of awaiting per member. Keep the existing downstream shape (raw_membersconsumers) by adapting to the typed members.
Steps
Step 1: batch core + list_*_by_persons store fns
Files: services/canopy-persons/src/store/{persons,income,assets,expenses}.rs (+ reuse addresses::list_by_persons), new expand_persons.
Add list_by_ids/list_*_by_persons siblings (ANY($1)) next to the existing per-person fns. Implement expand_persons + assemble_members. Unit-test the grouping + that ssn_last_four is populated and ssn_encrypted never appears in Person.
Step 2: endpoint + contract
Files: crates/canopy-contracts-persons/src/…, services/canopy-persons/src/api/…
Add HouseholdFull/MemberFull. Add the GET /v1/households/{id}/full handler. 404 on unknown household.
Step 3: household-keyed callsite refactors
Files: services/canopy-web/src/api/case_detail.rs, services/canopy-eligibility/src/orchestrator.rs (+ their clients in clients.rs).
Add a client method for /full; swap the loops. Verify the income-tab render and a determination produce identical output to before (golden/E2E).
Step 4: person batch endpoint + CMS-416
Files: crates/canopy-contracts-persons, services/canopy-persons/src/api/…, services/canopy-reporting/src/reporting/medicaid.rs.
Add POST /v1/persons:batchGet. Refactor the CMS-416 loop to page the roll and bulk-fetch. Bench against a synthetic 50k-beneficiary roll (acceptance criterion #5).
Step 5: tests + bench
-
SSN:
ssn_last_fourcorrect;ssn_encryptedabsent from the wire (serialize aMemberFull, assert no ciphertext field). -
Query-count: assert
expand_personsissues a fixed number of queries regardless of N (e.g. via a counting wrapper or sqlx logging) — proves the N+1 is gone. -
Parity:
/fullmember data equals the per-member endpoints for the same household. -
CMS-416 bench number recorded in the MR.
Files Touched
| File | Change |
|---|---|
|
|
|
|
|
|
|
Swap N+1 loops for the batch call. |
|
Page + bulk-fetch the CMS-416 roll. |
|
Document the new endpoint(s) + shape. |
Verification
-
cargo nextest run -p canopy-persons --lib— core + SSN tests pass. -
cargo xtask dev refresh→ integration tests against the devstack pool — query-count + parity assertions pass. -
cargo nextest run --workspace— orchestrator + income-tab callers green. -
cargo xtask e2e— income tab + a determination render identical output (no behavioral regression). -
cargo xtask validate— clean. -
CMS-416 bench: 50k-roll generation drops to minutes.
Documentation Updates
-
CHANGELOG.adoc— Unreleased entry. -
Antora
api/canopy-persons.adoc+data-models/canopy-persons.adoc— new endpoint(s) +HouseholdFull/MemberFull. -
Service Catalog — persons route-count bump.
-
GitLab #626 — correct the JSON-aggregation prescription, record decisions, link plan.