Plan: canopy-caps List Endpoints + Authorization Field Reconciliation

On this page

Status

Step Description Status

1

Store: list_determinations_for_household(db, household_id) — returns Vec<CapsDetermination> ordered by determined_at DESC

Done (2026-04-21) — pre-existing list_determinations_by_household (was #[allow(dead_code)]) now exposed. ORDER BY created_at DESC (same semantic as determined_at — the column name in CapsDetermination is created_at).

2

Store: list_authorizations_for_determination(db, determination_id) — returns Vec<CapsAuthorization> ordered by effective_date DESC

Done (2026-04-21) — pre-existing list_authorizations_by_determination unwrapped from #[allow(dead_code)]. ORDER BY created_at DESC.

3

API: GET /v1/determinations?household_id=X — lists CAPS determinations for a household. RBAC: require_caseworker_or_above. utoipa-annotated.

Done (2026-04-21) — list_determinations_by_household handler with HouseholdScopedQuery extractor, LIMIT 100 defensive cap.

4

API: GET /v1/determinations/{id}/authorizations — lists authorizations for a determination. Same RBAC, utoipa-annotated.

Done (2026-04-21) — list_authorizations_for_determination handler, path-param extractor.

5

Authorization field reconciliation — see Design for options. Resolve the authorization_status / care_type / rate_display / expiration_date mismatch between CapsAuthorization and tab_authorization.html.

Done (2026-04-21) — Option A applied. CapsAuthorizationData renamed status → authorization_status, expiration_date → end_date, dropped care_type (no column exists), added pre-formatted copayment_display + kept rate_display. Template loops over authorizations: Vec<…​> (multi-authorization support came for free since the schema allows multiple per determination).

6

canopy-web wiring: render_caps_authorization at services/canopy-web/src/api/case_detail.rs:1419 stops returning None; calls the new endpoints through a CapsClient helper and renders authorizations with the reconciled field names.

Done (2026-04-21) — fetches /v1/determinations?household_id=X → takes first determination → /v1/determinations/{id}/authorizations; maps rows into CapsAuthorizationData with f64-formatted rate + copayment. No new CapsClient helper needed — the generic InternalClient.get pattern matches the rest of canopy-web. render_caps_determination also switched to the new household-scoped endpoint (away from its ?limit=50 client-side-filter workaround).

7

Integration tests (canopy-caps): seed a determination + 2 authorizations, GET both list endpoints, assert shapes.

Done (2026-04-21) — 3 new tests: caps_list_determinations_for_household (creates 2 determinations, asserts both returned + household_id filter correct), caps_list_authorizations_for_determination (approved determination → exactly one authorization with expected status/provider), caps_list_determinations_empty_household (random household returns []). 22/22 canopy-caps tests pass.

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: tests/e2e/specs/caps.spec.ts asserts the seeded provider-001 row in the Authorization tab against a CAPS-seeded household.

9

Plan sync: update worker-portal-expansion.adoc to mark the CAPS authorization deferral resolved; update the Tier 5.5 row in roadmap.adoc.

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 only GET /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 only GET /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 has authorization_status

  • Template: auth.rate_display — struct has rate_cents_per_hour: i32 (raw cents)

  • Template: auth.expiration_date — struct has end_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 — existing caps_authorizations schema.

  • 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 — add CapsClient::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.

Rename template variables:

  • auth.care_typeauth.authorization_status

  • auth.rate_display → compute at render time from rate_cents_per_hour: ${value / 100:.2f}/hour (move formatting out of the template into a helper in case_detail.rs)

  • auth.expiration_dateauth.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.

Step 8: Playwright E2E

Files: tests/e2e/specs/caps.spec.ts (new) or extend an existing CAPS spec.

Log in as caseworker, navigate to CAPS case, click Authorization tab, assert provider + weekly hours + rate render. Include a dark-theme accessibility check alongside the existing pattern.

Step 9: Plan sync

Files: worker-portal-expansion.adoc, roadmap.adoc, CHANGELOG.adoc.

Files Touched

File Change

services/canopy-caps/src/store/mod.rs

+2 list helpers

services/canopy-caps/src/api/handlers.rs

+2 handlers

services/canopy-caps/src/api/mod.rs

Route registration

services/canopy-web/src/clients.rs

+2 CapsClient methods

services/canopy-web/src/api/case_detail.rs

Rewire render_caps_authorization

services/canopy-web/templates/cases/tab_authorization.html

Field rename (Option A)

services/canopy-caps/tests/*

New integration tests

tests/e2e/specs/caps.spec.ts

New Playwright coverage

docs/modules/ROOT/pages/plans/worker-portal-expansion.adoc

Resolved deferral

docs/modules/ROOT/pages/roadmap.adoc

Tier 5.5 row → Done

CHANGELOG.adoc

Unreleased entry

Verification

  1. cargo nextest run -p canopy-caps — new integration tests pass.

  2. cargo nextest run -p canopy-web — no regressions in case-detail rendering.

  3. Seed a CAPS determination + authorization, curl $CAPS_URL/v1/determinations?household_id=X and /v1/determinations/{id}/authorizations — JSON shape matches the utoipa schemas.

  4. cargo xtask e2e --grep "caps" — Playwright CAPS spec passes.

  5. Manual: navigate to seeded CAPS case in the worker portal, Authorization tab renders real data.

  6. cargo xtask validate — full battery green.

Documentation Updates

  • CHANGELOG.adoc== Unreleased entry

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

Edit this page · default