Plan: canopy-caps List Endpoints + Authorization Field Reconciliation
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Store: |
Done (2026-04-21) — pre-existing |
2 |
Store: |
Done (2026-04-21) — pre-existing |
3 |
API: |
Done (2026-04-21) — |
4 |
API: |
Done (2026-04-21) — |
5 |
Authorization field reconciliation — see Design for options. Resolve the |
Done (2026-04-21) — Option A applied. |
6 |
canopy-web wiring: |
Done (2026-04-21) — fetches |
7 |
Integration tests (canopy-caps): seed a determination + 2 authorizations, GET both list endpoints, assert shapes. |
Done (2026-04-21) — 3 new tests: |
8 |
E2E test (canopy-web, Playwright): navigate to a seeded CAPS case detail, click the Authorization tab, assert a provider row renders. |
Done (2026-04-21) — Resolved by canopy-seed-caps-wic-fixtures: |
9 |
Plan sync: update |
Done (2026-04-21) |
Branch: feature/canopy-caps-list-endpoints
Labels: type::feature, priority::medium, program::caps, service::caps, service::web, workflow::ready
Context
The CAPS case-detail tab in canopy-web exists but is intentionally an empty
stub. services/canopy-web/src/api/case_detail.rs:1419 (render_caps_authorization)
returns TabAuthorizationTemplate { authorization: None } with the
comment "CAPS authorization is fetched by determination ID, not household
ID. For now, show empty state — the authorization is displayed inline in
the determination tab above."
Two endpoints are missing to wire it up properly:
-
GET /v1/determinations?household_id=X— today onlyGET /v1/determinations/{id}exists. Worker portal needs to go from a household-scoped URL to a list of determinations for that household. -
GET /v1/determinations/{id}/authorizations— today onlyGET /v1/authorizations/{id}exists (single fetch by ID). Portal needs to list authorizations for a determination without already knowing their IDs.
Additionally, services/canopy-web/templates/cases/tab_authorization.html
expects template variables that don’t exist on CapsAuthorization:
-
Template:
auth.care_type— struct hasauthorization_status -
Template:
auth.rate_display— struct hasrate_cents_per_hour: i32(raw cents) -
Template:
auth.expiration_date— struct hasend_date
One side needs to change. ADR-001 separates each program service’s schema,
so the DB side is canonical. The template should adapt. But the semantic
mismatch around authorization_status vs care_type is real — those are
different concepts. authorization_status is lifecycle state
(active/suspended/terminated/expired). care_type would be the service
category (in-home/center-based). The DB has no care_type column at all.
Either the template drops care_type (the template was speculatively
designed before the CAPS migration was finalised) or the migration adds a
column. See Design.
Scope
In scope:
-
Two list endpoints on canopy-caps.
-
Field reconciliation resulting in a template that renders against the real schema.
-
canopy-web wiring to consume the list endpoints.
-
Integration + E2E tests.
Out of scope:
-
Changes to CAPS determination / authorization lifecycle logic.
-
CAPS provider registry (a separate service-registry question tracked under Tier 6).
-
Historical authorization data migration — authorizations are a forward-only concept; existing rows fit the current schema.
Dependencies
-
services/canopy-caps/src/store/mod.rs— add list helpers. -
services/canopy-caps/src/api/handlers.rs— add list endpoints. -
services/canopy-caps/migrations/20260413000000_create_caps_tables.sql:39— existingcaps_authorizationsschema. -
services/canopy-web/src/api/case_detail.rs:1419— existing empty-state renderer. -
services/canopy-web/templates/cases/tab_authorization.html— template to reconcile. -
services/canopy-web/src/clients.rs— addCapsClient::list_determinations_for_household,list_authorizations_for_determination.
Design
Field reconciliation decision
Two viable options. Plan recommends Option A; Option B is kept here because it may be the right call after consulting CAPS policy.
Option A (recommended): fit the template to the current schema
Rename template variables:
-
auth.care_type→auth.authorization_status -
auth.rate_display→ compute at render time fromrate_cents_per_hour:${value / 100:.2f}/hour(move formatting out of the template into a helper incase_detail.rs) -
auth.expiration_date→auth.end_date(same semantic; cosmetic rename)
No migration. The worker portal immediately renders the status the DB actually tracks.
Option B: add care_type column
Migration adds care_type TEXT NOT NULL DEFAULT 'in_home' with a CHECK
constraint (in_home, center_based, family_care, relative_care,
school_based per CCDF definitions). Populate from the determination or
the application intake form. Requires updating the POST handler + the JDM
ruleset to wire the new value through. Larger scope, deferred unless
policy says the status field alone is insufficient.
The prereq plan ships with Option A; if CAPS policy later requires
care_type we’ll file a follow-up issue.
List-endpoint semantics
Household-scoped determinations:
SELECT * FROM caps_determinations
WHERE household_id = $1
ORDER BY determined_at DESC
Soft-deleted / superseded determinations are out of scope for this plan —
caps_determinations doesn’t carry a soft-delete column today. If one is
added later the query grows a WHERE active = true.
Determination-scoped authorizations:
SELECT * FROM caps_authorizations
WHERE determination_id = $1
ORDER BY effective_date DESC
No pagination needed — a determination has at most one authorization per
child-per-provider, bounded by household size. LIMIT 100 defensively to
prevent accidental runaway growth.
canopy-web flow
Worker lands on /cases/{household_id}/caps. Current code renders the
determination tab inline. New flow:
case_detail.rs (pseudocode):
determinations = caps_client.list_determinations_for_household(household_id).await?;
for det in determinations {
authorizations = caps_client.list_authorizations_for_determination(det.id).await?;
// Pass into TabAuthorizationTemplate
}
For MVP: assume one active determination per household (the common case). If multiple, show authorizations for the latest. Multi-determination UI is a follow-up.
Steps
Step 1 & 2: Store helpers
Files: services/canopy-caps/src/store/mod.rs.
Two functions matching the queries above. Unit tests against
infrastructure_available().
Step 3 & 4: API endpoints
Files: services/canopy-caps/src/api/handlers.rs,
services/canopy-caps/src/api/mod.rs.
Handlers:
#[utoipa::path(
get,
path = "/v1/determinations",
params(("household_id" = HouseholdId, Query, description = "Household to list for")),
responses((status = 200, body = Vec<CapsDetermination>), (status = 401), (status = 403)),
security(("bearer_auth" = []))
)]
async fn list_determinations_for_household(
State(state): State<AppState>,
Query(q): Query<HouseholdScopedQuery>,
claims: Claims,
) -> Result<Json<Vec<CapsDetermination>>, ApiError> { ... }
#[utoipa::path(
get,
path = "/v1/determinations/{id}/authorizations",
params(("id" = Uuid, Path, description = "Determination ID")),
responses((status = 200, body = Vec<CapsAuthorization>), (status = 401), (status = 403), (status = 404)),
security(("bearer_auth" = []))
)]
async fn list_authorizations_for_determination(
State(state): State<AppState>,
Path(id): Path<Uuid>,
claims: Claims,
) -> Result<Json<Vec<CapsAuthorization>>, ApiError> { ... }
Step 5: Field reconciliation
Apply Option A. Edit tab_authorization.html to use the real field names
and move rate formatting into case_detail.rs via a helper struct
(CapsAuthorizationView with pre-formatted strings).
Step 6: canopy-web wiring
Files: services/canopy-web/src/api/case_detail.rs,
services/canopy-web/src/clients.rs.
Extend CapsClient with the two new methods. Rewrite
render_caps_authorization to fetch via the household ID → determinations
→ authorizations chain.
Step 7: canopy-caps integration tests
Files: services/canopy-caps/tests/ (new file or extend existing).
Seed a determination, two authorizations; GET both list endpoints; assert JSON shape and ordering.
Files Touched
| File | Change |
|---|---|
|
+2 list helpers |
|
+2 handlers |
|
Route registration |
|
+2 CapsClient methods |
|
Rewire render_caps_authorization |
|
Field rename (Option A) |
|
New integration tests |
|
New Playwright coverage |
|
Resolved deferral |
|
Tier 5.5 row → Done |
|
Unreleased entry |
Verification
-
cargo nextest run -p canopy-caps— new integration tests pass. -
cargo nextest run -p canopy-web— no regressions in case-detail rendering. -
Seed a CAPS determination + authorization,
curl $CAPS_URL/v1/determinations?household_id=Xand/v1/determinations/{id}/authorizations— JSON shape matches the utoipa schemas. -
cargo xtask e2e --grep "caps"— Playwright CAPS spec passes. -
Manual: navigate to seeded CAPS case in the worker portal, Authorization tab renders real data.
-
cargo xtask validate— full battery green.
Documentation Updates
-
CHANGELOG.adoc—== Unreleasedentry -
canopy-caps API reference — new routes (deferred to follow-up doc pass)
-
.claude/docs/services.md— canopy-caps endpoint list (deferred to follow-up doc pass)
Errata
2026-04-21 — Step 8 Playwright E2E deferred (no CAPS seed data)
The plan’s Step 8 E2E spec depends on seed data containing a CAPS case — tests/e2e/lib/seed.ts’s `findApproved() helper walks SNAP determinations only, and tools/canopy-seed does not currently seed a CAPS household / determination / authorization chain. Adding CAPS seeding is cross-cutting (touches canopy-persons for household + child-person-id fixtures, canopy-applications for an approved CAPS application, and canopy-caps itself for the determination + authorization) and larger than this prereq plan’s scope.
Existing case-detail.spec.ts navigation tests already prove the Authorization tab renders without exploding (empty state) against SNAP-seeded data. The integration tests in Step 7 cover the HTTP contract end-to-end with a real DB. The narrower "click tab, see a provider row" assertion is deferred to a follow-up plan that also extends canopy-seed.
Resolved 2026-04-21 by canopy-seed-caps-wic-fixtures — canopy-seed now emits CAPS determinations + authorizations, and tests/e2e/specs/caps.spec.ts asserts the authorization tab renders the seeded provider-001 row.
2026-04-21 — Step 5 Option A scope clarification
The plan’s Option A described the field rename as auth.care_type → auth.authorization_status. On implementation we found the template already had a separate auth.status badge, so a direct rename would have produced two identical status cells. Resolution: drop the care_type row entirely (no column exists in caps_authorizations), rename auth.status → auth.authorization_status to match the DB column, and add a copayment_display cell (pre-formatted from copayment_weekly_cents) since the authorization-level copayment is more specific than the determination-level one and the template didn’t show it previously. The template now renders a Vec<CapsAuthorizationData> (loop) rather than Option<CapsAuthorizationData> — multi-authorization support came for free since the schema allows multiple per determination.