Plan: Portal Design-Fidelity Follow-ups
On this page
The &53 design-fidelity lane is complete; it spun out follow-up issues now orphaned from the closed plan. This plan groups the five workable ones, each of which a readiness scan (4 recon agents) + a Plan red-team + a repo-grounded review found to hide a real backend/data decision. The architecturally significant calls are in Design decisions ("architecturally correct, no shortcuts"). Deferrals are filed as linked issues (#728 / #729 / #730), not prose.
Status
| MR | Description | Status |
|---|---|---|
MR0 |
Author this plan ( |
Done (2026-06-08) — !550; issues #728/#729/#730 filed. |
MR1 |
#702 backend — canopy-renewals caseload-DEPTH trend endpoint ( |
Done (2026-06-08) — !551; 6 tests, live-verified. |
MR2 |
#702 frontend — |
Done (2026-06-08) — 11 unit + e2e. |
MR3 |
#722 — applicant Home renewal hero. Deviation: no new |
Done (2026-06-08) — 18 unit + 2 e2e; renewal-due persona (Priya); light/dark verified. |
MR4 |
#721 — Letters read/unread (notices |
Done (2026-06-08) — backend idempotent-set + 404, portal flow (owned/foreign/no-session), |
MR5 |
#727 — draft-resume ( |
Done (2026-06-08) — get-draft + |
MR6 |
#719 — Home "Your year" recap (canopy-enrollment annual-summary endpoint + |
Done (2026-06-08) — annual-summary endpoint (shared #408 gate/audit helpers) + |
Deferrals (filed + linked; out of this plan’s scope) |
||
#728 |
Cross-program + TSNAP caseload-depth aggregation (canopy-reporting HTTP-aggregation, ADR-001). The MR1 slice is SNAP-only standard-certification depth. |
Deferred (#728) |
#729 |
Per-worker MiniBar caseload panel + |
Deferred (#729) |
#730 |
Lapsed / already-expired renewal hero — needs a household-scoped expired-cert read that does not exist (the active-cert read returns |
Deferred (#730) |
Context
The user directed: finish the workable &53 follow-ups MR-by-MR, with the rigor
bar that every UI-touching MR ships concrete e2e scenarios + light/dark
screenshots verified against the committed design/ renders.
In scope: #702 (supervisor caseload trend), #722 (Home renewal hero), #721 (Letters read/unread), #727 (draft-resume), #719 (Home "Your year" recap). Deferred, NOT here: #720 (authored legal NOA summary), #726 (income-3-tier → epic &56), #725 (secure messaging — new service + ADR), #660 (es i18n).
Design decisions ("architecturally correct, no shortcuts")
D1 — #702 plots caseload DEPTH, not inflow. The design’s "Caseload trend"
(design/canopy-web/dashboard/panels.jsx:540) is active cases per program over
time (~95k SNAP) — a different metric from #718’s already-shipped application
inflow. We build the real depth metric.
D2 — Depth reconstructed from certification intervals; owner = canopy-renewals.
"Active SNAP cases as of bucket-end day W" = snap_certifications whose in-force
interval covers W. canopy-renewals owns the SNAP cert intervals — mirroring #718
inflow living in canopy-applications (owner of received_at). Cross-program depth
is a future canopy-reporting HTTP-aggregation (ADR-001: reporting calls program
APIs, never their DBs — services/canopy-reporting/src/clients/mod.rs), additive
as programs mature (#728). SNAP-UAT slice = SNAP depth from renewals.
D3 — Bucket semantics + honest-by-construction.
-
Explicit semantics: each bucket is evaluated as of its last day
W_end`. A cert counts iff `certification_start_date ⇐ W_end AND certification_end_date >= W_end AND (terminated_at IS NULL OR terminated_at > W_end) AND active = true.activehere is the soft-delete flag (excludes retracted/superseded rows) — NOT the lifecyclestatus(a present-state filter that must not be used for historical buckets).COUNT(DISTINCT household_id)dedups overlapping recertify rows. -
Termination losslessness:
snap_certificationshas noterminated_attoday;update_certification_status(services/canopy-renewals/src/store.rs) is dead code (zero call sites) → no cert is ever terminated early → scheduled depth == true depth today. MR1 addsterminated_atand makesupdate_certification_statusthe single setter that stamps it. The metric is documented as "active certification depth — scheduled intervals, refined byterminated_atfor early terminations", and a code-review invariant requires every future termination path to route through that setter (else the metric silently regresses). -
Honest scope: standard SNAP certification depth — excludes Transitional SNAP (
snap_tsnap_certifications, owned by canopy-snap); TSNAP + cross-program roll-up are #728.
D4 — #719 recap = benefits ISSUED, matching the system’s existing definition. Sum
snap_benefit_issuances.allotment_amount (Decimal) WHERE
issuance_status='issued', filtered by benefit_month within the year (the column
the existing household-issuance API filters on, canopy-enrollment/src/api/mod.rs).
Do NOT exclude expunged_at — the existing "issued" listing does not, and the
recap must match that definition (label "issued to your EBT card," with an
expungement note in the contract docstring). Owner = canopy-enrollment; the handler
mirrors the existing actor-aware read audit (ADR-019).
D5 — Auth posture (superseded in part by OIDC P2/P3, #1441/#1442). New
portal-reachable reads take the _or_portal guards with an operation scope,
and resource-keyed ones enforce the signed X-Canopy-Applicant ownership
claim at the ORIGIN; the portal/web BFF still derives the household/draft id
from a *server-verified credential or session (never client input) as
defense-in-depth. Do not relax any guard, and do not expose an applicant-data read as a raw
service-token CLI keyed by arbitrary id (see D7).
D6 — Acceptance bar (measurable). Every UI MR ships the concrete e2e scenarios
listed in its row (positive + negative + state-preservation assertions) in the named
Playwright project, plus light+dark screenshots vs the design/ render, plus honest
empty/error states ported from the design variants
(design/canopy-web/dashboard/states.jsx:553).
D7 — Deferrals are filed + linked issues (ADR-013), each with a visible signal.
-
#728 — cross-program + TSNAP depth aggregation (canopy-reporting).
-
#729 — per-worker MiniBar panel + its
o::minibarprimitive (blocked on worker-directory #597 / #607 — defer together, no dead primitive). -
#730 — #719 lapsed/already-expired renewal hero (needs a household-scoped expired-cert read that doesn’t exist).
-
draft-get has NO CLI — ADR-007 exception (not a deferral): a service-token CLI dumping any encrypted draft by id is an applicant-privacy/abuse vector; the get is only meaningful inside the passcode-holding BFF resume flow. Recorded here + in the ADR-007 page.
D2/D3 establish an operational-metric pattern (reconstruct from intervals; per-program owner exposes, reporting aggregates). Consistent with #718 (no ADR), it lives in this Design section; flag for the architect whether to elevate to a short ADR once the cross-program aggregation (#728) lands.
Cross-cutting requirements (apply to every MR)
-
Docs (Documentation homes → Antora): per new endpoint, update the Antora API page
docs/modules/ROOT/pages/api/canopy-<svc>.adoc; per new column, update the data-model pagedocs/modules/ROOT/pages/data-models/canopy-{renewals,notices}.adoc(terminated_at,read_at); CHANGELOG; the Status row above. -
OpenAPI drift gate (pinned counts): rebuild container →
cargo xtask api-docs --update→ commitopenapi/<svc>.json→ update the route-count assertion in the same MR: renewals 10→11 (MR1), notices 6→7 (MR4), applications 27→28 (MR5), enrollment 6→7 (MR6). -
#[into_params(parameter_in = Query)]on every new query-params struct (#593). -
CLI parity (ADR-007):
canopy renewal caseload-trend(newcmd/renewal.rs
config::Profile.renewals_urldefault :8007);canopy notice mark-read <id>(newcmd/notice.rs, reusesnotices_url);canopy enrollment annual-summary <hh> --year(newcmd/enrollment.rs+config::Profile.enrollment_urldefault :8006, actor-aware); draft-get → none (D7). New top-level commands register intools/canopy-cli/src/cmd/mod.rs+ theCommandenum/dispatch intools/canopy-cli/src/main.rs+ aconfig::ProfileURL field. -
Labels: one
type::feature, onepriority::,service::{renewals,web,notices,applications,enrollment,portal}, aprogram::(snap/cross-program),workflow::in-review;compliance::wcag-21-aaon every UI MR (2b + 3/4/5/6). -
Live verify: integration tests + curl use
acquire_service_token("canopy-<svc>")(crates/canopy-test-lib/src/auth.rs); SQL viadocker exec canopy-postgres-1 psql -U canopy -d canopy_<svc>. -
e2e project per MR: 2b →
supervisor; 3/4/6 →vb-applicant
vb-applicant-dark+ theapplicant-portalwalk; 5 →applicant-portal
vb-applicant/vb-applicant-dark.applicant-portalruns in the default pre-push gate (#716 MR4b); thevb-*captures are on-demand viacargo xtask e2e --visual(#716 MR4d); theme vialocalStorage canopy-portal-theme. -
Merge: branch-first; 2-stage precommit (Q1–Q8 inline); reseed default before the push gate; force-merge squash=false (verify 2-parent);
plan-lint
check-docs.
MR sequence
MR1 — #702 backend: caseload-depth trend (canopy-renewals)
-
Migration
<ts>_add_certification_terminated_at.sql:ALTER TABLE snap_certifications ADD COLUMN terminated_at TIMESTAMPTZ NULL(safe additive, ADR-016); makeupdate_certification_statusstamp it on→ terminated(the single setter, D3). -
New
GET /v1/renewals/caseload-trend?program=snap&window=12w&bucket=week. SQL mirrors #718 (canopy-applications/src/store/mod.rs); no program EXISTS clause —snap_certificationshas no program column, the table IS SNAP; validateprogram=snapelse 422:
SELECT s.bucket_end::date AS bucket_start,
COUNT(DISTINCT c.household_id) AS count
FROM generate_series(date_trunc('week',$1::timestamptz),
date_trunc('week',now()), interval '1 week') AS g(bucket_start),
LATERAL (SELECT (g.bucket_start + interval '6 days') AS bucket_end) s
LEFT JOIN snap_certifications c
ON c.active = true -- soft-delete flag, not lifecycle
AND c.certification_start_date <= s.bucket_end::date
AND c.certification_end_date >= s.bucket_end::date
AND (c.terminated_at IS NULL OR c.terminated_at > s.bucket_end)
GROUP BY s.bucket_end ORDER BY s.bucket_end;
(day bucket = date_trunc('day',…), bucket_end = bucket_start.) TrendBucket enum
picks the unit — never interpolated. Window clamps to ≤366d/≤104w (matching #718 —
clamp, not 422); 422 only on unparseable.
-
Contract
crates/canopy-contracts-renewals/src/caseload_trend.rs(reuse the 718CaseloadTrend/…Bucket/…Paramsshapes +[into_params(parameter_in = Query)]) +CASELOAD_TRENDpath const + honest-scope docstring (D3). -
Tests
services/canopy-renewals/tests/caseload_trend_test.rs: zero-fill continuity; distinct-household dedup across overlapping recertify rows; terminated-mid-window drops in the right bucket; soft-deleted (active=false) excluded; daily bucket; non-snap program → 422. + store unit tests. Verify live via psql againstcanopy_renewals.
MR2a — #702 primitive: o::sparkline
-
Helper
services/canopy-web/src/dashboard/util.rs:pub fn sparkline_path(values: &[i64], width, height) → SparklinePoints(path string + endpoint(cx,cy); geometrydesign/…/primitives.jsx:339, r=2.5); unit tests incl. empty input, single-point, andmax==minflat-line guard. -
Macro in
templates/_primitives/orchard.html:{% macro sparkline(path, cx, cy, width=120, height=32, kind="primary") %}— inline<svg>, geometry via attrs, stroke/fill via CSS classes.sparklineline/.sparklinedot(--orchard-primary, no inlinestyle=). Add the classes to canopy-web.css.
MR2b — #702 panel + seed + screenshots
-
New panel
dashboard/panels/supervisor_caseload_trend.{rs,/Plugin.toml}
template, mirroringsupervisor_kpis.rs(purebuild_template; honest empty "Not enough data yet" + error+Retry perstates.jsx:553). Fetch the MR1 feed (canopy-web already hasCANOPY_WEB__RENEWALS_URL=:8007— no compose change); rendero::sparkline+ latest-vs-prioro::delta. Touchpoints:panels/mod.rs(pub mod+ dispatch +SUPERVISOR_DASHBOARD_PLUGIN_SLUGS+assert_registered), bothrulesets/{georgia,default}/composition/supervisor_dashboard.toml, the order list
count indashboard-supervisor.spec.ts. -
Seed fix: spread the generated renewals cert start/end/terminated dates across the window in the default seeder (
tools/canopy-seed/src/datagen.rs) so depth moves (else min==max → flat line). (The demo dataset this once patched was retired in #716; the fix now belongs in the generative seeder.) -
e2e: panel renders in order; sparkline
<svg>present with the expected point count; empty-state when no certs. Screenshot light+dark.
MR3 — #722 applicant Home renewal hero
Design deviation (ADR-013): NO new renewals_url dep was needed. The plan
assumed the renewal-window data required a separate renewals read. In fact the
/home/state proxy already fetches the household’s program_determinations
(for the benefit cards + timeline), and each carries a renewal_date. So the
renewal hero is a refinement of the approved state derived from data already in
hand — simpler, one fewer cross-service dependency, and no compose/constructor
wiring.
-
home.rsderive_stategains arenewal_soon: boolarg: anapproved/determinedcase becomesrenewal(instead ofapproved) when any approved determination’srenewal_dateis within[today, today + RENEWAL_SOON_DAYS](named const 45 — a presentation threshold, NOT a jurisdiction.toml policy value). It is a refinement of the approved state only; it never overrides the pre-approval (interview/pending/active) or terminal (closed) states (they matchstatusfirst). Already-lapsed renewals are out of scope → #730. -
The portal has no chrono dep (dates are strings), so a tiny
iso_to_epoch_day(Howard Hinnantdays_from_civil) +today_epoch_day(SystemTime) do the window check — unit-tested against known anchors. The existinghero_renewal()(previously unreachable) lights up. -
Demo persona: a committed renewal-due persona (Priya Anand,
HH-7e2ec0de/3025-7788-1196) — approved months ago, recert due 2026-07-06 — so the renewal hero is reproducible (Carlos stays the freshly-approved control). -
Tests: 18 unit assertions (derive_state precedence, renewal_due_soon window / lapsed / non-approved, iso_to_epoch_day anchors / malformed) + 2 live e2e (Priya = renewal hero, AA-clean; Carlos = approved control) + the visual-baseline
home-renewalcapture. Light+dark screenshot-verified faithful.
MR4 — #721 Letters read/unread
-
Migration
<ts>_add_notice_read_tracking.sql:notices ADD COLUMN read_at TIMESTAMPTZ NULL(safe additive, ADR-016) +read_at: Option<DateTime>on theNoticecontract. Mark-read at the notice-row level (onerecipient_person_idper row). -
POST /v1/notices/{id}/mark-read(service-caller) → storeUPDATE … SET read_at = COALESCE(read_at, now())(idempotent, preserves first-read time; no new audit event — applicant self-read). -
Portal BFF route: add a server-only
POST /notices/{id}/mark-readto canopy-portal (#[cfg(feature="server")]) that derives household from session + verifies the notice belongs to that household (mirror the existing/notices/{id}/pdfownership check) before calling the service endpoint with the service token. The client calls the BFF route, never the service directly. -
pages/letters.rs: fire mark-read on an explicit user open/click of a letter — NOT on the page-load auto-select of the first notice; unread styling on list rows. -
e2e: opening a letter clears its unread badge; reload preserves read; the first letter is NOT auto-marked on load. Screenshot light+dark.
MR5 — #727 draft-resume (client-driven credential flow)
-
New
GET /v1/applicants/drafts/{id}→GetDraftResponse { application_id, current_step: i32, ciphertext: String (b64), nonce: String (b64), enc_version: i16, kdf_salt: String (b64), expires_at, last_saved_at }— no decryption; service-caller; 404 ifexpires_at < now()(don’t serve stale-unreaped drafts); BFF supplies the id. Contract in…/drafts.rs+GET_DRAFTpath const. -
Resume flow (passcode stays client-side): a client-side Dioxus affordance "Continue your draft" on
pages/apply.rsholdscode+passcodein WASM signals andclient_api-POSTs to a new BFFPOST /apply/resumeproxy (#[cfg(feature="server")]). The BFF:verify-credential(code,passcode)→ reservedapplication_id(id derived from the verified credential, never client input — the IDOR boundary) →get-draft(id)→ returns the encrypted blob. The client (still holding the passcode) re-derives the key from passcode + returnedkdf_salt(the existing client draft KDF) → decrypts → hydrates the apply form atcurrent_step. (The native server-POST/lookup/submitflow is NOT reused — it discards the passcode on redirect.) 404/expired → "no in-progress draft" message. -
No draft-get CLI (D7).
-
e2e: code+passcode for a persona with a draft → form decrypts and lands on the saved step with prior values; bad credential → error; expired draft → "no draft". Screenshot light+dark.
The original spec planned to seed a demo draft (a pre-computed ciphertext under
a known credential) so the resume box had something to open. On build it was
caught that this is the demo-shortcut, not the feature: today the portal reveals
the Application ID + passcode only at submit (ApplySubmitted), so a real
first-time applicant who starts, saves a step, and leaves has no credential to
type into the resume box — the box would be unreachable for anyone but a seeded
persona. The architecturally-correct fix (chosen by the user over seed+follow-up)
is to surface the credential at start: after "Begin application", show a
"Save your Application ID — you’ll need it to come back" screen (the existing
CredentialReveal, reused) before step 1, and soften the submit-time reveal copy
(no longer "the only time"). The resume box on the apply intro then works for any
applicant, and the e2e is a genuine round-trip — start → capture the shown
credential → fill + save a step → return to the intro → "Continue your draft" →
the form decrypts and lands on the saved step — with no seeded ciphertext.
MR6 — #719 Home "Your year" recap
-
New
GET /v1/households/{household_id}/annual-summary?year=in canopy-enrollment →{ household_id, year, total_issued: Decimal, months: [{benefit_month, amount}] }(D4 sum:issuance_status='issued', bybenefit_month, expunged included; service-caller; actor-aware audit). Contract + path const incrates/canopy-contracts-enrollment/. -
New
enrollment_urlportal dep (same constructor + main.rs + compose + unit-assert checklist as MR3;CANOPY_PORTAL__ENROLLMENT_URLdefaulthttp://localhost:8006). -
home.rsrecap teaser — copy says "issued" (D4); renders nothing when the sum is zero (honest empty). -
e2e: a persona with issuances shows "issued this year $X"; a zero-issuance persona shows no recap. Screenshot light+dark.
Verification
-
Per backend MR (1,4,5,6):
cargo nextest -p canopy-<svc>;cargo xtask dev refresh; live psql sanity;cargo xtask api-docs --update+ the pinned route-count assert;curlwith a service token. -
Per UI MR (2b,3,4,5,6):
cargo xtask dev refresh; screenshot light+dark vs thedesign/render; the named Playwright project green (seed demo first). -
Each MR: full pre-push
cargo xtask validate+ e2e gate (reseed default first); force-merge squash=false (verify 2-parent);plan-lint+check-docs.