canopy-web API Reference

On this page

Overview

Backend For Frontend (BFF) serving the worker portal. Renders HTML pages via Askama templates with htmx for dynamic updates and Alpine.js for client-side interactions. Not a JSON API — returns HTML.

For caseworker workflow documentation, see SNAP Caseworker Guide. For visual reference of the page surfaces + login (layout diagrams, Orchard color tokens, htmx interactions), see Worker Portal Mockups.

NOTE

canopy-web is a server-rendered htmx BFF with no utoipa decorators — its routes are not part of an OpenAPI snapshot. The exception is the JSON composition surface (/v1/composition/*, documented at GET /api-doc/openapi.json via the CompositionApi doc) and DELETE /v1/composition/{surface}/user/me. The tables below document the route surface in narrative form rather than as per-endpoint response-code tables.

Base URL

http://localhost:8080

Authentication

Keycloak OIDC session (redirects to login page)

Session

PostgreSQL-backed, 8-hour TTL, HttpOnly cookie

Database

canopy_web (sessions only — no domain data)

NOTE
Worker read authorization (ADR-044; #1518). Every protected GET is program-scoped against the worker’s primary_programs claim: case detail and its fragments gate on household participation (the union of programs_requested across the household’s applications, any-of — the same authority as the #1516 fact-write gate; no participation overlap is a 403, an EMPTY union an honest 404 on the full page — a nonexistent household and an application-less one are deliberately indistinguishable — an unparseable union a 422, a lookup failure refuses the view; the tab and fact-history FRAGMENTS keep their banner denial shapes until #1526); case search and the command palette apply the participation gate per row (unknown participation drops fail-closed; case search degrades the fragment so an outage never reads as "not found", the palette omits silently by design); the application/notices/appeals indexes, team queue, renewals queue and dashboard panels are query-time scoped (programs= storage slugs or per-program route legs — no unfiltered fetch exists); notice PDFs and document bytes are authorized by their owning case before a byte streams. The census is machine-enforced: READ_SCOPE_POLICY (30 protected GETs, cargo xtask route-authz)
PANEL_SCOPE_POLICY (22 panels, exhaustiveness test). The audit surfaces are scoped since #1519 (the programs= row-visibility predicate upstream; citation-by-id authorizes against the row’s program set); /sse is scoped since #1520 (minimal invalidation messages — never the envelope; a per-connection fail-closed filter on the event’s #1519 program metadata; the stream terminates on mid-connection scope change and the browser reconnects); read-denial shape unification is #1526.

Pages

Method / Path Page Description

GET /

Dashboard (3 surfaces dispatched per WorkerRole)

Composition-driven dashboard. Surface dispatch via surface_for_role(&WorkerRole): Supervisor → 11-panel supervisor dashboard; Analyst (formerly QualityControl) → 6-panel analyst dashboard; Caseworker/EligibilitySpecialist/Admin → 12-panel worker dashboard (Stage 5 #495 + #496). Layouts per rulesets/{juris}/composition/{worker,supervisor,analyst}_dashboard.toml. All three surfaces share one _panel_grid.html Askama macro.

GET /team-queue

Team Queue (supervisor + admin only)

Stage 5 MR2 #496 FU-13: full-page route surfacing all status=submitted applications across the jurisdiction. 403 for non-Supervisor/Admin roles. Linked from sidebar+topbar nav only for supervisors.

GET /cases

Case Search

htmx live search (name, case number, last 4 SSN). Results update after 300ms / 2+ characters.

GET /cases/search?q=

Search Results (htmx partial)

Returns HTML table rows for the search results panel

GET /command-palette/search?q=

Command Palette (htmx fragment, #502)

Powers the always-mounted ⌘K modal in base.html. Empty q returns Recents + Suggested; non-empty fans out to canopy-persons + canopy-notices and groups the results.

GET /cases/{household_id}

Case Detail

Composition-driven section list (Georgia’s TOML populates the full 20-section case_detail surface; shell — tabs / scroll / card-grid — per the jurisdiction’s ShellSpec). Default focus section: household. ?program=all renders the cross-program summary matrix instead (program × status × last-determination-date).

GET /cases/{household_id}/tab/{tab_id}

Tab Content (htmx partial)

Returns HTML for a single tab. 13 explicit arms — household, income, determination, notices, appeals, activity, guidance, abawd, work-req, time-limits, categories, authorization, nutrition — plus the composed-section fallthrough (persons, assets, expenses, fact-history, verifications, audit, …), which runs the same composition dispatcher as the full-page load. The assets/expenses tabs are worker fact editors and persons is the worker member editor (T1-8 #676); household stays the read-only household summary.

GET /cases/{household_id}/fact-history/{resource}

Fact Change History (htmx partial, T1-6 #674)

Backs the case-detail "Change history" section’s resource sub-tabs ({resource} = income / asset / expense). Resolves the household’s members from canopy-persons, fans out per member to canopy-security’s person-scoped GET /v1/security/persons/{id}/fact-history/{kind}, and merges the ordered, attributed household view. The per-worker program-scope VIEW gate (#632 L1) is enforced at the BFF (canopy-security keeps its service/admin auth until ADR-019 on-behalf-of plumbing lands).

GET /applications

Application List

Queue of pending applications with expedited flags

GET /applications/{id}/process

Application Processing

Review page with eligibility result, approve/deny buttons

GET /applications/{id}/intake/{program}

Per-Program Intake (Plan worker-intake-program-independence MR4b)

Program-scoped intake page ({program} ∈ snap/tanf/medicaid/caps/wic). A worker landing on …/intake/tanf for an application sees only TANF-applicable sections (ADR-001 program-service isolation extended to the UI surface). The page’s section-save and run-determination affordances POST/PUT to the BFF proxy routes (see Action Endpoints).

GET /notices

Notice List

Notice history across the jurisdiction (canopy-notices)

GET /appeals

Appeal List

Fair-hearing / appeal queue (canopy-appeals)

GET /renewals

Renewal Queue

Certifications due within 30/60/90 days, color-coded by urgency

GET /renewals/list?window=

Renewal List Fragment (htmx partial, #530)

Returns the renewal table body for the 30/60/90-day filter swap (no full-page chrome)

GET /audit-log

System Audit Log (#503, epic &53)

System-wide audit stream read from canopy-security. Admin / StudioAdmin / Auditor roles only (403 for everyone else). Filters: an allow-listed source_service, a clamped limit, and an inclusive from/to date range (resolved to a half-open UTC window upstream); rows are selectable, driving the sticky master-detail rail.

GET /studio

Composition Studio Home (Stage 6 MR1, #499, epic &51)

Jurisdiction composition Studio landing. StudioAdmin/Admin only (StudioAdminOnly extractor).

GET /studio/onboarding

Studio Onboarding Start

Entry point for the composition onboarding wizard.

GET, POST /studio/onboarding/step/{step}

Studio Onboarding Step (htmx wizard)

GET renders a wizard step; POST advances it (session-stored WizardDraft).

GET /studio/onboarding/bundle

Studio Onboarding Bundle (#530 fragment)

Renders the generated composition bundle preview.

GET /dashboard/customize

Customize My Dashboard (Stage 5 MR3 #498, ADR-024)

Per-worker dashboard composition editor. Hide / reorder / resize panels within the role’s baseline; HTML5 native DnD + keyboard pickup (Space + arrows). Save persists via PUT /v1/composition/{surface}/user/me with a user_delta_v1 body (dashboard surfaces only). Reset reverts via DELETE /v1/composition/{surface}/user/me. 404s for non-dashboard surfaces (surface resolved via surface_for_role).

SSR deadlines & degraded states

Six read routes run under an aggregate request deadline (#1306, plan SSR aggregate request deadline + honest degraded states): every upstream read a page makes — gate wait, retries, token acquisition, body/decode — is bounded by one absolute per-request cutoff, and an upstream failure renders an honest per-component state instead of a fabricated success or a whole-page 500.

Budgets

Budget Default Env knob Applies to

Page

12 000 ms

CANOPY_WEB__PAGE_DEADLINE_MS

GET /, GET /cases, GET /cases/{household_id} (incl. ?program=all)

Fragment

8 000 ms

CANOPY_WEB__FRAGMENT_DEADLINE_MS

GET /cases/search, GET /cases/{household_id}/tab/{tab_id}, GET /dashboard/panel/{slug}

Boot validation bounds the knobs (page ∈ [1 000, 14 000] ms, fragment ∈ [1 000, 9 000] ms; out-of-range rejects at boot). CANOPY_WEB__DEADLINE_OVERRIDE=true is the per-control accountable override (deployment owns the risk; loud warn every boot; a hard 600 s cap still applies). Raising budgets is never the fix for a slow upstream. Per-component caps come from each plugin manifest’s data.timeout_ms (default 5 000 ms) — the binding constraint is always min(page deadline, component cap).

Session-refresh budget (#1340)

The auth extractor’s token-refresh slow path runs under the same one-cutoff model, with its own 3 s constant budget (REFRESH_SLOW_PATH_BUDGET, minted at slow-path entry — the extractor runs before any page deadline exists). One budget covers the per-session single-flight mutex wait, the session re-read, and the RFC 6749 refresh_token grant (whose transport is bounded by the residual, always tightening the shared client’s 10 s default). Elapse at any stage fails closed to /login — an expired token is never served — and a waiter whose budget was exhausted queueing never fires a grant, so a hung IdP costs each request at most the budget instead of the pre-#1340 worst case (10 s × queue depth, every waiter re-firing serially). Post-grant validation and session persist deliberately run to completion un-cancelled (#475 gap 2: an abandoned persist after a rotated grant manufactures an invalid_grant cascade).

Honest state vocabulary

Every covered component (dashboard panel, case-detail section, hero, search rows, ?program=all row) renders exactly one of:

  • populated — real data.

  • empty — the upstream answered and there is genuinely nothing (the EARNED empty; an outage is never rendered as "All caught up" / "No determinations").

  • degraded (partial) — some legs failed while others delivered; rows stay visible under an incomplete-list banner with a real per-component Retry.

  • error — the component’s spine failed; a static error block with Retry. Copy is time-aware — a time-class failure reads "Took too long to load — try again", any other failure "Couldn’t load right now" — and NEVER quotes upstream text (#715).

Telemetry

Instruments on the canopy_web meter (all label values are closed enum names or compile-time slugs — upstream text and diagnostics never cross into metrics):

Instrument Labels Recorded

canopy_web.upstream.call_outcome (counter)

service; kind ∈ {ok, deadline_exceeded, timeout, transport, http_4xx, http_5xx, exhausted, decode, auth_unavailable, token_timeout}

Once per logical upstream call, in the client verb tail after status + decode.

canopy_web.component.outcome (counter)

surface ∈ {dashboard, cases, case_search, case_detail, tab, program_all}; component = plugin-registry slug, one of the static components (hero, search_rows, program_all), or unknown; outcome ∈ {populated, empty, partial, error, timeout}

Once per rendered component — panels on GET / and the panel-retry fragment, sections on case detail and tab loads, the hero, the search rows, and each ?program=all row.

canopy_web.page.outcome (histogram; value = handler wall-clock ms, remaining_ms = deadline budget left at exit)

surface; outcome ∈ {ok, degraded, error}

Once per handler exit on each of the six covered routes — including redirects, authz denials, and error exits.

canopy_web.panel_cache.outcome (counter, #1218)

service (the upstream roster, plus canopy-web for seam-level bypass records); kind ∈ {hit, miss, coalesced, bypass}

hit/miss/coalesced once per cache-scoped GET (exactly one disposition per consultation; a hit never also ticks upstream.call_outcome); bypass once per dispatch SCOPING event whose resolved TTL is 0.

canopy_web.panel_cache.evictions (counter)
canopy_web.panel_cache.entries / .bytes (observable gauges)

service on evictions; gauges unlabeled

Evictions under the R6 byte/entry budgets (CANOPY_WEB__PANEL_CACHE_{MAX_ENTRIES,MAX_BODY_BYTES,MAX_TOTAL_BYTES}); occupancy observed from the shared cache each collection.

The #1218 panel-data cache sits at the InternalClient JSON-GET seam: fail-closed guards (page deadline, auth-unavailable) run BEFORE any cache read; entries are keyed (service, full URL, sha256(credential)); hits are judged against the CALLING panel’s resolved TTL (composition item override, else the manifest author default; 0 bypasses); per-key single-flight coalesces cold bursts into one upstream call; only successfully-decoded 2xx bodies are ever cached.

A page records degraded when it rendered with at least one failure-class component (error / timeout / partial), error on a non-2xx exit (and on the /cases/search fragment whose single component errored outright), else ok.

Dashboard composition (worker overrides)

ADR-024 introduces a user_delta_v1 semantic envelope for the user-layer composition row on dashboard surfaces. case_detail and other surfaces continue using RFC 6902 ops verbatim.

Body shape Description

{"type": "user_delta_v1", "hidden_slugs": […​], "span_overrides": {slug: span}, "slug_order": […​]}

Sent as the body of PUT /v1/composition/{surface}/user/me where surfaceworker_dashboard, supervisor_dashboard, analyst_dashboard. Each referenced slug must exist in the post-role-filter baseline (SlugNotInBaseline → 422 otherwise); each (slug, span) in span_overrides must appear in the plugin manifest’s allowed_spans (SpanOutOfRange → 422); loader replays the delta over the baseline at next dashboard render. PATCH against a dashboard surface returns 415 (the semantic schema is PUT-only).

RFC 6902 [{"op":"add"/"replace"/"remove",…​}, …​]

Unchanged path for non-dashboard surfaces (e.g. case_detail). Same precondition headers (If-Match / If-None-Match: *). Dashboard surfaces still accept this shape as a back-compat fallback but the customize UI never emits it.

DELETE /v1/composition/{surface}/user/me

Reset to baseline. 204 on success or when no user-layer row existed (idempotent). Audit fires only when a row was actually removed.

The composition JSON sub-router (the only utoipa-documented surface; merged CSRF/require_auth-bypassed per Decision 11 + 17, authenticated via session-based JSON extractors) exposes the three DB-backed override layers from ADR-022:

Method / Path Description

GET, PUT, PATCH, DELETE /v1/composition/{surface}/live

Jurisdiction-live override layer. GET reads; PUT replaces; PATCH applies RFC 6902 ops; DELETE removes the row. test ops support optimistic concurrency (ADR-022).

POST /v1/composition/{surface}/live/archive

Explicit-archive the live override after a Studio promote-merge (ADR-022 lifecycle).

GET, PUT, PATCH /v1/composition/{surface}/role/{role}

Role override layer. Role filtering applies after override merge (ADR-021). 404 for an unknown {role} (RoleNotFound).

GET, PUT, PATCH, DELETE /v1/composition/{surface}/user/me

Per-worker user layer. Dashboard surfaces use the user_delta_v1 envelope (PUT-only; PATCH → 415); case_detail and other surfaces use RFC 6902 ops. DELETE resets to baseline (idempotent 204).

Action Endpoints (POST / PUT)

These endpoints process caseworker actions (and BFF proxies) and redirect back to — or swap a fragment into — the relevant page.

NOTE
Worker fact-write authorization (ADR-044; #1516 closed the #632-era label bypass). Every fact-write action — income (/actions/income/), the asset/expense editors (/actions/{asset,expense}/), the member editors (/actions/{member,person}/), and the address editor (/actions/address/) — authorizes against the resource, never the posted program label: the household’s participating programs (the union of programs_requested across its applications, looked up per request) must intersect the worker’s WorkerProgramScope (any-of — shared facts are not per-program). The pre-#1516 gate trusted the caller-supplied program field, so a SNAP-only worker could post program=snap and edit facts on a household that participates only in TANF; that bypass is closed, and the posted program survives only as the post-redirect tab. Denials are 403 (out of scope) or 422 (the household has no usable participation set — fail-closed, never a permitted write), always before the canopy-persons call. Structurally, the write verbs on the internal clients are module-private: every mutation must present an AuthorizedResource proof to obtain a write-capable client, so deleting an authorization check fails to compile, and cargo xtask route-authz additionally requires every mutating route’s scope classification. This is BFF-level enforcement; server-side enforcement at the canopy-persons boundary is epic &52 / #424. Facts are authored as worker claims (auto-accepted accepted_verified) into the canopy-persons version corpus. Every fact editor also enforces the household-membership IDOR guard: the income/asset/expense/address editors bind the posted person_id to the posted household’s roster (#996), and since #1523 the member editors do too — edit_person runs the same person↔household binding, and remove_member binds the posted membership fact_id to the household’s roster (canopy-persons independently enforces that binding at the system of record via require_member_ownership, a 404 before any mutation — the BFF check gives tampered ids the uniform not-a-member 403 and spends no upstream mutation round-trip). add_member posts no person id at all: the person is created and attached to the just-authorized household in one flow, so there is nothing to bind (the #1523 ruling).

Application lifecycle + intake proxies

Endpoint Description

POST /applications/{id}/approve

Approve an application — calls canopy-eligibility orchestrator

POST /applications/{id}/deny

Deny an application — records denial with reason code

POST /applications/{id}/run-determination?program={program}

Per-program Run Determination (Plan worker-intake-program-independence MR4). ?program= is required — a missing value renders an error banner rather than silently defaulting to SNAP. Replaces the removed household-scoped /cases/{household_id}/run-determination route. BFF proxy to canopy-eligibility.

POST /applications/{id}/record-ele-consent

Worker-attested ELE (Express Lane) consent (#977). Gated on write + SNAP-or-TANF program scope. BFF proxy that POSTs a typed EleConsentRequest (consent_source=worker_attestation) to canopy-applications' POST /v1/applications/{id}/ele-consent with canopy-web’s service identity (ADR-019). Pre-determination only — a 409 (application already determined) renders a friendly inline message; the Action ▾ item is disabled once determined. On success, PRG-redirects back to the case (the ELE grant surfaces on the identity hero).

PUT /applications/{id}/sections/{program}/{section}

BFF proxy: per-program intake section save. The htmx form posts application/x-www-form-urlencoded; the proxy (put_section_proxy) is the JSON-shaping authority — it converts the flat fields into the typed section payload, fills verified_at, drops the CSRF echo, and (for household_composition) synthesizes the members array from the canopy-persons household membership. The intake page prefills household_id + head-of-household person id from the application’s household so no UUID is hand-typed. Forwards to canopy-applications' PUT /v1/applications/{id}/sections/{program}/{section}.

POST /applications/{id}/programs/{program}/complete-data-collection

BFF proxy: complete data collection for a program. Forwards to canopy-applications' POST /v1/applications/{id}/programs/{program}/complete-data-collection.

POST /cases/{household_id}/request-verification

"Request Verification" Action ▾ mini-form (demo-dataset-seed Step 9e). POSTs a CreateVerificationRequest to canopy-verification on the worker’s behalf.

POST /cases/{household_id}/file-application

"File application" Action ▾ modal (#978). Files a NEW application for the EXISTING household across one or more programs (the worker-side intake; the applicant portal always mints a fresh household). One checkbox per fileable program (deployed ∩ the worker’s scope), named by the bare program slug; gated on write + per-program scope (fail-closed — any out-of-scope program is denied). Resolves submitted_by server-side to the head of household from GET /v1/households/{id}/full (never a client field), then POSTs a typed CreateApplicationRequest (submitted_by_role=caseworker, submission_channel=in_person) to canopy-applications' POST /v1/applications with canopy-web’s service identity (ADR-019). PRG-redirects to the first filed program’s case view.

Generic / SNAP caseworker actions

Endpoint Description

POST /actions/interim-contact

Record an interim contact — calls canopy-renewals

POST /actions/change-report

Submit a change report — calls canopy-renewals

POST /actions/renewals/file-recert

Record the worker’s decision to FILE a recertification in response to a material recert nudge (T2-7 #680) — calls canopy-renewals POST /v1/renewals/snap/nudges/{id}/action with action=filed_recert (intent only; provisioning the recert application is a tracked follow-up). PRG-redirects back to the case

POST /actions/renewals/dismiss-nudge

Dismiss a material recert nudge without recertifying (T2-7 #680) — the same nudge-action endpoint with action=dismissed

POST /actions/abawd-activity

Record ABAWD work activity — calls canopy-snap

POST /actions/resolve-discrepancy

Resolve an IEVS income discrepancy — calls canopy-snap

POST /actions/documents/accept

Accept an applicant-uploaded document (forwards to canopy-applications). Plan 4 G3: also resolves any pending verification the document was submitted against (queries canopy-verification’s ?document_id= filter, then POST /v1/verifications/{id}/resolve for each) — so one Accept clears the open verification gating Run Determination. Best-effort; a document that satisfied no verification is a no-op.

POST /actions/documents/reject

Reject an applicant-uploaded document with a worker-supplied reason — forwards to canopy-applications.

POST /actions/income/add

Add an income record — authors a worker income claim into the canopy-persons version corpus (#632-gated)

POST /actions/income/edit

Edit an income record — authors a /claims correction (#632-gated)

POST /actions/income/remove

Remove an income record — closes the income fact (#632-gated)

POST /actions/asset/add

Add an asset fact — authors a worker asset claim into the version corpus (T1-8 #676; #632-gated). No remove yet (asset close primitive is #562)

POST /actions/asset/edit

Edit an asset fact — authors a /claims correction (T1-8 #676; #632-gated)

POST /actions/expense/add

Add an expense fact — authors a worker expense claim into the version corpus (T1-8 #676; #632-gated)

POST /actions/expense/edit

Edit an expense fact — authors a /claims correction (T1-8 #676; #632-gated)

POST /actions/address/add

Add a residential/mailing address — authors a worker address claim (POST /v1/persons/{id}/addresses/claims, typed AddressClaimRequest) into the version corpus (#983; #632-gated plus the household-membership IDOR guard below)

POST /actions/address/edit

Edit/move an address — authors a /claims valid-time correction (carries fact_id, re-tiles the open window) (#983; #632-gated + membership-gated). A redacted (crypto-shredded) street renders read-only and cannot be edited

POST /actions/member/add

Add a household member — creates a person then attaches them (T1-8 #676; #632-gated). Un-versioned identity write; SSN excluded (PII). Orphan-safe (logs the person id if the attach fails after create)

POST /actions/person/edit

Edit a member’s person demographics — PUT /v1/persons/{id} (T1-8 #676; #632-gated). COALESCE-aware (blank fields keep their value); SSN + relationship not editable here

POST /actions/member/remove

Remove a household member — DELETE /v1/households/{household_id}/members/{member_id} (T1-8 #676; #632-gated)

POST /actions/ievs/accept

Accept a SNAP IEVS income discrepancy (T1-9 #677; #632-gated). Authors a worker-verified income fact via POST /v1/persons/{id}/income/claims (source=ievs, origin=ievs:{discrepancy_id}, auto-accepted accepted_verified) — the raw IEVS figure stays snap-local (ADR-004 §2025(e)), only the worker-verified value enters canopy-persons — then flips the snap discrepancy to accepted_verified. Persons-first + origin-based idempotent fact resolution (retry-safe). A matched self-report is corrected; an unreported/ambiguous hit authors a new fact

POST /actions/ievs/reject

Reject a SNAP IEVS income discrepancy (T1-9 #677; #632-gated). Flips the snap discrepancy to rejected (audited via the attributed ievs.discrepancy_resolved event) — no canopy-persons write (ADR-027 §2)

POST /actions/snap/create-certification

Open a household’s SNAP certification period (#973; #632-gated). The form carries only the household id + the two cert-period dates; the determination + application ids are resolved server-side from the household’s current approved SNAP determination (the template exposes only a presentational id), and an existing in-force certification is refused — calls canopy-renewals POST /v1/renewals/snap/certifications. PRG-redirects to the case (which shows the new certification period)

POST /actions/snap/overpayment-recompute

Replay a determination’s frozen snapshot against the corrected facts to size an overpayment (T2-8 #681; #632-gated). The worker supplies the correction date + claim basis (agency_error | inadvertent_household_error); the contested determination is resolved server-side from the household’s current SNAP determination (#976 — the strip’s DET-… id is presentational, never a form field). Typed against canopy-contracts-snap; requested_by derives from canopy-web’s service identity (ADR-019). The recompute is idempotent and degrades to a typed manual-review outcome — the redirect surfaces only transport errors

POST /actions/snap/create-enrollment

Open a SNAP enrollment for the household’s current approved determination (#976; #632-gated). The worker enters only the certification period + expedited flag; the determination + application ids, the monthly allotment (the determination’s net benefit), and the application filing date (the LEGAL filing day — date_in(received_at, [jurisdiction].timezone), #1583) are resolved server-side — calls canopy-enrollment. PRG-redirects to the case Determination tab (which shows the enrollment).

POST /actions/snap/issue-benefits

Issue one benefit month against the household’s latest active SNAP enrollment (#976; #632-gated). The enrollment is resolved server-side (created_at DESC → latest); the benefit month is normalized to the first of the month — calls canopy-enrollment. Repeatable per month

POST /actions/snap/file-appeal

File a SNAP fair-hearing appeal (#974; #632-gated; action-selection #1103). The worker picks the requestor + method and OPTIONALLY one of the household’s OPEN adverse actions — the selected id is validated against the household’s own open actions (neutral refusal on a foreign/unknown id) and travels on the typed filing, so the Chart B2 continued-benefits election runs action-bound in canopy-appeals (filing date SERVER-STAMPED — backdating died with #1098); blank = narrative grievance on the current determination (no CB). The Form 118 waiver + repayment-disclosure checkboxes are recorded on the filing. PRG-redirects to the case Appeals tab

POST /actions/snap/record-decision

Record a hearing decision on an existing SNAP appeal (#975; #632-gated). SNAP-only; the BFF fetches the appeal and rejects unless it belongs to the posted household, is a SNAP appeal, and is still pending/scheduled (the appeals service authorizes only the service caller). decision is restricted to upheld_agency / reversed_household; decision_basis is required — calls canopy-appeals (PUT /v1/appeals/{id}/decision). PRG-redirects to the case Appeals tab (which re-renders the appeal as decided + any assessed overpayment)

POST /actions/snap/schedule-termination

Schedule a SNAP adverse action (#1103; #632-gated). The target enrollment is resolved server-side (latest active — no id to tamper with); reason_code must be in the boot-loaded policy vocabulary (no free text, re-validated server-side); the PAMMS 3705 advance-notice exemption is OPERATOR-tier (fields render for eligibility specialist+ only AND the handler re-checks the role; authority citation pair-required); actor is the authenticated worker. Calls canopy-enrollment POST /v1/adverse-actions (which stages the notice trigger in the same transaction, #1102). PRG-redirects to the Determination tab’s scheduled-actions read-back

POST /actions/snap/cancel-termination

Cancel a scheduled adverse action (#1103; #632-gated). Fetches the action FIRST and refuses a household mismatch with the same neutral message as not-found (no existence oracle); enrollment moots any stayed appeal links atomically. Optional reason lands on the action’s signal ledger; actor is the authenticated worker

Program-specific actions (#392)

Each program tab on case detail exposes its own action handlers. Program ∈ {tanf, medicaid, caps, wic}.

Endpoint Description

POST /actions/tanf/interim-contact

Record a TANF interim contact — calls canopy-renewals

POST /actions/tanf/change-report

Submit a TANF change report — calls canopy-renewals

POST /actions/tanf/work-activity

Record a TANF work activity — calls canopy-tanf

POST /actions/tanf/resolve-discrepancy

Resolve a TANF income discrepancy — calls canopy-tanf

POST /actions/medicaid/interim-contact

Record a Medicaid interim contact — calls canopy-renewals

POST /actions/medicaid/change-report

Submit a Medicaid change report — calls canopy-renewals

POST /actions/medicaid/cmd-update

Ingest a Change in Medicaid Determination (CMD) update — calls canopy-medicaid POST /v1/cmd/ingest

POST /actions/medicaid/resolve-quarantined

Re-queue a quarantined Medicaid determination — calls canopy-medicaid POST /v1/determinations/{id}/requeue

POST /actions/caps/interim-contact

Record a CAPS interim contact — calls canopy-renewals

POST /actions/caps/change-report

Submit a CAPS change report — calls canopy-renewals

POST /actions/caps/update-authorization

Partial-update a CAPS authorization — calls canopy-caps PUT /v1/authorizations/{id}

POST /actions/caps/switch-provider

Switch a CAPS authorization’s provider — calls canopy-caps PUT /v1/authorizations/{id}/provider (FK-validated; unknown providers surface as 422)

POST /actions/wic/interim-contact

Record a WIC interim contact — calls canopy-renewals

POST /actions/wic/change-report

Submit a WIC change report — calls canopy-renewals

POST /actions/wic/schedule-appointment

Schedule a WIC certification appointment — calls canopy-wic POST /v1/wic/certifications/{id}/appointments

POST /actions/wic/nutritional-risk

Record a WIC nutritional-risk assessment — calls canopy-wic

Studio (StudioAdmin/Admin only)

Endpoint Description

POST /studio/onboarding/step/{step}

Advance the composition onboarding wizard (session-stored WizardDraft)

POST /studio/onboarding/cancel

Cancel the onboarding wizard and clear the draft

All action endpoints validate CSRF tokens and require an active session. Studio routes additionally require StudioAdmin/Admin via the StudioAdminOnly extractor.

Other routes

Method / Path Description

GET /notices/{id}/pdf

Stream a generated notice PDF (proxied from canopy-notices)

GET /audit-log/export.csv

CSV export of the audit stream — same Admin/StudioAdmin/Auditor gate and the same source/limit/date filters as GET /audit-log, so the downloaded file matches the displayed view

GET /audit-log/citation/{event_id}/pdf

Signed "Cite for hearing" citation PDF for one audit event (#503 slice 8, ADR-029) — rendered via canopy-notices' POST /v1/documents/render (audit-citation template, ES256-signed); same Admin/StudioAdmin/Auditor gate. The .pdf is a separate path segment (matchit forbids a param + literal suffix in one segment); the download filename comes from Content-Disposition

GET /documents/{application_id}/{document_id}/content

Worker content-proxy for applicant-uploaded documents (Plan 3 MR9b) — the applicant-side object is service-token-gated, so the BFF fetches the bytes from canopy-applications with its service identity and serves them inline with the upstream Content-Type (PDFs / images open in a new tab). Upstream 404 → 404, other failures → 502 (#594 — never a 200 error page)

GET /sse

Server-Sent Events stream for live dashboard / case-detail updates

GET /login, GET /logout

OIDC session establish / tear-down. ?error=<code> (ADR-044, #1515) renders the sign-in page with an admission-rejection banner and suppresses the single-IdP auto-redirect, so a refused worker reads the reason instead of looping IdP → callback → reject → IdP. Recognized codes: no_role, missing_primary_programs, malformed_primary_programs; anything else renders the ordinary login (no 400, no banner) and the raw parameter is never reflected into the page

GET /auth/callback, GET /auth/landing, GET /auth/select, GET /auth/local-login

OIDC sign-in surface (Stage 4 #493/#494 — htmx IdP discovery + chip-select + local-account stub). /auth/callback applies fail-closed admission (ADR-044): a validated token with no recognized role or no usable primary_programs claim establishes no session and redirects to /login?error=<code>

GET /v1/auth/discover

htmx IdP-discovery fragment for the sign-in page

GET /livez, GET /readyz

Liveness / readiness probes

GET /theme.css

Dynamic jurisdiction-specific theme stylesheet (served as a real stylesheet for strict-CSP compatibility)

GET /api-doc/openapi.json

OpenAPI JSON for the composition surface only (CompositionApi); no Swagger UI in v1 (strict CSP)

/static/*

Static asset serving (ServeDir)

Error Codes

Status codes emitted across the page, action, and composition surfaces. Page/action routes mostly return HTML (or a 303 redirect back to the originating page); the /v1/composition/* JSON surface returns the precise codes below with a structured error body.

Status Meaning

200 OK

Page or htmx fragment rendered; composition GET returned a document

204 No Content

Composition DELETE succeeded (or was idempotent no-op — no row existed)

303 See Other

Action handler / auth route redirect back to the originating page (Redirect::to)

400 Bad Request

Malformed action form / missing required field (e.g. run-determination without ?program= renders an error banner; other malformed inputs reject)

401 Unauthorized

No active session on a protected JSON composition request

403 Forbidden

Role gate failed — /team-queue for non-Supervisor/Admin, Studio routes for non-StudioAdmin/Admin, or composition role-scoped writes outside the caller’s authority

404 Not Found

Unknown route (fallback handler), unknown household/application, unknown composition surface, or RoleNotFound on /role/{role}

412 Precondition Failed

If-Match / If-None-Match: * optimistic-concurrency precondition not met on a composition write

415 Unsupported Media Type

PATCH against a dashboard composition surface (the user_delta_v1 schema is PUT-only)

422 Unprocessable Entity

UserDeltaError family on composition writes — SlugNotInBaseline, SpanOutOfRange, RowOverflow (or other structured validation failures)

428 Precondition Required

Composition write missing a required If-Match precondition header

429 Too Many Requests

Per-IP rate limit exceeded (governor middleware)

500 Internal Server Error

Composition loading failed or an unexpected template-render error occurred. On the six deadline-covered read routes an upstream data failure no longer 500s — it renders the honest per-component degraded/error state (see SSR deadlines & degraded states); action (POST/PUT) proxies and the not-yet-covered read pages (#1319) still surface upstream failures as 5xx

501 Not Implemented

GET /auth/local-login stub (local-account sign-in not yet wired — Stage 4)

Backend Service Clients

canopy-web proxies all data requests to backend JSON API services:

Service Purpose

canopy-persons

Household and member data, income CRUD

canopy-applications

Application queue, processing, per-program intake sections + complete-data-collection

canopy-eligibility

Eligibility determination orchestration, run-determination, case-status, cross-program alerts, determinations

canopy-verification

Verification requests (request-verification action)

canopy-snap

ABAWD tracking, discrepancies, SNAP parameters

canopy-tanf

Work activities, TANF discrepancy resolution

canopy-medicaid

CMD ingest, quarantined-determination re-queue

canopy-caps

Authorization updates, provider switch

canopy-wic

Certification appointments, nutritional-risk assessments

canopy-renewals

Certification periods, interim contacts, change reports

canopy-notices

Notice history and PDF download, command-palette search

canopy-appeals

Appeal filing and status

canopy-security

Audit events for activity tab

Security

  • Strict CSP: no unsafe-inline or unsafe-eval (Alpine.js CSP build, nonce-based styles)

  • CSRF protection on all POST routes

  • HttpOnly session cookie (SameSite=Lax)

  • Per-IP rate limiting — replica-aware Redis fixed window when CANOPY_WEBRATE_LIMIT_REDIS_URL is set (#1227: the effective limit is invariant under replica count; on Redis failure it degrades to the process-local governor limiter, never to unlimited); rpm via CANOPY_WEBRATE_LIMIT_RPM

  • All backend calls authenticated via the user’s JWT (forwarded from session)

Edit this page · default