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) — 0e1c114.

MR1 (a) handlers

canopy-web api/{assets,expenses}.rsadd/edit action handlers POSTing /v1/persons/{id}/{assets,expenses}/claims (worker author, AcceptedVerified); return Response with a real 403 deny; success redirect carries program. No remove (close primitive → #562).

Done (2026-06-19) — 7db2f28.

MR1 (b) #632 sweep

Apply in_program_scope + the 403 deny to the existing income.rs actions (currently ungated); add the hidden program field to tab_income.html + program_slug to TabIncomeTemplate + fixtures; carry program in income’s redirect.

Done (2026-06-19) — c2fdc9e.

MR1 (c) read+render

render_{assets,expenses}_tab (mirror render_income_tab minus IEVS) + tab_{assets,expenses}.html showing current facts with provenance (claim_status + author) + the add/edit forms; wire the section fetch + dispatch_fetch arms.

Done (2026-06-19) — e94b97c.

MR1 (d) tests

The /full worker-claim → AcceptedVerified provenance contract is covered by canopy-persons batch_expansion_test + the canopy-web render unit tests (provenance_badge/render); 5 fact_editor #632 gate unit tests (incl. 403 deny); a Playwright add-asset/expense E2E spec.

Done (2026-06-19) — 8643346.

MR1 (e) docs

Antora api/canopy-web.adoc (new routes + gate + 403), this plan, CHANGELOG, master-plan T1-8 row → In progress.

Done (2026-06-19) — the docs commit.

MR2 (a) handlers

canopy-web api/members.rsadd_member (CreatePerson→AddMember, effective_date, orphan-safe), edit_person (PUT person, demographics only, SSN-excluded, COALESCE-aware), remove_member; #632 gate + 403 deny.

Done (2026-06-19) — f97d223.

MR2 (b) read+render

render_persons_tab over GET /v1/households/{id} (HouseholdWithMembers — the only source carrying the member id) + tab_persons.html + section/dispatch_fetch wiring.

Done (2026-06-19) — 98bb7ff (2-read merge with /full for demographics; persons-tab render test incl. a no-SSN-input assertion).

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 batch_expansion_test. CLI person update deferred → #869 (pre-existing ADR-007 gap; no new REST endpoint here).

Done (2026-06-19) — 43d0846.

MR2 (d) docs

Antora api/canopy-web.adoc (member routes + persons tab), this plan → Done + As-built, master-plan T1-8 row → Done + reconcile, CHANGELOG; follow-ups #868–#871.

Done (2026-06-19) — the docs commit.

NOTE
ADR-007 API/CLI parity adds no work in T1-8 — the asset/expense editors call the T1-4 /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}.rsunknown_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 via fact_id), exactly as income does. A Track-2 issue tracks the policy-aware write.

  • Proposed-claim inbox — the Proposed producer is IEVS = T1-9 (#677); no Proposed claims 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 // #562 marker 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 /claims like income.

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 (// #562 marker).

Persons section

Member editor (add member, edit person demographics, remove member). render_household_tab stays the read-only summary (display vs edit).

Member relationship

Free-text <input> at add time (mirror the applicant portal apply.rs:383; no relationship enum exists — do not invent a <select>). Not editable after add (no update-member endpoint → remove+re-add, or a follow-up endpoint).

AddMember body

{person_id, relationship, effective_date}effective_date is required → default to today.

edit_person

Demographics only; SSN excluded; COALESCE-aware blank handling (omit unchanged/blank optionals — null keeps the existing value).

SSN

Excluded from both person forms (PII / J4); secure capture is a follow-up.

Response / deny status

Handlers return axum::response::Response; deny → (FORBIDDEN, render_program_scope_denied_case(…​)) (mirror put_section_proxy, not income’s status-less Html = 200); success → Redirect carrying program; error → (422, Html).

#632 gate

Applied to all fact-write actions incl. income (sweep); form program rendered from the already-VIEW-gated ?program= context, re-checked on write (appeals.rs:137 precedent); 403 on deny. BFF-level — server-side enforcement is epic &52/#424 (residual risk noted in Verification).

Member read source

GET /v1/households/{id}HouseholdWithMembers (carries member id) for the editor; /full/MemberFull lacks the member id.

Provenance display

Asset/expense tabs render claim_status + author kind (parsed into the *Record); a test asserts it.

Worker author → status

AcceptedVerified (feeds determinations immediately) — canopy-contracts-facts/src/lib.rs:250.

Contracts dep

None — serde_json::json! bodies (mirror income’s no-dep edge pattern).

CLI parity

T1-8 adds no new REST endpoint → no new ADR-007 obligation; the missing canopy person update is a follow-up.

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}&notice=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 (AddMember takes an existing person_id; no inline/transactional endpoint): pre-validate the form first (ADR-026 §5 discipline), POST /v1/persons (CreatePerson) → POST /v1/households/{id}/members (AddMember with relationship + effective_date = today). If AddMember fails after CreatePerson succeeds, tracing::error! the orphaned person_id + return a worker-facing error naming it. Relationship = free-text <input> (mirror canopy-portal/src/pages/apply.rs:383); SSN excluded from the form.

  • edit_person — PUT /v1/persons/{id} (UpdatePerson), demographics only; relationship is not editable (no endpoint); SSN excluded; normalize blank optionals to None (COALESCE keeps existing — a blank box must not clear or trip length(min=1)).

  • remove_member — DELETE /v1/households/{household_id}/members/{member_id}; the member_id comes from the HouseholdWithMembers read, 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 checkdocs crate); no functional impact on T1-8.

  • Deviation — canopy person update CLI 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-aware PUT/Proposed-feed remain Track 2 / T1-9.

Follow-ups

  • Track-2 issue (related to #676): policy-aware fact-write — PUT=reported-changesnap_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 update CLI verb (pre-existing ADR-007 parity gap — PersonAction has 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.

Edit this page · default