Plan: canopy-persons Batch Expansion Endpoint — Eliminate the N+1 Fan-Out

On this page
NOTE

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:

  1. "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 derive ssn_last_four (services/canopy-persons/src/store/models.rs:71). A SQL JSON_AGG of person rows would either leak raw ssn_encrypted ciphertext 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-based ANY($1) idiom (store/addresses.rs:42 list_by_persons) — ~5 batched queries + Rust assembly.

  2. ?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 a 501 documenting "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).

  3. Callsite #2 (CMS-416) is person-keyed, not household-keyed — it iterates beneficiary person IDs on the Medicaid roll, so GET /v1/households/{id}/full structurally 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 (expand_persons(ids) → Vec<MemberFull>) using ANY($1) per child table + Rust SSN decrypt.

Done (2026-06-04) — store/batch.rs::expand_persons (5 queries any N) + persons::list_by_ids + {income,assets,expenses}::list_by_persons (addresses already had it); pure assemble_members split out.

2

canopy-persons API + contract: GET /v1/households/{id}/fullHouseholdFull; new HouseholdFull/MemberFull types in crates/canopy-contracts-persons.

Done (2026-06-04) — contracts-persons/src/batch.rs (HouseholdFull/MemberFull/BatchGetPersonsRequest/HouseholdFullParams); handler + paths::GET_HOUSEHOLD_FULL; ApiError::NotImplemented (501) added for the as_of rejector. relationship: Option<String> (Some for household, None for batchGet).

3

Refactor household-keyed callsites: canopy-web income tab + canopy-eligibility orchestrator → single /full call.

Done (2026-06-04) — render_income_tab reads name + income from the bundle (IEVS merge unchanged); fetch_household_context one /full call (MemberContext + Vec<serde_json::Value> contracts preserved). Removed the now-dead list_income_for_person BFF helper.

4

Person-keyed batch endpoint (POST /v1/persons:batchGet) over the shared core + refactor the CMS-416 reporter.

Done (2026-06-04) — batch_get_persons handler (500-ID cap, 422 over) + paths::BATCH_GET_PERSONS; extract_cms416 pages the roll in 500-ID chunks via new reporting-client batch_get_persons + ServiceClient::post.

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 store/batch.rs; HTTP integration tests (tests/batch_expansion_test.rs: parity, 404, 501, 422, no-relationship). Fixed-query-count is structural (5 set-based calls, no per-N loop — verified in expand_persons); the orchestrator/income-tab parity is also covered by the existing eligibility integration + worker-determination E2E. CMS-416 micro-bench deferred (no 50k synthetic-roll fixture in-tree; the chunked path is exercised by the reporting integration tests).

6

Docs + CHANGELOG + GitLab issue update.

Done (2026-06-04) — CHANGELOG Added entry; Antora api/canopy-persons.adoc + persons route-count bump; OpenAPI snapshot regenerated; #626 updated with the corrected prescription + decisions.

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 tabservices/canopy-web/src/api/case_detail.rs (resolve_name + list_income_for_person per member; symbols at ~lines 1762/1765 on main — anchor on the symbols, the issue’s line range 1436-1551 has drifted). 2N+1 calls on every Income-tab open.

  • canopy-eligibility orchestratorservices/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 — every POST /v1/eligibility/determine pays it.

  • CMS-416 reporterservices/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}/full returning HouseholdFull (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)

  1. CMS-416 (callsite #2) — build both endpoints in this plan. CMS-416 is person-keyed; /v1/households/{id}/full cannot 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 rates priority::high.

  2. ?as_of={date} — accept the param but reject non-now values with 501. 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 returns 501 Not Implemented with 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]:

  1. SELECT … FROM persons WHERE id = ANY($1)Vec<PersonRow> → map each through PersonRow::into_person(encryption_key) (Rust-side SSN decrypt → ssn_last_four). This is the load-bearing reason not to JSON-aggregate persons in SQL.

  2. 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; add list_*by_persons siblings next to the existing list*_for_person, following the addresses::list_by_persons shape).

  3. Assemble in Rust: group the child rows by person_id (e.g. HashMap<PersonId, Vec<_>>) and fold into MemberFull { 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:

  1. If as_of is present and is not absent/"now"/today, return 501 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).

  2. Load the household + its member rows (existing GET /v1/households/{id} store path) → member `PersonId`s + relationships.

  3. expand_persons(member_ids)Vec<MemberFull>.

  4. 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-member resolve_name/list_income_for_person loop with one GET /v1/households/{id}/full; read names + income from the returned members[].

  • orchestrator (orchestrator.rs:143+): replace the for member fan-out with one /full call; the loop body now reads from HouseholdFull.members instead of awaiting per member. Keep the existing downstream shape (raw_members consumers) 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_four correct; ssn_encrypted absent from the wire (serialize a MemberFull, assert no ciphertext field).

  • Query-count: assert expand_persons issues a fixed number of queries regardless of N (e.g. via a counting wrapper or sqlx logging) — proves the N+1 is gone.

  • Parity: /full member data equals the per-member endpoints for the same household.

  • CMS-416 bench number recorded in the MR.

Step 6: docs + issue update

  • CHANGELOG.adoc; Antora api/canopy-persons.adoc + data-models/… for the new shape.

  • Update #626: correct the JSON-aggregation prescription, record the chosen decisions (1 + 2), link this plan.

Files Touched

File Change

services/canopy-persons/src/store/{persons,income,assets,expenses}.rs

list_*_by_persons (ANY($1)) + expand_persons core.

crates/canopy-contracts-persons/src/…

HouseholdFull, MemberFull.

services/canopy-persons/src/api/…

GET /v1/households/{id}/full + POST /v1/persons:batchGet.

services/canopy-web/src/api/case_detail.rs, services/canopy-eligibility/src/orchestrator.rs (+ clients)

Swap N+1 loops for the batch call.

services/canopy-reporting/src/reporting/medicaid.rs

Page + bulk-fetch the CMS-416 roll.

CHANGELOG.adoc, Antora persons api/data-model pages

Document the new endpoint(s) + shape.

Verification

  1. cargo nextest run -p canopy-persons --lib — core + SSN tests pass.

  2. cargo xtask dev refresh → integration tests against the devstack pool — query-count + parity assertions pass.

  3. cargo nextest run --workspace — orchestrator + income-tab callers green.

  4. cargo xtask e2e — income tab + a determination render identical output (no behavioral regression).

  5. cargo xtask validate — clean.

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

Edit this page · default