Plan: Portal Design-Fidelity Follow-ups

On this page
NOTE

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 (.adoc + nav) + file the D7 deferral issues.

Done (2026-06-08) — !550; issues #728/#729/#730 filed.

MR1

#702 backend — canopy-renewals caseload-DEPTH trend endpoint (terminated_at migration + single-setter; depth SQL with COUNT(DISTINCT household_id); contract; canopy renewal caseload-trend CLI; tests; OpenAPI 10→11).

Done (2026-06-08) — !551; 6 tests, live-verified.

MR2

#702 frontend — o::sparkline primitive + sparkline_path helper + supervisor_caseload_trend panel + composition baselines + panel-order e2e + light/dark screenshots. (MR2a + MR2b combined — shipping the primitive with its only consumer avoids a dead-code window; o::minibar + the per-worker panel deferred together to #729.)

Done (2026-06-08) — 11 unit + e2e.

MR3

#722 — applicant Home renewal hero. Deviation: no new renewals_url dep — the renewal hero is derived from the determination’s renewal_date already fetched by /home/state.

Done (2026-06-08) — 18 unit + 2 e2e; renewal-due persona (Priya); light/dark verified.

MR4

#721 — Letters read/unread (notices read_at migration + mark-read endpoint + portal BFF route with ownership check + explicit-open) + e2e.

Done (2026-06-08) — backend idempotent-set + 404, portal flow (owned/foreign/no-session), row_class/projection units, applicant e2e + letters visual baseline; light/dark verified.

MR5

#727 — draft-resume (GET /drafts/{id} + client-driven BFF /apply/resume credential flow) + e2e.

Done (2026-06-08) — get-draft + /apply/resume + credential-at-start (real feature, not seeded — see deviation note); backend + portal flow + SSR units + round-trip e2e; light/dark verified.

MR6

#719 — Home "Your year" recap (canopy-enrollment annual-summary endpoint + enrollment_url dep + teaser) + e2e.

Done (2026-06-08) — annual-summary endpoint (shared #408 gate/audit helpers) + enrollment_url dep + Home recap teaser; backend total==sum + project units + recap e2e (Carlos $2675 / Maria none) + home-recap visual; light/dark verified.

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 + o::minibar primitive — blocked on the worker directory + assignment data (#597 / #607). Defer panel + primitive together.

Deferred (#729)

#730

Lapsed / already-expired renewal hero — needs a household-scoped expired-cert read that does not exist (the active-cert read returns status='active' only).

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. active here is the soft-delete flag (excludes retracted/superseded rows) — NOT the lifecycle status (a present-state filter that must not be used for historical buckets). COUNT(DISTINCT household_id) dedups overlapping recertify rows.

  • Termination losslessness: snap_certifications has no terminated_at today; 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 adds terminated_at and makes update_certification_status the single setter that stamps it. The metric is documented as "active certification depth — scheduled intervals, refined by terminated_at for 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::minibar primitive (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.

NOTE

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 page docs/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 → commit openapi/<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 (new cmd/renewal.rs
    config::Profile.renewals_url default :8007); canopy notice mark-read <id> (new cmd/notice.rs, reuses notices_url); canopy enrollment annual-summary <hh> --year (new cmd/enrollment.rs + config::Profile.enrollment_url default :8006, actor-aware); draft-get → none (D7). New top-level commands register in tools/canopy-cli/src/cmd/mod.rs + the Command enum/dispatch in tools/canopy-cli/src/main.rs + a config::Profile URL field.

  • Labels: one type::feature, one priority::, service::{renewals,web,notices,applications,enrollment,portal}, a program:: (snap/cross-program), workflow::in-review; compliance::wcag-21-aa on 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 via docker exec canopy-postgres-1 psql -U canopy -d canopy_<svc>.

  • e2e project per MR: 2b → supervisor; 3/4/6 → vb-applicant
    vb-applicant-dark + the applicant-portal walk; 5 → applicant-portal
    vb-applicant/vb-applicant-dark. applicant-portal runs in the default pre-push gate (#716 MR4b); the vb-* captures are on-demand via cargo xtask e2e --visual (#716 MR4d); theme via localStorage 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); make update_certification_status stamp 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 clausesnap_certifications has no program column, the table IS SNAP; validate program=snap else 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 718 CaseloadTrend/…Bucket/…Params shapes + [into_params(parameter_in = Query)]) + CASELOAD_TREND path 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 against canopy_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); geometry design/…​/primitives.jsx:339, r=2.5); unit tests incl. empty input, single-point, and max==min flat-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 inline style=). Add the classes to canopy-web.css.

MR2b — #702 panel + seed + screenshots

  • New panel dashboard/panels/supervisor_caseload_trend.{rs,/Plugin.toml}
    template, mirroring supervisor_kpis.rs (pure build_template; honest empty "Not enough data yet" + error+Retry per states.jsx:553). Fetch the MR1 feed (canopy-web already has CANOPY_WEB__RENEWALS_URL=:8007no compose change); render o::sparkline + latest-vs-prior o::delta. Touchpoints: panels/mod.rs (pub mod + dispatch + SUPERVISOR_DASHBOARD_PLUGIN_SLUGS + assert_registered), both rulesets/{georgia,default}/composition/supervisor_dashboard.toml, the order list
    count in dashboard-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.rs derive_state gains a renewal_soon: bool arg: an approved/determined case becomes renewal (instead of approved) when any approved determination’s renewal_date is 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 match status first). 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 Hinnant days_from_civil) + today_epoch_day (SystemTime) do the window check — unit-tested against known anchors. The existing hero_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-renewal capture. 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 the Notice contract. Mark-read at the notice-row level (one recipient_person_id per row).

  • POST /v1/notices/{id}/mark-read (service-caller) → store UPDATE … 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-read to canopy-portal (#[cfg(feature="server")]) that derives household from session + verifies the notice belongs to that household (mirror the existing /notices/{id}/pdf ownership 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 if expires_at < now() (don’t serve stale-unreaped drafts); BFF supplies the id. Contract in …/drafts.rs + GET_DRAFT path const.

  • Resume flow (passcode stays client-side): a client-side Dioxus affordance "Continue your draft" on pages/apply.rs holds code+passcode in WASM signals and client_api-POSTs to a new BFF POST /apply/resume proxy (#[cfg(feature="server")]). The BFF: verify-credential(code,passcode) → reserved application_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 + returned kdf_salt (the existing client draft KDF) → decrypts → hydrates the apply form at current_step. (The native server-POST /lookup/submit flow 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.

NOTE
Design deviation (2026-06-08, ADR-013) — credential-at-start is part of the real feature

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', by benefit_month, expunged included; service-caller; actor-aware audit). Contract + path const in crates/canopy-contracts-enrollment/.

  • New enrollment_url portal dep (same constructor + main.rs + compose + unit-assert checklist as MR3; CANOPY_PORTAL__ENROLLMENT_URL default http://localhost:8006).

  • home.rs recap 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; curl with a service token.

  • Per UI MR (2b,3,4,5,6): cargo xtask dev refresh; screenshot light+dark vs the design/ 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.

Edit this page · default