Plan: Demo-Review Hardening (Epic &57)

On this page
NOTE

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:

  1. #598’s backend is already done. canopy-security’s GET /v1/security/events already accepts ?household_id= and filters on a real household_id column (store/mod.rs:189, migration 20260601000010), and every household-linked emitter already bakes household_id into 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 legacy render_activity_tab (case_detail.rs:2219), which still discards the household_id it receives. The composition audit section (MR5b, sections/audit.rs) already filters correctly. So #598 is a small canopy-web fix, not the backend feature the issue describes.

  2. #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: render_activity_tab filters by household_id (reuse audit.rs::list_events_url_for_household, promoted to a shared helper); drop the stale "not yet filterable" comment + the let _ = household_id no-op.

Done (2026-06-04) — list_events_url_for_household moved to audit/mod.rs (pub(crate), + its 2 tests); render_activity_tab uses it; stale comment + no-op removed.

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: audit_ingest_test::ingest_persists_and_chains_event (ingest for a fresh household → ?household_id= returns exactly that 1 event) + security_test::list_events_household_filter_returns_only_matching_rows (unknown household → 0). No new test needed.

MR2 — #694 per-program case-search status

3

canopy-eligibility: add GET /v1/eligibility/case-status support for a program filter (new get_latest_status_by_household_and_program store fn) so the status badge can match the rendered program label.

Done (2026-06-04) — program: Option<String> on CaseStatusQuery + the new store fn; per-program discrimination test (case_status_per_program_test).

4

canopy-web: cases.rs resolves the status for the rendered program (not the latest determination across all programs), fixing the latent "TANF / Active while TANF denied" mislabel.

Done (2026-06-04) — fetch_household_program returns (label, slug); the slug scopes fetch_case_status (&program=).

MR2 — #695 + #696 worker-portal small fixes (folded with #694 into one MR)

5

canopy-web (#695): applications.rs:1359 open-verification fetch fails safe (.unwrap_or(true)) + a // SILENT-OK: note, so a transient verification error keeps the Run-Determination button disabled (matching the server-side gate).

Done (2026-06-04) — .unwrap_or(true) + // SILENT-OK: note.

6

canopy-web (#696): queue/search/recent-determination program badges swap the non-existent u-status-{Program} class for the static .u-badge-program primitive (text stays the humanized item.program; no struct change needed — matches the live cases/_results.html precedent).

Done (2026-06-04) — 3 templates swapped to class="badge u-badge-program".

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.

NOTE
MR structure revised (living spec, ADR-013) — #694 + #695 + #696 shipped as one MR ("worker-portal demo-review fixes") rather than the original two, since they are all small same-epic canopy-web/eligibility fixes. #598 stayed its own MR (the prod-blocker). #693 deferred.

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)

  1. #598 — backend already shipped. services/canopy-security/src/api/mod.rs accepts household_id; store/mod.rs:175-202 filters AND ($4::UUID IS NULL OR household_id = $4); migration 20260601000010_add_household_id_to_audit_events.sql added 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) write household_id into 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 no household_id (NULL) and are excluded from a household-scoped view — correct. Remaining work = the canopy-web caller only.

  2. #693 — blocked. pages/apply.rs:732 hardcodes programs_requested: ["snap"] and documents.rs:32 const 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.

Step 5-6 (MR3 — #695 + #696)

Files: services/canopy-web/src/api/applications.rs; services/canopy-web/src/dashboard/panels/my_queue.rs + recent_determinations.rs + the 3 templates.

Step 7 (docs + issues)

  • CHANGELOG.adoc per MR; Antora api/canopy-security.adoc (#598 — confirm the household_id param is documented) + api/canopy-eligibility.adoc (#694 program param).

  • Update #598/#694/#695/#696 to current state; mark #693 Blocked (blocked-by &56) with a note; update epic &57.

Verification

  1. cargo nextest run -p canopy-security -p canopy-web -p canopy-eligibility — filter + caller + per-program status tests pass.

  2. cargo xtask dev refresh + a manual case-detail Activity-tab check: events scope to the viewed household.

  3. cargo xtask validate — clean.

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

Edit this page · default