Plan: Demo-Review Hardening (Epic &57)
On this page
Authored from a code-grounded research pass (5-agent workflow, 2026-06-04) over the issues filed from the validated demo-sprint review. Two premises shifted since the issues were written — read Corrected Scope (research, 2026-06-04) before implementing:
-
#598’s backend is already done. canopy-security’s
GET /v1/security/eventsalready accepts?household_id=and filters on a realhousehold_idcolumn (store/mod.rs:189, migration20260601000010), and every household-linked emitter already bakeshousehold_idinto the event metadata at publish time — so the filter is pure-SQL and ADR-001 isolation holds (canopy-security never needs persons/eligibility data). The only remaining leak is the legacyrender_activity_tab(case_detail.rs:2219), which still discards thehousehold_idit receives. The compositionauditsection (MR5b,sections/audit.rs) already filters correctly. So #598 is a small canopy-web fix, not the backend feature the issue describes. -
#693 is blocked on epic &56. The applicant portal is structurally SNAP-only — no multi-program selection UI, no program context in the session, nothing to thread. "Thread program from context" cannot work until multi-program intake exists (epic &56). #693 is recorded here as
Blocked, not implemented.
Status
| Step | Description | Status |
|---|---|---|
MR1 — #598 household-scoped Activity tab (the prod-blocker) |
||
1 |
canopy-web: |
Done (2026-06-04) — |
2 |
Verify/add the canopy-security household-filter integration test (seed a household + two audit events, assert the scoped query returns exactly the matching row). |
Done (2026-06-04) — already covered: |
MR2 — #694 per-program case-search status |
||
3 |
canopy-eligibility: add |
Done (2026-06-04) — |
4 |
canopy-web: |
Done (2026-06-04) — |
MR2 — #695 + #696 worker-portal small fixes (folded with #694 into one MR) |
||
5 |
canopy-web (#695): |
Done (2026-06-04) — |
6 |
canopy-web (#696): queue/search/recent-determination program badges swap the non-existent |
Done (2026-06-04) — 3 templates swapped to |
Deferred |
||
— |
#693 (portal SNAP-hardcode) — Blocked on epic &56 (multi-program intake). Mark the issue blocked-by &56; no code change here. |
Blocked (&56) |
7 |
Docs + CHANGELOG + GitLab issue/epic updates. |
Done (2026-06-04) — CHANGELOG (#694/#695/#696); eligibility OpenAPI snapshot regen; #693 marked blocked. |
Epic: &57
Issues: #598 (prod-blocker, MR1), #694/#695/#696 (MR2); #693 (deferred, blocked on &56)
Branches: feat/598-household-audit-scope (merged), feat/epic57-worker-portal-fixes (#694/#695/#696)
Corrected Scope (research, 2026-06-04)
-
#598 — backend already shipped.
services/canopy-security/src/api/mod.rsacceptshousehold_id;store/mod.rs:175-202filtersAND ($4::UUID IS NULL OR household_id = $4); migration20260601000010_add_household_id_to_audit_events.sqladded the column + a partial index. The ADR-001 worry in the issue ("resource_id references a person/determination") is moot: emitters (canopy-persons,canopy-snap,canopy-applications,canopy-eligibility,canopy-notices) writehousehold_idinto the event metadata at publish time, and the security ingest lifts it into the typed column (store/mod.rs:82-96). Auth/rules/system events legitimately carry nohousehold_id(NULL) and are excluded from a household-scoped view — correct. Remaining work = the canopy-web caller only. -
#693 — blocked.
pages/apply.rs:732hardcodesprograms_requested: ["snap"]anddocuments.rs:32const UPLOAD_PROGRAM = "snap", both with deferral comments. There is no multi-program selection UI or session program context to thread (verified). The fix is epic &56’s intake work; until then any change is premature. Record as blocked-by &56.
Design
MR1 — #598 household-scoped Activity tab
render_activity_tab (services/canopy-web/src/api/case_detail.rs:2212) receives household_id and discards it:
// stale: claims security events "aren't yet filterable by household"
let _ = household_id;
let events = clients.security
.get::<Vec<serde_json::Value>>("/v1/security/events?limit=50")
.await ...
The composition audit section already does this right via sections/audit.rs::list_events_url_for_household(household_id) → String (UUID-validated, falls back to the bare path on a non-UUID so canopy-security degrades to "no rows" rather than 4xx). render_activity_tab is shared by the legacy get_tab "activity" arm (case_detail.rs:1439) and the composition activity section (sections/activity.rs:27), so fixing the function fixes both surfaces.
Fix: move list_events_url_for_household from services/canopy-web/src/case_detail/sections/audit.rs:141 (where it is a private fn) to services/canopy-web/src/audit/mod.rs (alongside partition_events at audit/mod.rs:54, which render_activity_tab already imports via use crate::audit::partition_events), promote it to pub(crate), and update sections/audit.rs to call it via the crate::audit:: path. Then call it from render_activity_tab and delete the stale comment + the let _ = household_id no-op. The dashboard Audit Events panel stays jurisdiction-wide (different semantics — untouched).
Test (Step 2): the canopy-security store/handler filter is the regression-critical surface. Verify services/canopy-security/tests/security_test.rs covers the household_id filter; if not, add an integration test that seeds one household-tagged event + one for another household and asserts ?household_id= returns exactly the first. The list_events_url_for_household URL builder is already unit-tested in audit.rs.
MR2 — #694 per-program case-search status
services/canopy-web/src/api/cases.rs renders the program label from the worker/program-scope intersection (intake_program_slug) but the status badge from GET /v1/eligibility/case-status?household_id= → get_latest_status_by_household (ORDER BY determined_at DESC LIMIT 1 across all programs). A mixed-outcome household (SNAP approved + TANF denied) renders "TANF / Active". CaseStatus already carries program.
Fix: canopy-eligibility gains a per-program status lookup — extend GET /v1/eligibility/case-status with an optional program query param backed by a new get_latest_status_by_household_and_program store fn (WHERE household_id = $1 AND program = $2 ORDER BY determined_at DESC LIMIT 1). In cases.rs, pass the exact program slug that fetch_household_program(clients, &household_id, primary) resolved (the value rendered as the label at cases.rs:166) — not the worker’s primary claim — so the status badge matches the rendered label. fetch_household_program already computes the in-scope slug; thread it (or its slug) into fetch_case_status so both come from one source. Absent a determination for that program, the badge reads "—" (no determination) rather than borrowing another program’s status.
MR3 — #695 + #696
#695 (trivial): services/canopy-web/src/api/applications.rs:1359 —
.unwrap_or(false) → .unwrap_or(true) with a // SILENT-OK: note. A transient canopy-verification error then renders the Run-Determination button as potentially-blocked (disabled), matching the server-side action gate (applications.rs:846-862, which fails closed). Satisfies coding-conventions §Errors (every swallowed Result propagates, logs, or carries // SILENT-OK:).
#696 (cosmetic): my_queue.html:45, cases/search.html:37, recent_determinations.html:26 render class="u-status-{{ item.program }}" → u-status-SNAP, which matches no CSS rule (the u-status- vocabulary, canopy-web.css:440-465, is lowercase status *kinds). The program primitive .u-badge-program (canopy-web.css:698) already exists and is used by the live htmx cases/_results.html:31 (class="badge u-badge-program" with program_label as text — the precedent to copy). The fix follows that precedent: render class="badge u-badge-program" with the humanized program as text. The u-status-{program} class was the only reason the raw program value was needed in the class slot, so no new program_slug field is required — the templates keep rendering the existing humanized item.program as the badge text and just swap the class to the static .u-badge-program. (my_queue.rs:122 already humanizes the slug into WorkQueueItem.program before the struct; recent_determinations.rs similarly. Leave those structs as-is.) Coordinates with epic &53 (design-fidelity) but is self-contained.
Steps
Step 1-2 (MR1 — #598)
Files: services/canopy-web/src/api/case_detail.rs, services/canopy-web/src/audit.rs (or wherever partition_events lives) + sections/audit.rs (move helper), services/canopy-security/tests/security_test.rs.
Step 3-4 (MR2 — #694)
Files: services/canopy-eligibility/src/store/mod.rs (+ the case-status handler + contract param), services/canopy-web/src/api/cases.rs. Regenerate the canopy-eligibility OpenAPI snapshot if the param is utoipa-documented.
Verification
-
cargo nextest run -p canopy-security -p canopy-web -p canopy-eligibility— filter + caller + per-program status tests pass. -
cargo xtask dev refresh+ a manual case-detail Activity-tab check: events scope to the viewed household. -
cargo xtask validate— clean. -
cargo xtask e2e— no worker-portal regression (queue/search badges render; Run-Determination gate behaves).
Documentation Updates
-
CHANGELOG.adoc— one entry per MR. -
Antora
api/canopy-security.adoc(#598 param),api/canopy-eligibility.adoc(#694 param) as applicable. -
GitLab #598/#694/#695/#696 updated; #693 marked Blocked (&56); epic &57 task list synced.