Applicant Portal Design Reference
On this page
- Overview
- 1. The thesis
- 3. The auth model in detail
- 4. Information architecture
- 5. Voice and copy rules
- 6. Schemas implied by the design
- 6.1
Case - 6.2
ProgramEnrollment - 6.3
Application - 6.4
Document - 6.5
Letter(formerlyNotice) - 6.6
RenewalAnswer - 6.7
ChangeRequest - 6.8
MessageandMessageThread - 6.9
DocumentRequest - 6.10
EligibilityHint(Discover) - 6.11
RecapDataand the recap pipeline - 6.12
RecoveryAttempt - 6.13
EligibilityRulesandEligibilityEstimate
- 6.1
- 7. API surface (sketch)
- 8. Persistence boundaries
- 9. Customer ↔ Worker wiring
- 10. Accessibility commitments
- 12. Hard rules for the implementation
Overview
This page is the durable reference translation of the constituent-facing
portal design contract (design/canopy-portal/). The applicant
portal (services/canopy-portal) is a Dioxus 0.7 fullstack surface
(SSR + WASM hydration) for benefit recipients — distinct from the
caseworker-facing worker portal documented in
Worker Portal Mockups.
The handoff is the source of design intent; the Rust types, copy strings,
and locked auth decisions below are derived from it. Many Rust code
comments and other docs cite specific subsection anchors ("applicant-portal design ref §3.7",
"§4.5", "§6.5"), so this page preserves the exact X.Y numbering in
its section titles.
This page documents design intent, not what ships today. The applicant portal’s current implementation status (live routes, ADR-026 session model) lives in the service catalog and in ADR-026. Where this reference describes a screen or schema that is not yet built, treat it as the design contract the implementation works toward.
Relationship to the ADRs
| ADR | What it governs relative to this page |
|---|---|
Applicant portal architecture (Dioxus fullstack, containerized surface). |
|
The privacy-first stance: opaque Redis-primary sessions, the recovery flow, confidential-case handling, and the intimate-threat model that §3.7–§3.12 below encode. |
The credential format here supersedes ADR-008’s original sketch. ADR-008
contemplated a reference number plus a date-of-birth second factor. This
design replaces that with an Application ID (HH- + 8 hex) paired with
a word-word-word-NN passcode and uses no DOB as a steady-state login
factor. DOB survives only as one entry gate into the recovery flow
(§3.7), never as the primary login second factor. The locked auth model
below (§3.1–§3.12) is authoritative over the ADR-008 prose where they
conflict; ADR-026 ratifies this newer model.
1. The thesis
No accounts. No passwords. No usernames. No "forgot password" purgatory.
Recipients have hard enough problems. The portal is identified by a single artifact issued at application time:
-
Application ID —
HH-+ 8 hex chars (e.g.HH-c8841a23) -
Passcode — 3 dictionary words + 2 digits (e.g.
maple-river-orchard-44)
The pair is generated server-side on application submit, displayed once on the Submitted screen with a giant "save this" affordance (copy / download / screenshot prompts), and never echoed in plaintext again. If they lose them, a 4-step self-serve recovery flow can mint a fresh view of the passcode (see §3.7) — and beyond that, a phone-call fallback exists.
This forces a few good consequences:
-
No password reuse risk. The credential pair is meaningful only to canopy.
-
No phishing surface. No "login" page that fraudsters can mimic.
-
No abandoned accounts. The portal IS the case; closing a case closes the way in.
-
No session theft via stolen password. Sessions are scoped to the device that authenticated and short-lived.
The tradeoff is that lost-passcode recovery is heavier than email/SMS resets. That’s intentional. The whole point is to avoid the auxiliary credential plumbing that fraudsters target — recovery email accounts, SIM-swap-able phone numbers, etc.
3. The auth model in detail
This is the load-bearing, locked-decisions section. The subsection numbering below is cited from code; do not renumber.
§3.5 was moved to §3.12 ("Shared / kiosk devices") in the source handoff. The §3.5 anchor is intentionally a forwarding stub so that older citations of "§3.5" resolve to the shared/kiosk content now under §3.12.
3.1 Application ID format
HH-[a-f0-9]{8}
-
Prefix
HH-for Household. Always uppercase. -
8 hex chars. Stable for the life of the case.
-
Survives case state transitions. A closed case retains its ID forever for audit.
-
One ID per household. Multiple programs (SNAP + Medicaid + WIC) share the same ID.
3.2 Passcode format
word-word-word-NN
-
3 words drawn from a curated wordlist of ~2000 common English nouns and adjectives. 4-7 chars each.
-
2-digit suffix. Disambiguates collisions and adds bits without making the passcode hostile to remember.
-
~2000³ × 100 ≈ 8 × 10¹¹ ≈ ~40 bits of entropy. Combined with the cascade in §3.6 and the 8-hex-char ID, comfortably above the threshold for the threat model.
-
Spanish wordlist must exist for
language: es-USjurisdictions. Same shape, different words. -
Lowercase, hyphen-separated. Never displayed mixed-case.
3.3 Session
-
Successful lookup mints a device-bound session token, stored in indexedDB (encrypted at rest).
-
Default inactivity timeout: 30 min in the apply / recovery / renewal flows, 2 hr in the steady-state portal, 15 min in shared kiosk mode (a launch flag — see §3.12).
-
Re-auth requires both ID and passcode again. No "remember me" toggle; the device-bound token is the only persistence.
-
Token rotation: on every authenticated request, mint a fresh token; previous token grace-window of 30s for in-flight requests.
3.4 Lost credentials
There are now two paths, in order of preference:
-
Self-serve recovery (the prototype’s
recover.jsx) — a 4-step identity challenge that ends in a 24-hour pending reveal with kill-switch notification. See §3.7 for the threat model. The flow itself always accepts input; the server short-circuits toConfidentialBlockedafter a successful App-ID lookup if the case is flagged confidential (§3.8). This is by design: Lookup cannot peek at confidentiality from no credentials without leaking case existence. -
Helpline / in-person fallback —
1-877-423-4746, Mon–Fri, 7am to 6pm. The only path that resolves for confidential cases. Reachable from inside the self-serve flow when challenges fail or the case is confidential, and prominently displayed on Lookup.
The self-serve path must:
-
Reject any input that doesn’t match. Never reveal whether the ID exists. Generic "We couldn’t find that case" error.
-
Run inside the rate-limit cascade (§3.6).
-
After challenges pass, enter a 24-hour pending state during which the passcode is NOT shown and existing sessions remain active.
-
Fire a side-channel notification (email + SMS if both exist) to the contact on file at time of original application, not the most recently changed contact (§3.10).
-
The notification includes a one-tap "this wasn’t me" kill-switch.
-
Never email or text the passcode itself. The passcode is shown only in the open session that initiated recovery, after the 24h window, behind a blur-reveal toggle, once.
3.5 (moved to §3.12)
See §3.12 "Shared / kiosk devices". This anchor is retained so older citations of "§3.5" resolve.
3.6 Rate-limiting — the cascade
Never primary-key on IP. Cellular carriers route huge fractions of their users through a small pool of shared public IPs (CGNAT). A clumsy "5 attempts / hour per IP" cuts off thousands of legitimate phone users when one attacker abuses recovery from the same carrier. The right shape:
-
Per-device cookie (primary) — first signal we check. Set a long-lived, HttpOnly, SameSite=Lax cookie on first visit (e.g.
cy-dev) containing a random 128-bit ID. Limit per-device: 5 attempts / hour, 10 / day, exponential cooldown beyond. Attackers rotate cookies, legit users don’t. -
Per-CaseID cap (always) — independent of device or IP, a specific case cannot accept more than 8 wrong attempts / day before that case enters cooldown. Defends against brute force targeting a known ID.
-
Per-IP fallback (last resort, generous) — only when both the cookie is missing AND no per-CaseID counter applies. Set the limit high enough that a busy carrier NAT (~thousands of users) doesn’t trip it: 300 attempts / hour / IP. Below the device cap so a determined attacker hits the per-device limit long before the IP limit.
-
Per-IP-subnet anomaly detection — separate, observability layer. Watch for "1,000 attempts in 5 minutes from a /24" patterns and flag for human review. Never auto-block based on this — the rate-limiter handles the auto-response; this just surfaces unusual patterns.
Same generic error for every rejection. "We couldn’t find that case." Don’t reveal which limit was hit, which counter is closest to triggering, or whether the ID exists. Even a 429 with Retry-After leaks structure — return 401 consistently and only use 429 once cooldown is in place for all paths from that source.
Recovery flow lockout is in addition to all this. Two wrong identity-verification answers inside the recovery flow → flow locks for that session and dumps to "call us" (see recover.jsx). This is per-flow, separate from rate-limit counters.
Cookie-deleting attackers. A determined attacker clearing cookies between attempts loops back to the per-IP fallback fast. That’s fine — the per-IP cap is high but exists. And the per-CaseID cap caps total brute-force progress against any specific case regardless of source.
CGNAT edge: known carrier subnets get a generous cap. AT&T, T-Mobile, and Verizon publish their CGNAT subnet ranges; busy NATs can have 10,000+ users on one IP. The default 300/hr/IP could trip on a heavily-used carrier IP under attack. Maintain a list of those subnets and raise the cap to 3,000/hr/IP on them, OR skip per-IP for those subnets and lean on per-CaseID as the only backstop. This is operational tuning, not policy — watch the dashboards.
One more layer for high-value paths. Recovery completion (passcode reveal) should require a CAPTCHA-equivalent challenge gate — but design it as a "we’re checking that you’re a person" friendly screen, not a wall of squiggly text. Cloudflare Turnstile or hCaptcha invisible mode is fine; do not use Google reCAPTCHA (deanonymization concern for a benefits population).
3.7 Recovery threat model
The challenges in recover.jsx are not a security boundary on their own —
they’re the second wall after the rate-limiter. But the real threat is not
the random stranger; the real threat is the person who knows.
Intimate threats are the dominant threat model. Benefits as control is a well-documented domestic-violence pattern: an abusive partner cancels EBT to coerce, a non-custodial parent intercepts a kid’s Medicaid information, a controlling adult child manages an elderly parent’s case without consent. These people know the recipient’s DOB, address (often better than the recipient), the kids' names, the SSN’s last 4, the worker’s name, and have physical access to mail and devices.
The recovery flow must assume this person exists and is patient.
| Threat | Mitigation |
|---|---|
Stranger guesses someone’s passcode |
Per-CaseID cap (§3.6) — 8 wrong attempts / day |
Stranger knows ID + DOB (data breach) |
Recovery requires multiple matched answers + 24h delay |
Wide-net automated guessing |
Per-device cookie cap + per-IP fallback + CAPTCHA on reveal |
Intimate threat (partner, ex, family) |
Confidential-case flag disables self-serve entirely (§3.8); 24h delay-with-kill-switch on every reveal; side-channel notification to the application-time contact, not the current contact; recovery flow exposes a safety exit at every step |
Coercion during recovery (sitting next to the person) |
The 24h delay means the abuser cannot use the credentials in the moment. The "this isn’t safe for me right now" exit appears in the header of every recovery/change/apply screen. |
Caseworker (insider) |
Out of scope here — handled by worker portal audit trail. |
Identity-verification questions, redesigned:
-
Application ID + DOB — required to enter self-serve recovery at all. There is no "I don’t have my ID" alternative. The App ID is the only piece of identifying information an intimate threat is unlikely to have memorized (random hex, not personally derived). Without it, recovery is phone-only.
-
A recent letter ID ("Type the NTC- number from any letter we mailed in the last 6 months") — this requires mail interception, which is harder than knowing static info, especially after the relationship ends.
-
Rough year of last decision (with "I’m not sure" as a valid option that proceeds — kept from v1, low entropy but useful redundancy).
The previous v1 address-challenge step is removed for the same reason as the name+DOB+SSN entry path: address is on every piece of mail an intimate threat has handled and often on a co-signed lease.
Why no SSN/name/DOB-only entry path. It seems "more accessible" but it’s the opposite. Anyone with reasonable knowledge of the recipient already has SSN’s last 4, DOB, and the name on the case. That path lets the abuser in. The Application ID gate is the only way self-serve can ever be safer than calling.
The questions are deliberately not SSN-as-primary anywhere. SSN is the most-leaked piece of identifying information; tying a system’s security to SSN recoverability is bad practice generally and especially bad here.
On 2 wrong answers in this flow, the user lands on the RecoverLocked
screen and must call the helpline. The lockout is per-session (cookie); it
doesn’t compound with the rate-limit cascade so an honest user typoing
twice doesn’t get locked out of the whole system.
After all challenges pass:
-
Mint a pending recovery, not a successful one. Hold the passcode reveal for 24 hours.
-
Fire the side-channel notification immediately (§3.4) to the contact on file at the time of the original application, not to any recently-updated contact (a recently-changed contact is a possibly-controlled contact).
-
The notification includes a one-tap "this wasn’t me" link that cancels the pending recovery and locks the case (worker contact required to unlock).
-
Existing logged-in sessions are not invalidated. Instead, prior sessions receive a one-shot prompt: "an account recovery is pending — if it wasn’t you, tap KEEP." The KEEP wins.
-
After 24h with no cancellation, the passcode reveal screen becomes accessible (one-shot, blur-toggle) and existing sessions are then asked to re-authenticate.
This is more friction than v1’s "verify → reveal." That’s the design. The 24-hour window is the difference between "your abuser has your benefits right now" and "your abuser has 24 hours to surface to you, and then you catch them."
3.8 Confidential cases
A Case carries a confidentiality: Confidentiality flag:
enum Confidentiality {
Standard, // default — self-serve recovery available
Confidential, // self-serve recovery disabled; phone/in-person only
AddressConfidential, // address never echoed; ACP routing applies (see §3.9)
Both, // confidential AND address-confidential
}
Setting paths:
-
During apply, after the household step, a screen asks: "Is anyone in this application escaping violence, stalking, or someone who shouldn’t have access to your information?" If yes → a follow-up explains the implications and offers Confidential mode, AddressConfidential (if the state has an ACP), or both.
-
By the recipient any time from a future Safety settings screen (v1.1).
-
By worker request through the worker portal when the customer asks by phone or signals risk on the call.
When Confidentiality != Standard:
-
The Lookup screen still offers "Recover my access" — hiding it would leak case existence to anyone who knows an App ID. The flow accepts input and the server routes to
ConfidentialBlockedafter the App-ID lookup matches a confidential case. -
All identifying responses go through human verification (worker phone or in-person office).
-
The portal still works for the legitimate user with the credentials, and the worker portal flags any attempt to recover via self-serve.
-
Worker-side note: caseworkers cannot disable Confidential status without supervisor approval and a logged reason. Recipient can disable from settings (with confirmation).
3.9 Address Confidentiality Programs
Most states have an ACP for survivors of domestic violence, stalking, or sexual assault. The recipient uses a state-provided substitute address; the state forwards mail. Canopy must respect this:
-
ACP-flagged cases store the substitute address as the visible address
-
The real address is held in a separate
protected_addresstable accessible only by privileged worker roles + audit-logged on access -
The Address change-of-info flow for an ACP case routes to the state ACP office, not directly to canopy
-
The portal never displays the substitute address in challenge questions or any visible UI surface that could be screenshotted
-
The Recap removes any address-derived information (closest-store-city) for ACP cases
Georgia’s ACP is the Address Confidentiality Program operated by the Criminal Justice Coordinating Council; the actual substitute-address format and forwarding API are state-specific and out of scope for this design but required for the implementation.
3.10 Contact-info change protocol
Scope. This section governs ChangeRequest of kind Contact against an existing case — i.e. changes the recipient makes after their case is created. It does not apply to the initial contact entries on the Apply form (there’s no "old contact" to dual-confirm against).
Changing a phone or email must require dual confirmation, never silent:
-
The new contact receives a verification code; the user enters it.
-
Simultaneously the old contact (if any) receives a "your contact info is being changed to X — if not you, tap to block this for 24 hours."
-
The change is applied after a 24-hour delay with no block.
-
If old contact blocks: change reverts, case is flagged in worker portal for review.
-
If old contact is no longer reachable (e.g. lost phone) and the user can’t verify ownership of it, the change MUST route through the helpline.
A recently-changed contact (within 30 days) is never the primary recipient of a recovery-attempt notification — the old contact, if any, is also notified. This breaks the "attacker changes phone, then 'recovers' the account" attack.
3.11 Safety exits
The header of every flow that touches credentials, contact info, or
high-stakes case state (recover, change, apply, messages compose
view) includes a discrete "Get help · this isn’t safe for me right now"
link.
Tapping it routes to the Safety screen (designed for v1.1, scaffolded behind the link today), which:
-
Lists the National DV Hotline (1-800-799-7233) and a county-routed local hotline
-
Lists the county canopy helpline for the case
-
Offers an in-person office locator
-
Offers to mark the case Confidential immediately
-
Does not echo any case data on screen (no name, no address, no case ID — so screen-sharing scenarios stay safe)
-
Has a giant "Hide this screen now" button that immediately goes to a generic weather page (the common "out-the-back-door" pattern)
3.12 Shared / kiosk devices
Library, family computer, county-office kiosk. The reality.
-
Sign out lives in the side rail’s persona footer (desktop) / avatar-tap menu (mobile). It clears indexedDB + revokes the server token. (Designed but not yet rendered in the prototype — implementation gap.)
-
All sessions auto-expire on tab close + the inactivity timeout for the active flow.
-
The Welcome screen never shows persona detail.
-
The Save-This screen (post-submit) and the Reveal screen (post-recovery) never echo the passcode after the user navigates away — both are one-shot views.
-
Kiosk mode (
?kiosk=1or build flag) shortens timeouts, hides the device-cookie nudge, and skips storing language/theme prefs to localStorage.
4. Information architecture
Tabs in the portal (mobile bottom, desktop side rail):
| Tab | Screen | Renders |
|---|---|---|
Home |
|
Per- |
Letters |
|
Inbox, filterable; opens |
Files |
|
Upload zone + history; the action surface for verification |
Help |
|
Phone numbers, worker contact, FAQ |
Deep / contextual screens — reachable but not tabs:
| Screen | Reached from |
|---|---|
|
Home quick link · Help → "Send a message" |
|
Home → Food Benefits program card → "See my card" |
|
Home → DiscoverTeaser tile |
|
Home → RecapTeaser (seasonal — Dec–Feb) |
|
Home quick link "Report a change" |
|
Home hero (only when |
|
Lookup screen → "Recover my access" |
4.1 Why exactly 4 tabs
Tested mental model — Home / Letters / Files / Help maps directly to "what’s going on", "what they sent me", "what I send them", "I need a person." Adding a 5th tab (Messages, Profile, Apply for more) hurts more than it helps. Profile is one tap into Home or accessed from the persona footer (desktop) / avatar (mobile). Apply-for-more belongs in Discover, not the nav.
Messages was the strongest pull for a 5th tab. We kept it out because (a) recipients message the worker rarely (estimated <1 message/case/month based on phone-helpline data), (b) the badge on Letters already creates one prominent unread surface — two competing red dots is worse than one, (c) Home’s quick-link tile + Help → "Send a message" hits the most common discovery paths.
4.2 Why no Search
Inboxes don’t get long enough. Active cases generate ~6 letters/year. Search becomes relevant at year 3+ and is in the v1.1 list.
4.3 Why Help has a real screen, not a modal
For the audience. A modal closes when you tap outside; a screen is a place you can dwell in while you dial the number. Don’t mistake low-tech for careless design.
4.4 Recap visibility (seasonal)
The RecapTeaser on Home is gated behind a should_show_recap() flag the
implementation owns. Suggested logic:
-
Show in December 15 → February 15 of each year, against the prior year’s data
-
Show in anniversary month (the month of first approval) as "Your first year on canopy" — same template, different intro
-
Always reachable via direct URL (
?screen=recap) regardless of season — useful for sharing -
Opt-out lives in account settings (v1.1) — some recipients won’t want their year quantified, and that’s their call
4.5 Eligibility guidance — encouragement, not gating
The portal ships a copy of the active jurisdiction’s eligibility rules and runs a non-binding estimate during the apply flow. The server runs the binding determination after submit. The client-side estimate exists for one purpose: make recipients more likely to fill out their applications completely. It is NEVER allowed to block, hide, or otherwise gate submit.
The dark version of this idea is real and dangerous. Telling a recipient "you don’t qualify" — even softly — measurably drops submission rates, including for cases that would qualify if a worker had seen the whole picture (medical deductions, dependent care, categorical eligibility, expedited circumstances). Under-enrollment is already the dominant problem in US safety-net programs. The portal must push toward submission, not away.
The good version: estimate + actionable nudges + pivots.
Three tiers the estimate can take, and what each one says:
| Tier | Headline | What follows |
|---|---|---|
|
"Households like yours usually qualify." |
Quiet "Show why" expander — calm, confident. Nothing else. |
|
"You’re close to the line, but the next questions usually decide it." |
Flip hints: childcare costs, medical $35+/mo, rent/utilities. Each one is a real next-step question that the form is about to ask. |
|
"Food benefits may not be the right fit at this income — but a couple of other programs almost certainly are." |
Flip hints (smaller list — elderly/disabled override) PLUS pivots into Discover programs (Medicaid, NSLP, Lifeline) that almost certainly qualify. |
The borderline tier is the design’s center of gravity. "You’re $40 over
the cap, but the form is about to ask about childcare and medical — those
flip a lot of borderline cases" is the script the portal whispers to a
recipient who would otherwise drop off thinking they don’t qualify. This
is the game-changer. A jurisdiction running this in production should
expect a measurable lift in completed applications among households that
used to abandon at the income step.
Where the estimate appears:
-
End of household step (#2) —
EligibilityPeek(the smaller variant). Sets expectations only: "Most 4-person Georgia households qualify if income is under $3,380. We’ll ask income next." No verdict yet — there’s not enough data. -
End of income step (#4) —
EligibilityCheck(the full variant). Real estimate with one of the three tiers, expandable Why, flip hints, pivots if applicable. -
Review step (#5) — a quiet one-line summary. "Estimated · likely qualifies for SNAP and Medicaid · a worker reviews next."
Hard constraints on the rules engine and the UI:
-
The engine must handle categorical eligibility correctly. If anyone in the household has active TANF, SSI, or qualifying Medicaid, SNAP’s gross-income test is waived entirely. Skipping this would send false
borderlinesignals to families who automatically qualify. -
Elderly (60+) and disabled households have a different test (net income only, with extra deductions). The engine must apply the right test based on household composition.
-
Expedited SNAP (7-day fast track for very low income + low liquid assets) must surface a separate "You may qualify for expedited benefits — keep going, you’ll see them in your portal in days, not weeks" message.
-
Never call client-side output "determination" anywhere — even in variable names. Use
eligibility_estimateorguidance. User-facing copy uses "likely qualifies" / "close to the line" / "may not be a fit" — never "qualified," "approved," or "denied." -
Always-submittable. No code path can lock the submit button on a negative estimate. Submit is always one tap.
-
Show the rule that made the decision in plain words. The expandable Why must show actual numbers ("Income $3,420 · cap $3,380 · $40 over"), not jargon. Honest and lets people see a fat-fingered entry.
Where it lives in the binary. Rules per program per jurisdiction ship
in a small TOML file under rulesets/{jurisdiction}/eligibility/. A SNAP
ruleset is ~20–30 KB before compression, ~6–8 KB after. The active
jurisdiction is the only one shipped — the binary doesn’t carry all 50
states. Annual COLA updates (October) ride the normal WASM update cadence.
Implications for the worker portal. The worker side gets the same estimate alongside the case. Easy cases route to a fast-approval queue; borderline cases route to deeper review. Better triage without burdening the recipient or expanding the worker UI.
The client-side estimate is guidance only and never binding. The authoritative determination is computed server-side by the program services after submit. Client copy must never use "denied," "ineligible," "qualified," or "approved" — see hard rule §12.20.
4.6 Form validation + language switcher (built specs)
Both of these were prototype v1.1 gaps and are now built — port the behavior, don’t redesign it.
Inline field validation — reference: ValidatedField + errInputStyle in entry-apply.jsx.
-
Error visual: input border switches to
errortoken; below the field, a row ofbell icon (12px) + the messagein theerrortoken. Never color-alone (§10) — the icon + the literal sentence carry meaning for color-blind and screen-reader users. -
Required marker: the muted word "· required" trailing the label. No asterisk — it fails the 6th-grade rule (§5); a recipient shouldn’t need to know the asterisk convention. Optional fields stay silent or say "Optional" in the hint.
-
Fire timing: on blur, once touched — never mid-keystroke. Track a
touchedmap; a field’s error is suppressed until its first blur. On submit, flip all fields to touched so unblurred errors surface. After first blur, re-validate on every change so the error clears live when the value becomes valid. -
Message voice: plain and specific — "Use MM/DD/YYYY — like 07/22/1991", not "Invalid format."
Language switcher — reference: LangPill, LangSwitch, useLang() in shell.jsx.
-
Mobile top bar:
<LangPill />— globe + current short code ("EN"), placed left of the theme toggle. Tap cycles EN↔ES. -
Desktop side rail:
<LangSwitch full />— full-width segmented EN/ES in the footer cluster, directly above the theme toggle (language + theme group as "how I read this"). -
Globe, never flags — flags misrepresent languages (Spanish ≠ Mexico). Short code on mobile, endonym ("Español", not "Spanish") on desktop — name a language in its own words.
-
Persistence:
useLang()writescy-langto localStorage; kiosk + reload safe. Production wiresset()to the i18n catalog swap; the prototype swaps the visible label only. Theesbundle already loads (§5 / Phase 8).
5. Voice and copy rules
These are tighter than the worker portal’s. The worker portal can use domain language; the customer portal must not.
| Worker term | Customer term |
|---|---|
SNAP |
Food benefits (the formal name appears once on the EBT card and on letters; otherwise "food benefits") |
Medicaid |
Health coverage |
Notice |
Letter |
Recertification |
Renewal |
Income |
Money coming in |
Earned income |
Money from work |
Self-reported |
What you told us |
IEVS / discrepancy |
A pay stub from the last 30 days |
Pending verification |
We need a pay stub |
Determination |
Decision |
Authorized |
Approved |
Adverse action |
(Spell it out — "your benefits will stop on…") |
Fair hearing |
Appeal |
Head of household |
(Don’t use; the form asks for "your name") |
Eligibility worker |
Your worker · Marcus |
Case number / case ID |
Application ID |
EBT card |
EBT card (this is what recipients call it; stays) |
Other rules:
-
6th-grade reading level target. Hemingway editor flags Grade 7 most days.
-
Sentences under 18 words. Most under 12.
-
Specific numbers and dates, not vague ranges. "$487 a month, on the 28th." Not "your monthly benefit."
-
Active voice for what we do. "We mailed you a letter." Not "A letter has been issued."
-
Plain past tense for finished things, future tense for upcoming things. Not present perfect.
-
Show emotion, never gush. "You’re approved." not "Hooray! 🎉 You did it!"
-
Mistakes are no big deal. "Wrong by a little? Just come back and update us." removes the punishment-anxiety that keeps people away from these systems.
-
Recap is the one place we lean warm. "Twelve months. Not one missed deposit." is editorial in a way the rest of the app isn’t. That’s the contract — the recap earns it because it’s rare.
6. Schemas implied by the design
The eventual Rust types. None of these exist yet; derive from these templates.
The Rust code blocks below are the schema contract. Preserve field names, ordering, and the inline comments faithfully when implementing — other docs and code comments cite these structs by name (e.g. "§6.5 `Letter`").
6.1 Case
struct Case {
id: CaseId, // "HH-c8841a23"
state: CaseState, // enum below
confidentiality: Confidentiality, // see §3.8
jurisdiction: String, // "georgia"
county: String, // "Fulton"
created_at: DateTime<Utc>,
closed_at: Option<DateTime<Utc>>,
closed_reason: Option<String>, // shown on the Closed Home hero
applicant_id: ApplicantId, // head of household
household: Vec<HouseholdMember>,
programs: Vec<ProgramEnrollment>,
address: Address, // substitute address only when confidentiality.address_protected
protected_address: Option<Address>, // real address; privileged worker roles only
contact: Contact,
contact_history: Vec<ContactHistoryEntry>, // for §3.10 dual-confirmation + side-channel routing
language: Lang, // en-US | es-US ...
}
enum CaseState {
Pending, // application submitted, under review
InterviewScheduled, // phone interview on the calendar
Approved, // < 14 days since first determination
Active, // > 14 days since approval, < 60 days from renewal due
Renewal, // inside renewal window
Closed,
}
enum Confidentiality {
Standard, // default — self-serve recovery available
Confidential, // self-serve recovery disabled; phone/in-person only
AddressConfidential, // address never echoed; ACP routing applies (see §3.9)
Both, // confidential AND address-confidential
}
6.2 ProgramEnrollment
struct ProgramEnrollment {
program: Program, // Snap | Medicaid | Tanf | Wic | Caps
status: EnrollmentStatus, // Active | Pending | Denied | Closed
amount: Option<Money>, // monthly benefit
covers: Vec<HouseholdMemberId>,
cert_start: Date,
cert_end: Date,
next_action: Option<NextAction>, // shown in Home timeline
}
6.3 Application
struct Application {
id: CaseId,
passcode_hash: PasscodeHash, // argon2id; never persisted in plaintext
submitted_at: DateTime<Utc>,
submitted_by: Actor,
answers: ApplicationAnswers,
documents: Vec<DocumentId>,
state: ApplicationState, // InProgress | Submitted | UnderReview | Decided
}
Save-and-resume before submit. Decision settled on Option A: issue a
temporary DraftCode on first save — DRAFT- + 8 hex chars — that doesn’t
grant access to anything else. The draft is stored server-side and accessible
only with the DraftCode. On submit, the Draft becomes the Case and a fresh
CaseId + passcode are minted. Lifetime: 60 days, then the draft is wiped.
Entry point. The DraftCode is set in a long-lived HttpOnly cookie on the first save. When that cookie is present, the Welcome screen surfaces a third tile: "Continue where you left off · your draft saves automatically." The tile only appears when the cookie matches a still-live draft (server verifies before render). No cookie or expired draft → no tile, no leak. If a recipient is on a fresh device, recovery is phone-only — a worker can locate the draft from the recipient’s name + DOB and read them the DraftCode.
6.4 Document
struct Document {
id: DocumentId,
case_id: CaseId,
uploader: Actor,
uploaded_at: DateTime<Utc>,
kind: DocumentKind, // PayStub | Lease | PhotoId | SsnCard | Other
filename: String,
mime: String,
size: u64,
status: DocumentStatus, // Pending | Accepted | Rejected
request_id: Option<RequestId>, // ties back to the worker request that asked for it
}
6.5 Letter (formerly Notice)
struct Letter {
id: LetterId, // "NTC-2026-0921" — the legal ID format is unchanged
case_id: CaseId,
sent_at: DateTime<Utc>,
category: LetterCategory, // ActionNeeded | Reminder | Decision | Confirmation
subject: String, // 6-8 words, the gist
plain_summary: String, // 1-2 sentences, 6th grade
body: Vec<String>, // paragraphs of the formal letter
program: Program,
signed: bool, // JWS-signed copy exists on disk
read_at: Option<DateTime<Utc>>,
}
The subject + plain_summary is the new payload. The formal body is
identical to the mailed PDF. Both must be authored by the worker /
template; do not synthesize the plain summary at render time.
6.6 RenewalAnswer
struct RenewalAnswer {
case_id: CaseId,
submitted_at: DateTime<Utc>,
household_changed: bool,
household_change_note: Option<String>,
income_changed: bool,
income_change_note: Option<String>,
address_changed: bool,
new_address: Option<Address>,
paystub_document_id: Option<DocumentId>,
}
If all _changed flags are false, renewal is "pure confirmation" — a worker
can fast-approve without re-evaluating. That’s the workflow incentive design.
6.7 ChangeRequest
struct ChangeRequest {
id: ChangeId,
case_id: CaseId,
submitted_at: DateTime<Utc>,
kind: ChangeKind, // Income | Household | Address | Contact | Other
change_type: Option<String>, // "new_job", "moved_in", "moved", etc. — kind-specific
affected_person: Option<HouseholdMemberId>,
effective_date: Option<Date>,
new_value: ChangeValue, // tagged union by kind — see below
notes: Option<String>,
documents: Vec<DocumentId>,
status: ChangeStatus, // Submitted | UnderReview | Applied | Rejected
}
enum ChangeValue {
Income { monthly_amount: Money, source_description: String },
Household { name: String, dob: Option<Date>, relationship: String },
Address(Address),
Contact { phone: Option<String>, email: Option<String> },
Other,
}
The 10-day reporting requirement is the legal context — design copy reflects it. The worker side queues these by jurisdiction → routes to the right caseworker → applies on approval.
6.8 Message and MessageThread
struct MessageThread {
case_id: CaseId,
participants: Vec<Actor>, // typically [customer, primary_worker]; supervisor can be added
created_at: DateTime<Utc>,
last_message_at: DateTime<Utc>,
}
struct Message {
id: MessageId,
thread_id: CaseId, // 1:1 with case in v1
from: Actor,
sent_at: DateTime<Utc>,
text: String, // max 2000 chars, validated for SSN/banking patterns
attachments: Vec<DocumentId>,
read_by: Vec<(Actor, DateTime<Utc>)>,
}
struct SystemMessage {
thread_id: CaseId,
at: DateTime<Utc>,
kind: SystemMessageKind, // PayStubDue | DepositLanded | InterviewScheduled | ...
payload: serde_json::Value,
}
The customer thread is scoped to the assigned worker for v1. Supervisor shadowing (worker portal handoff §2.4) lets a supervisor see all threads without joining them. A worker reassignment (case routes to a new county on move) carries the thread.
SSN/banking guard. The text field is validated client-side and again
server-side against patterns matching SSN (\d{3}-\d{2}-\d{4} and friends),
bank routing/account number patterns. If a match is detected, the send is
gated by a "Are you sure?" dialog explaining why this is risky. The
prototype’s trust footer ("Don’t send Social Security numbers or banking
info") is the user-facing copy.
6.9 DocumentRequest
struct DocumentRequest {
id: RequestId,
case_id: CaseId,
asked_at: DateTime<Utc>,
due_at: Date,
document_kind: DocumentKind,
instructions: String, // shown in the Home banner + Documents callout
resolved_by: Option<DocumentId>,
}
Home queries the open DocumentRequest`s and renders one banner per. Today
the prototype shows at most one in the `active Home hero; the design
accommodates two stacked banners (rare but real — e.g. pay stub + ID
re-verify).
6.10 EligibilityHint (Discover)
struct EligibilityHint {
case_id: CaseId,
program: ExternalProgram, // Lifeline | NSLP | LIHEAP | VITA | WIC | ...
eligibility: HintEligibility, // Automatic | Likely | Maybe
affected_members: Vec<HouseholdMemberId>, // e.g. "Aiden qualifies for school lunch"
plain_explanation: String,
detail_fields: BTreeMap<String, String>, // populated per program (providers count, etc.)
cta_url: Option<Url>, // where the user goes to actually apply
interested: bool, // user's one-tap signal; surfaces in worker view
last_computed: DateTime<Utc>,
}
Eligibility is computed, not hand-curated per case. The categorical rule
("if SNAP is active, Lifeline = Automatic") is encoded in a discover_rules.toml
per jurisdiction:
[hints.lifeline]
program = "Lifeline"
trigger = "snap.active OR medicaid.active OR ssi.active"
eligibility = "Automatic"
plain_explanation = "Because you have SNAP, you qualify automatically."
cta_url = "https://lifelinesupport.org/"
The runtime evaluates triggers against the Case and emits `EligibilityHint`s for the Discover screen.
6.11 RecapData and the recap pipeline
struct RecapData {
case_id: CaseId,
year: u16,
generated_at: DateTime<Utc>,
benefits: BenefitsRollup,
transactions: TransactionRollup,
household: HouseholdRollup,
notes: Option<String>, // optional editorial line (jurisdiction-set)
}
struct BenefitsRollup {
total_received: Money,
months_covered: u8,
per_person_average: Money,
biggest_month: (Month, Money),
}
struct TransactionRollup {
trip_count: u32,
average_trip: Money,
top_store: Option<(String, u32, String)>, // (name, visit_count, location_hint)
by_month: Vec<MonthlySpend>,
meals_estimate: u32, // USDA-derived
}
struct HouseholdRollup {
members_continuous: u8,
members_changed_count: u8,
}
Where the data comes from. The benefits + household side is canopy’s own records. The transactions side comes from the state’s EBT processor (FIS, Conduent, etc. depending on state contract) — typically a daily batch with per-transaction merchant name + amount. Georgia’s processor exposes this through the EPPIC interface.
Privacy boundaries for recap:
-
Never include individual store locations narrower than city. "Kroger · Buford Hwy" is fine; "Kroger #18342, 1247 Buford Hwy" is not (locates the recipient).
-
Never include specific dates of high spending. Aggregate to month.
-
Sharing (the Share button on the outro slide) generates a redacted version — no name, no case ID, no city. Just the numbers and the case-year handle.
-
Opt-out must be honored throughout; if a recipient opted out of recap, no recap data is computed or stored.
-
Data minimization: recap data is recomputed at render time from existing case + transaction records; the rollups themselves are not stored as a precomputed table. Cheaper to compute on-demand than to maintain another store of derived sensitive data.
-
For
Confidentiality::Confidentialcases: the Household slide must show initials only ("J., M., A., M.") not full names. The Top Store slide must aggregate to city level only (no neighborhood/street hints). ForAddressConfidential, all of the above plus no city information at all (state-level rollup only). -
For ACP-flagged cases: recap entirely strips any neighborhood- or store-location information. The Top Store slide degrades gracefully to "Your most-shopped chain" without a location label.
6.12 RecoveryAttempt
struct RecoveryAttempt {
id: AttemptId,
started_at: DateTime<Utc>,
case_id: Option<CaseId>, // None until the identify step succeeds
device_cookie: Option<DeviceCookie>,
ip: IpAddr,
user_agent: String,
steps: Vec<RecoveryStep>,
outcome: RecoveryOutcome, // InFlight | Succeeded | LockedOut | Abandoned
side_channel_sent: bool,
}
struct RecoveryStep {
step: RecoveryStepKind, // Identify | Address | History | Reveal
at: DateTime<Utc>,
correct: bool,
}
Stored for audit + anomaly review. Worker portal exposes a view of recent recovery attempts per case, with the failed-then-succeeded-from-a-new-device pattern flagged.
6.13 EligibilityRules and EligibilityEstimate
// Per-program, per-jurisdiction rule data shipped with the binary.
// SOURCE OF TRUTH for the server is a separate authoritative rules
// service — these client-shipped rules MUST be flagged guidance_only.
struct EligibilityRules {
program: Program, // SNAP | Medicaid | TANF | WIC | ...
jurisdiction: String, // "georgia"
effective_from: Date, // COLA cycle start (typically Oct 1)
effective_to: Option<Date>, // next cycle
guidance_only: bool, // must be true for client-shipped sets
income_limits: BTreeMap<u8, Money>, // household_size -> monthly gross cap
deductions: DeductionRules,
categorical: Vec<CategoricalRule>, // SNAP-auto-qualify if TANF/SSI/etc.
expedited: Vec<ExpediteRule>, // fast-track triggers
special_tests: Vec<SpecialTest>, // elderly/disabled net-income test, etc.
messaging: MessagingTemplate, // copy strings per tier, per language
}
// The output the rules engine produces from the in-progress application.
struct EligibilityEstimate {
program: Program,
tier: EstimateTier, // Likely | Borderline | Unlikely
gist: String, // 1-sentence headline
why: Vec<EstimateReason>, // shown in the "Show why" expander
flip_hints: Vec<FlipHint>, // actionable next questions
pivots: Vec<DiscoverPivot>, // adjacent programs if unlikely
bypass: Option<BypassReason>, // "categorical eligibility via TANF" wins
}
enum EstimateTier {
Likely, // comfortable margin OR categorical bypass triggered
Borderline, // within ~15% of the gate, OR likely-to-flip with deductions
Unlikely, // far over the gate even with deductions; pivot path
}
struct FlipHint {
icon: String, // matches ICON keys
label: String, // "Childcare or dependent care"
sub: String, // "Any amount you pay for daycare..."
follows_to_step: u8, // which apply step asks about this
}
The TOML form of EligibilityRules lives at
rulesets/{jurisdiction}/eligibility/{program}.toml and is bundle-loaded
at WASM init. See §4.5 for the canonical schema sketch.
7. API surface (sketch)
REST-shaped because Dioxus is happy with it. Authentication via the session token in an HttpOnly cookie + a paired CSRF token in a header (double-submit pattern).
Pre-auth
POST /api/application → Create draft. Returns DraftCode.
PATCH /api/application/:draft_code → Update draft.
POST /api/application/:draft_code/submit → Final submit. Returns { case_id, passcode }.
POST /api/auth/lookup → { case_id, passcode } → session.
POST /api/auth/recover/identify → { case_id | (name, dob, last4) } → { challenge_id }
POST /api/auth/recover/answer → { challenge_id, step, answer } → { next | locked | success }
POST /api/auth/recover/reveal → { challenge_id, captcha_token } → { passcode (one shot) }
POST /api/auth/logout → Invalidate session.
Authed — case data
GET /api/case → Case (current state, programs, household, contact)
GET /api/case/letters → List letters, paginated
GET /api/case/letters/:letter_id → Single letter
PATCH /api/case/letters/:letter_id/read → Mark read
GET /api/case/documents → List documents on file
POST /api/case/documents → Upload (multipart)
DELETE /api/case/documents/:doc_id → Remove (only if status=Pending)
GET /api/case/document_requests → Open requests (drive Home banner)
Authed — flows
POST /api/case/renewal → Submit renewal answers
GET /api/case/renewal/prefill → Existing values to confirm against
POST /api/case/change → Submit a ChangeRequest
GET /api/case/messages → List messages in the thread, paginated
POST /api/case/messages → Append a message (with optional attachments)
PATCH /api/case/messages/:msg_id/read → Mark a worker message read
Authed — extras
GET /api/case/ebt → Current balance + last N transactions
GET /api/case/ebt/transactions?since=... → Paginated transactions
GET /api/case/discover → Computed EligibilityHints for this case
POST /api/case/discover/:program/interest → One-tap interest toggle
GET /api/case/recap?year=2026 → Returns RecapData OR 404 if opted-out
Real-time updates
SSE on a single channel — /api/case/stream — emits events:
-
letter.new -
document.requested/document.accepted/document.rejected -
state.changed(caseState transitions) -
message.new -
interview.scheduled -
ebt.deposit
WebSocket is overkill; long-polling is enough for the cadence (every 30s foregrounded, every 5min backgrounded if SSE isn’t supported).
8. Persistence boundaries
| Lives in | Examples |
|---|---|
URL |
Active screen, selected letter ID, apply step number, recap year |
DB |
Application, Case, Documents, Letters, Renewal, Change, Messages, RecoveryAttempts, audit events |
indexedDB |
Session token, language pref, theme pref, draft-form scratch, last-known nav state for fast restore, recap-seen-this-year flag |
localStorage |
(prototype only) — the implementation should graduate everything to indexedDB |
Memory |
Letter being read, current form state pre-save, recap slide index |
URL params should support:
-
?screen=home -
?screen=letter&id=NTC-2026-0921 -
?screen=apply&step=3 -
?screen=recap&year=2026 -
?screen=recover -
?kiosk=1(shared-device mode)
Deep links are essential for the helpline-shared-link pattern: a worker emails a link to a specific letter and the customer lands on it.
9. Customer ↔ Worker wiring
The two portals see the same data through different lenses.
| Worker action | Customer-side effect |
|---|---|
Sends a letter (any category) |
New row in Letters · unread badge bumps · push optional |
Requests a document |
|
Marks an uploaded document accepted |
The Files list row shows Accepted; no notification |
Schedules a phone interview |
|
Issues a decision |
|
Closes the case |
|
Re-opens / new app under same household |
New |
Sends a message in the thread |
New row in Messages · unread count on Home quick-link tile · push optional |
Replies to a change request |
Status update in |
| Customer action | Worker-side effect |
|---|---|
Uploads a document |
Worker’s "Pending verifications" queue gets a row |
Submits a renewal |
Worker’s renewal queue; auto-route to assigned worker |
Submits a change-of-info request |
Worker’s "incoming changes" queue; SLA timer starts |
Sends a message |
Worker’s thread inbox surfaces an unread; SLA: 2 business days |
Toggles "Tell me more" on a Discover program |
Surfaces in worker view as a soft signal (not actionable); analytics for partner-program take-up |
Completes recovery successfully |
Audit log entry on the case; side-channel notification fires (see §3.4) |
Fails recovery (locked out) |
Audit log entry + flagged for review if patterns repeat |
Worker portal additions implied by this design (not yet in worker portal):
-
Customer-message inbox (separate from internal worker chat)
-
Change-request queue with SLA timer
-
Discover-interest analytics view (jurisdiction-aggregate)
-
Recovery-attempts review panel
10. Accessibility commitments
Same as worker, plus:
-
Tap targets ≥ 44 × 44 px everywhere. Currently met by
Btnatmdandlgsizes; thesmsize is for desktop-only contexts. -
Reading order matches visual order. Hero before programs before timeline before quick links.
-
Color is never the only signal. Status pills always have text. Required-form-field state never relies on red alone.
-
Reduced motion disables: the upload-progress animation, the case-state hero entrance, the EBT card flip transition (snap-cut instead), and the recap auto-advance (manual-advance only when
prefers-reduced-motion: reduce). -
prefers-color-schemeis respected on first paint when nocy-themeis saved. The prototype reads localStorage only; the implementation should fold prefers-color-scheme in as a default-only signal (a saved preference always wins). -
Focus visibility — the prototype’s inputs use the accent gold for focus rings; ensure that’s present in Dioxus’s implementation of every interactive element, not just
<input>. -
Recap is keyboard-navigable — arrow keys advance, Esc closes (implemented in the prototype; preserve in the Dioxus port).
-
Card-flip — the EBT card flips on click/tap, but the same content is reachable via expand/collapse for keyboard users.
12. Hard rules for the implementation
These are non-negotiable. Several are also enforced or echoed by ADR-026. The numbering matches the source handoff’s §12 list.
-
No
localStoragefor anything sensitive. Tokens go in indexedDB only. -
No client-side passcode strength checks. The server generates passcodes; clients only validate format.
-
No "remember me." Period.
-
No password fields on the apply form. The applicant doesn’t pick credentials.
-
No mandatory email. Email is opt-in (for letter copies). The portal must work without it.
-
No third-party fonts hosted by the implementation. Self-host Montserrat + JetBrains Mono.
-
No analytics that send personal data off-site. First-party only.
-
Every form is keyboard-completable. Tab order = visual order.
-
Per-IP-primary rate limiting is forbidden. Cellular CGNAT — see §3.6.
-
Recovery successful-attempt notifications are mandatory. The side-channel ping (email + SMS) is the cheapest fraud catch we have; don’t skip it for "noise reduction."
-
No store-level granularity in recap. Aggregated to city or street name only (§6.11).
-
No reCAPTCHA. Deanonymization concern for the audience. Use Turnstile or hCaptcha invisible.
-
Confidential cases never get self-serve recovery. The recovery flow must check
case.confidentialitybefore offering a single challenge. Phone or in-person only. -
Address is never used in challenge questions. Intimate threats know addresses. Don’t make that the security boundary (§3.7).
-
No identity-data-only recovery path. SSN, DOB, name, and address are all things a current or former household member already knows. Self-serve recovery requires the Application ID + DOB at minimum; without the App ID, recovery is phone-only (§3.7).
-
24h delay on every successful recovery before passcode reveal. The window for the legitimate user to intercept must always exist (§3.7).
-
Contact-info changes require dual confirmation + 24h cooldown. Never silently update phone or email (§3.10).
-
Side-channel notifications go to the application-time contact as well as the current contact. A recently-changed contact is a possibly-controlled contact.
-
"Get help" safety exit on every credential / contact / case-state flow. Routes to a screen with DV resources and a one-tap "hide this screen now" affordance (§3.11).
-
Eligibility guidance never gates submit. Client-side rules produce a non-binding estimate to encourage more complete applications, never block them. The words "denied," "ineligible," "qualified," or "approved" are forbidden in client copy — the binding determination happens server-side after submit (§4.5).