T1-8 — Worker Fact-Authoring UI: Asset/Expense + Member Editors + #632 Gate (#676)
On this page
Epic &56 / Track 1, T1-8 (#676). Deliver the worker-portal case-detail editors the
income editor (T1-4 Slice 3) already models — for assets, expenses, and
household members — replacing the #562 "coming soon" stubs, and apply the
#632 per-program write gate to every fact-write action (including the income
ones that currently lack it). Asset/expense facts are authored into the
canopy-persons version corpus as worker-authored AcceptedVerified claims; member
edits use the un-versioned identity endpoints. Shipped in two MRs under #676.
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-19) — |
MR1 (a) handlers |
|
Done (2026-06-19) — |
MR1 (b) #632 sweep |
Apply |
Done (2026-06-19) — |
MR1 (c) read+render |
|
Done (2026-06-19) — |
MR1 (d) tests |
The |
Done (2026-06-19) — |
MR1 (e) docs |
Antora |
Done (2026-06-19) — the docs commit. |
MR2 (a) handlers |
|
Done (2026-06-19) — |
MR2 (b) read+render |
|
Done (2026-06-19) — |
MR2 (c) tests |
The fact_editor #632 gate unit tests (MR1) cover the member-write gate (same helper); the persons-tab render test asserts the forms wire + no editable SSN input; an E2E persons spec; the canopy-persons create/add/full contract is |
Done (2026-06-19) — |
MR2 (d) docs |
Antora |
Done (2026-06-19) — the docs commit. |
/claims endpoints (CLI verbs already ship), and the member editor calls
pre-existing person/household endpoints. The one pre-existing parity gap
(canopy person update, missing) is a follow-up, keeping T1-8 canopy-web-only.
Context
T1-4 Slice 3 (#672, merged) made the canopy-persons version corpus the sole
fact store and, as part of that slice, cut over the canopy-web income editor
(services/canopy-web/src/api/income.rs: add→new claim, edit→full-window
correction, remove→close) to author worker income facts via POST
/v1/persons/{id}/income/claims carrying Author::Worker (auto-accepted
AcceptedVerified, which feeds determinations). T1-5 (#673) made every claim emit
an attributed *.claimed event.
The case-detail assets, expenses, and persons sections remain #562
"coming soon" stubs
(services/canopy-web/src/case_detail/sections/{assets,expenses,persons}.rs →
unknown_section::render_coming_soon). T1-8 delivers their worker editors by
mirroring the income editor, and closes an authorization gap: the income write
actions skip the #632 program-scope gate
(Plan: Worker intake + program independence (SNAP + TANF) L1 /
SessionData::in_program_scope), which is enforced across actions.rs /
appeals.rs / applications.rs but not in income.rs.
Scope decision
#676 as written is stale on three counts vs the as-built reality; all three
descopes are ADR/as-built grounded.
SHIP (canopy-web only): asset + expense editors (add/edit), the persons/member editor (add member, edit person demographics, remove member), the #632 gate on all fact-write actions, tests, docs.
DEFER (tracked):
-
Policy-aware
PUT = reported-change-during-cert— there is no PUT on the claim endpoints and no change-reporting backend (snap_change_reports, the 10-day timeliness clock, the adjustment re-determination do not exist), and ADR-027 §5 places the materiality→recert + notice wiring in Track 2. The editors POST/claims(worker correction viafact_id), exactly as income does. A Track-2 issue tracks the policy-aware write. -
Proposed-claim inbox — the
Proposedproducer is IEVS = T1-9 (#677); noProposedclaims exist until then (the T1-6 as-built note already re-sliced the feed to T1-9). -
Asset/expense
remove— the close (DELETE) primitive exists only for income; asset/expense close is #562. Editors ship add+edit; remove lands when #562 does (a// #562marker sits where the remove handler will go).
Identity facts (persons/members) are not versioned (ADR-027 §3) —
CreatePerson/AddMember/UpdatePerson carry no Author. SSN is excluded from
both the add and edit person forms (PII; see Decisions).
Decisions
| Decision | Resolution |
|---|---|
Policy-aware PUT / change-reporting |
Defer → Track 2 (no backend; ADR-027 §5). Editors POST |
Proposed-claim inbox |
Defer → T1-9 (#677) (its IEVS producer); already re-sliced. |
Asset/expense remove |
Add+edit only; remove wired when #562’s close primitive lands ( |
Persons section |
Member editor (add member, edit person demographics, remove member). |
Member relationship |
Free-text |
AddMember body |
|
edit_person |
Demographics only; SSN excluded; COALESCE-aware blank handling (omit unchanged/blank optionals — |
SSN |
Excluded from both person forms (PII / J4); secure capture is a follow-up. |
Response / deny status |
Handlers return |
#632 gate |
Applied to all fact-write actions incl. income (sweep); form |
Member read source |
|
Provenance display |
Asset/expense tabs render |
Worker author → status |
|
Contracts dep |
None — |
CLI parity |
T1-8 adds no new REST endpoint → no new ADR-007 obligation; the missing |
Implementation
Two MRs under #676 (MR1 Relates to, MR2 Closes); each commit independently
build-green; per-commit the pre-commit token gate + a fresh J1–J8 subagent over the
staged diff.
MR1 (a) handlers — assets/expenses
services/canopy-web/src/api/{assets,expenses}.rs (new; add mod assets; mod
expenses; to api/mod.rs), mirroring income.rs: Add{Asset,Expense}Form
Edit{Asset,Expense}Form (edit carries the full value + window as hidden carriers,
like EditIncomeForm) + a hidden program + target_section;
{asset,expense}_claim_body(…) building the serde_json::json! body
(value.{asset_type,value,description} / value.{expense_type,amount,frequency},
source:"self_attestation", author:{author_type:"worker",sub},
origin:"worker_portal", valid_from, valid_to, fact_id); add_* + edit_*
handlers POSTing /v1/persons/{id}/{assets,expenses}/claims. No remove_*
(close → #562; leave a // #562 marker). Handlers return
axum::response::Response: gate before the persons call — deny → (FORBIDDEN,
render_program_scope_denied_case(&form.program, &form.household_id)); success →
Redirect to /cases/{hh}?program={prog}&focus_section={fs}¬ice=eligibility-changed
(reuse safe_focus_section); error → (UNPROCESSABLE_ENTITY, Html(…)). Routes
after api/mod.rs:159: /actions/{asset,expense}/{add,edit}. Separate form DTOs
separate tab_{assets,expenses}.html (no IEVS/variance/employer_name).
MR1 (b) #632 gate sweep
Add in_program_scope(&form.program) + the 403 deny to the existing income.rs
add_income/edit_income/remove_income (change their return type to Response
to emit a real 403); add a hidden program field to the tab_income.html editor
forms + a program_slug field to TabIncomeTemplate and every construction site
test fixture; carry program in income’s success redirect (income.rs:157).
MR1 (c) read+render — assets/expenses
render_{assets,expenses}_tab in case_detail.rs (mirror render_income_tab
minus the SNAP-IEVS merge): read GET /v1/households/{id}/full, parse
m["assets"] / m["expenses"] into new AssetRecord / ExpenseRecord read DTOs
in clients.rs (mirror IncomeRecord) that carry the provenance fields
(claim_status + author kind), build Asset/ExpenseRow for new
templates/cases/tab_{assets,expenses}.html (current-facts table with a
provenance/status indicator mirroring income’s "verified" badge; add form; per-row
edit form with hidden carriers; no variance column, no remove button). Factor each
member-loop body into a build_{asset,expense}_row() helper so each render_*_tab
stays ≤100 lines (B2) / ideally ≤40 (clippy). Wire sections/{assets,expenses}.rs
fetch(clients, household_id, session, program, csrf_token, item) (mirror
sections/income.rs) + the dispatch_fetch arms (sections.rs:261-262). The stub
modules already carry #[canopy_plugin] + Plugin.toml — change only the fetch
signature + the dispatch arm.
MR1 (d) tests
Integration: a worker asset + expense authored via the editor handler lands an
AcceptedVerified worker-authored version and appears in GET
/v1/households/{id}/full, and the rendered tab shows the provenance; a 403
#632 gate test on an out-of-scope-program write (mirror put_section_proxy’s 403).
E2E: a Playwright spec (mirror `tests/e2e/specs/fact-history.spec.ts) opening the
Assets/Expenses sections, adding a fact, asserting it renders (dark-theme check).
MR1 (e) docs
Antora api/canopy-web.adoc (new /actions/{asset,expense}/ routes + the #632
gate on all fact writes + the 403 deny); this plan’s MR1 Status cells →
Done (YYYY-MM-DD) — <sha>; CHANGELOG.adoc == Unreleased *Added entry;
master-plan T1-8 row → In progress.
MR2 (a) handlers — members
services/canopy-web/src/api/members.rs (new; add mod members;): forms carry the
hidden program + target_section; AuthenticatedWorker + WritePermission
in_program_scope(&form.program); return Response with the 403 deny pattern.
-
add_member— 2-call orchestration (AddMembertakes an existingperson_id; no inline/transactional endpoint): pre-validate the form first (ADR-026 §5 discipline), POST/v1/persons(CreatePerson) → POST/v1/households/{id}/members(AddMemberwithrelationship+effective_date = today). IfAddMemberfails afterCreatePersonsucceeds,tracing::error!the orphanedperson_id+ return a worker-facing error naming it. Relationship = free-text<input>(mirrorcanopy-portal/src/pages/apply.rs:383); SSN excluded from the form. -
edit_person— PUT/v1/persons/{id}(UpdatePerson), demographics only;relationshipis not editable (no endpoint); SSN excluded; normalize blank optionals toNone(COALESCE keeps existing — a blank box must not clear or triplength(min=1)). -
remove_member— DELETE/v1/households/{household_id}/members/{member_id}; themember_idcomes from theHouseholdWithMembersread, not/full.
MR2 (b) read+render — persons
render_persons_tab in case_detail.rs reads GET /v1/households/{id} →
HouseholdWithMembers (members carry id + relationship + effective_date — the
only source with the member id) for the member list / remove + edit-demographics
forms; templates/cases/tab_persons.html; wire sections/persons.rs fetch(…)
+ the dispatch_fetch arm (sections.rs:260). The persons section is the member
editor; render_household_tab stays the read-only summary.
MR2 (c) tests
Integration: add member (→ membership reflects it), edit demographics (COALESCE-aware — an omitted field is preserved), remove member (→ gone); a 403 gate test; an assertion that SSN never appears in logs for the add/edit path. E2E: add a member via the persons editor, assert it appears.
MR2 (d) docs (FINAL T1-8 MR)
Antora api/canopy-web.adoc (member routes); this plan Status → Done + an
As-built note (the descopes, ADR-027 §5 Track-2 grounding, relationship-immutable,
SSN exclusion); master-plan T1-8 row → Done (YYYY-MM-DD) — <sha> + reconcile its
description (drop "policy-aware PUT" + "Proposed-claim inbox"; note the Track-2 /
T1-9 re-slice); CHANGELOG Added entry. No .claude/CLAUDE.md change (only the
final MR of the epic flips its status tables; T1-8 is not the last epic unit).
Verification
Per commit: cargo build + cargo clippy -p canopy-web --all-targets — -D
warnings + nextest on canopy-web. After handler/template work: cargo xtask dev
refresh then cargo nextest run -p canopy-web (devstack-gated; confirm new tests
RAN) + cargo xtask e2e. Before push: full cargo xtask validate. Tests must
include the 403 gate-deny assertion, asset/expense appearing in /full,
provenance rendered, and SSN absent from logs. cargo xtask quality-budgets:
B3a_src/B5 may legitimately rise (the BFF serde_json::Value pattern;
serde_json::json! does not count) — bump the lock with a documented justification
+ surface it (ADR-030 gate), never a silent --write-lock; B2/B8 stay flat.
Residual security risk (state in the MR): the BFF program gate proves the worker
holds some claim for form.program, not that the write is bound to this
household’s program — true binding needs the epic &52 / #424 server-side
X-Canopy-Actor enforcement. T1-8 matches the existing BFF-gate posture and does
not regress it.
As-built notes
-
Shipped in two MRs: MR1 (!644, merged) — asset/expense editors + the #632 gate sweep across all fact writes; MR2 (this branch) — the persons/member editor.
-
The branch rebased onto the post-#867 template migration (check-docs is now the
checkdocscrate); no functional impact on T1-8. -
Deviation —
canopy person updateCLI deferred to a follow-up (#869) rather than added in MR2. Rationale:PUT /v1/persons/{id}is a pre-existing REST endpoint, so T1-8 adds no new REST surface and ADR-007 parity is not newly triggered; the missing CLI verb is a pre-existing gap, and deferring it keeps MR2 canopy-web-only (the stated scope). The other descopes held: relationship is non-editable (no update-member endpoint → #870), SSN excluded from the forms (#871), and the policy-awarePUT/Proposed-feed remain Track 2 / T1-9.
Follow-ups
-
Track-2 issue (related to #676): policy-aware fact-write —
PUT=reported-change→snap_change_reports+ the 10-day timeliness clock + adjustment re-determination + Notice of Action (ADR-027 §5/§6). -
New issue (related to #676):
feat: canopy person updateCLI verb (pre-existing ADR-007 parity gap —PersonActionhas create/list/get/delete, no update). -
New issue (related to #676): an update-member / update-relationship endpoint in canopy-persons (so a member’s relationship can be edited without remove+re-add).
-
New issue (related to #676): secure SSN capture in the worker person editor (redaction /
secrecy), since v1 omits SSN from both forms. -
Plan: Worker Fact Authoring and Provenance — #562 owns the asset/expense close primitive (the editors' remove); T1-9 (#677) owns the Proposed-claim feed + accept/reject.