Plan: Worker portal household/person address editor (#983)
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1:
api/address.rs— forms, typed body-builder, handlers (with membership check), render - Step 2: Routes + Address section plugin + composition registration
- Step 3: Editor UI (Askama form)
- Step 4: Handler unit tests
- Step 5: e2e — seed-then-edit address in the shelter-cascade journey
- Step 6: Inventory + docs
- Follow-ups (filed)
- Step 1:
- Files Touched
- Verification
- Documentation Updates
A UI-gap unit of the Scenario Inventory & Human-Fidelity E2E (epic &61) plan (MR7’s #973–#983 backlog). #983 is the inverse of the #981 pattern: where #981 built missing service behavior and left its walkthrough walkthrough_blocked_by a new UI issue, #983 builds the missing worker UI on an address-authoring surface that already exists in canopy-persons — so it closes the fidelity gap in an already-paired walkthrough (journey-snap-shelter-cascade, shipped by #973) rather than carrying a walkthrough_blocked_by. Surfaced by #973 / MR !768.
Status
| Step | Description | Status |
|---|---|---|
1 |
New |
Done (2026-07-07) |
2 |
|
Done (2026-07-07) |
3 |
Askama |
Done (2026-07-07) |
4 |
Co-located |
Done (2026-07-07) |
5 |
e2e |
Done (2026-07-07) |
6 |
Inventory + docs: walkthrough honest-scope flip + |
Done (2026-07-07) |
Epic: &61
Issues: #983 (this); #973 (surfacing / MR !768)
Branch: feature/983-worker-address-editor
Context
The worker portal already has first-class fact-editors for income, expenses, and assets: the POST /actions/{income,expense,asset}/{add,edit,…} routes (services/canopy-web/src/api/mod.rs:193-204 — income carries add/edit/remove, asset + expense add/edit) author effective-dated, accepted-verified facts into the canopy-persons version corpus via the /claims write path. There is no equivalent for address. Case detail only displays the head-of-household’s primary residential address, read-only (card at services/canopy-web/templates/cases/tab_household.html:35-43, from a GET /v1/persons/{id}/addresses read in render_household_tab, case_detail.rs:2024-2126).
A change of address is itself an agency-action trigger under 7 CFR 273.12(a)(1), independent of any shelter-cost consequence. September 2026 SNAP UAT needs a worker to be able to record a move. Today the journey-snap-shelter-cascade walkthrough models a move only as a rent increase on the Expenses tab and carries an explicit honest-scope caveat naming this gap (docs/modules/ROOT/pages/walkthroughs/journey-snap-shelter-cascade.adoc:37-49; spec header tests/e2e/specs/journey-snap-shelter-cascade.spec.ts:42-59).
Key finding — the persons side already exists (no schema change). Addresses are append-only, valid-time-versioned facts (ADR-027 §3, T2-1 #683). The authoring surface is shipped:
-
POST /v1/persons/{id}/addresses/claims→claim_address(services/canopy-persons/src/api/mod.rs:888-925) — validates the value, rejects aSystemauthor (422), enforces fact-ownership on a correction (require_fact_ownership, only whenfact_idpresent), derivesclaim_statusviaauto_accept_status(worker →accepted_verified), returns201 ClaimResponse. It does NOT verify the person belongs to any household (see the Authorization decision). -
GET /v1/persons/{id}/addresses→list_addresses(:1406-1421);DELETE …/claims/{fact_id}→close_address_claim(:945-967, unused by v1). -
Path consts
CLAIM_ADDRESS/LIST_ADDRESSES(crates/canopy-contracts-persons/src/paths.rs:84,98). Wire typesAddressClaimRequest/AddressFactValue/Address/AddressType(crates/canopy-contracts-persons/src/addresses.rs:151,120,71,42); responseClaimResponse(crates/canopy-contracts-persons/src/claims.rs:20). The legacy flataddressestable was dropped — no mutate-in-place path to avoid.
So #983 is a BFF + template gap only (ADR-001: persons owns the corpus; canopy-web is the BFF), mirroring services/canopy-web/src/api/income.rs and api/expenses.rs.
Scope
In scope:
-
New canopy-web address action module +
/actions/address/{add,edit}routes, mirroring the income/expense editors. -
Effective-dated authoring via
POST …/addresses/claims:valid_from = effective_date; add =fact_id: None; edit/move = a single-fact valid-time correction carrying the existingfact_id. Author = this worker (origin: "worker_portal",source: SelfAttestation, server-derivedaccepted_verified). -
The #632 per-program write gate plus a household-membership check (see Authorization) before every write; CSRF on the form; PRG redirect.
-
An Askama editor supporting both
AddressTypevariants (residential + mailing) via a<select>; a redacted address renders read-only (no accidental overwrite). The e2e drives the residential move; mailing covered by handler unit tests. -
The address-edit step added to
journey-snap-shelter-cascade.spec.ts+ walkthrough; the honest-scope note rewritten. -
CHANGELOG;
services.adoc; caseworker guide + worker case-detail Antora page.
Out of scope (routed to follow-ups):
-
Any canopy-persons schema/endpoint change — the surface already exists.
-
Retrofitting the household-membership check onto the existing income/expense/asset editors — they share the same missing check (Authorization decision). #983 does the right thing for the address handler; file a security follow-up issue to add the check to the income/expense/asset handlers (and/or enforce membership in
claim_*service-side). Not folded in (don’t balloon #983; git-workflow "bugs found mid-implementation → separate issue"). -
Address in the fact change-history tab —
FactResourceKind(crates/canopy-contracts-security/src/fact_history.rs:29) has onlyIncome/Asset/Expense; address events carryresource_type = "address"but neither the kind nor the web history tab handles them. v1’s editor authors the fact but the change-history tab won’t list address edits. File a follow-up issue to add anAddresskind + its history query + tab wiring. Noted so it’s an explicit deferral, not a silent gap. -
PersonsClientaddress convenience methods (crates/canopy-persons-client) — canopy-web uses its ownInternalClient; add only if a service needs them. -
Address remove/close in the UI (
DELETE …/claims/{fact_id}) — a move is a correction, not a delete; defer the explicit "remove" affordance. -
The RFI "request shelter verification → fails → remove deduction" sub-flow — that is
snap.change.unclear-information-clarification. -
The Expenses-tab rent step of the shelter-cascade journey — it stays; address ≠ shelter cost, and the benefit oracle still needs the rent delta. #983 adds an address beat.
Design
Editor home — a dedicated per-member Address section
A dedicated per-member Address section, a 1:1 mirror of the expenses section plugin (src/case_detail/sections/expenses.rs + expenses/Plugin.toml; registered in sections.rs mod-list :47-71, dispatch_fetch :282, assert_registered:405-429). It receives csrf_token+program via dispatch_fetch, iterates household members, and lists each member’s addresses — matching the "edit a household’s and/or a member’s" requirement.
Address is displayed on the Household tab today (tab_household.html:35-43), but that tab shows only the head-of-household’s single primary address (via a per-person GET /v1/persons/{id}/addresses read, case_detail.rs:2084), so per-member + mailing editing would mean reworking that read onto the /full bundle and extending TabHouseholdTemplate. The dedicated section avoids that rework and keeps case_detail.rs out of the edit set (Budgets B1).
Decisions
-
New module
services/canopy-web/src/api/address.rs(privatemod address;inapi/mod.rs, SPDX first line) holds the form structs, the typed body-builder, the parse/membership helpers, and theadd/edithandlers.NOTEAs-built deviation (B1) — render lives in the section module, notapi/address.rsThe plan first put
render_address_tab+TabAddressTemplateinapi/address.rsaspub(crate), imported by the section. As built, with the handler tests co-located,api/address.rscame to ~730 LOC — over the 500-LOC B1 route-module threshold (B1 countsservices//src/api/.rsfiles > 500). So the render half (render_address_tab, the view structs, and the row/badge helpers, plus the render tests) moved into the section moduleservices/canopy-web/src/case_detail/sections/address.rs, which is not asrc/api/.rsfile → off B1. Consequences:render_address_tabis now a *private fn local to the section (no cross-module import), somod address;is a plain privatemod(notpub(crate)).api/address.rs= handlers + forms + helpers + handler tests (~490 LOC); the section module = plugin +fetch+ render + view + render tests.case_detail.rsstill gains no handler/render fn (locked B1 set). Net B1 count unchanged (18). -
Authorization — gate program scope AND household membership (J4 / IDOR).
deny_unless_in_scope(&worker, &form.program, &form.household_id)(fact_editor.rs:21) only checks the worker’s program scope over the household; it does not check thatform.person_idbelongs toform.household_id, andclaim_addressverifies only fact-ownership-on-correction, never household membership. So a scoped worker could post an address onto an arbitraryperson_idby tampering the hidden field. Add a membership check: after the scope gate, read the household bundle (GET /v1/households/{id}/full→HouseholdFull; the render path reads it anyway) and returnerror_response(StatusCode::FORBIDDEN, …)unlessfull.members.iter().any(|m| m.person.id == person_id)(MemberFull.person: Person,batch.rs:29). This is the security control the plan adds; the identical pre-existing gap in the income/expense/asset handlers is routed to a security follow-up (Scope). -
Typed body, not
json!(leads the B3a story). Build a typedAddressClaimRequestand post::<AddressClaimRequest, ClaimResponse>— 0 newserde_json::Valueliterals. (The income/expense editors post::<Value, Value>withjson!, justified by a now-stale "canopy-web has no contracts-persons dep" comment,income.rs:91-92; canopy-web does depend oncanopy-contracts-persons,Cargo.toml:38.) Exact imports — canopy-web depends oncanopy-contracts-personsbut notcanopy-contracts-facts, so pull provenance types through the persons re-export:use std::str::FromStr; // for AddressType::from_str use canopy_contracts_persons::addresses::{AddressClaimRequest, AddressFactValue, AddressType}; use canopy_contracts_persons::batch::HouseholdFull; // membership check + render use canopy_contracts_persons::claims::ClaimResponse; // NOT top-level re-exported use canopy_contracts_persons::{Author, VerificationSource}; // re-exports (lib.rs:37-39) -
Body-builder (extract to keep the handler ≤100 LOC, B2).
worker_sub=worker.worker_id. Use the ctorAuthor::worker(worker_sub)— notAuthor::Worker { sub: worker_sub.to_owned() }:Author::Worker.subis aKeycloakSub(pub String)newtype (canopy_contracts_facts::lib.rs:68), so a bareStringis a type error;impl From<&str> for KeycloakSub(:90) lets the ctor take the&str.fn address_claim_body( worker_sub: &str, value: AddressFactValue, valid_from: NaiveDate, valid_to: Option<NaiveDate>, fact_id: Option<Uuid>, ) -> AddressClaimRequest { AddressClaimRequest { value, source: VerificationSource::SelfAttestation, author: Author::worker(worker_sub), origin: Some("worker_portal".to_owned()), valid_from, valid_to, fact_id, } } -
Handler shape — copy
income.rs:117-174, swapping the body type and adding the membership gate. ExtractorsAuthenticatedWorker+WritePermission+Extension<Arc<ServiceClients>>+Extension<ServiceTokenSource>+Form<..>, then:-
deny_unless_in_scope(&worker, &form.program, &form.household_id)? -
clients.with_service_identity(&svc_token).await -
membership check — fetch
HouseholdFull, 403 unlessperson_idis a member (Authorization) -
parse
address_type/effective_date/end_date/address_id→error_responseon any parse error; buildAddressFactValue(filter emptyline_2/county_fips→None) +address_claim_body(…) -
clients.persons.post::<AddressClaimRequest, ClaimResponse>(&format!("/v1/persons/{person_id}/addresses/claims"), &body) -
Err→tracing::error!+error_response(…)(:52);Ok→tracing::info!+redirect_to_case(…)(:39, PRG →?program=..&focus_section=address¬ice=eligibility-changed)Return type
Result<Redirect, (StatusCode, Html<String>)>.
-
-
Form structs
AddAddressForm/EditAddressForm(#[derive(Deserialize)],axum::extract::Form), mirroringAddIncomeForm:30/EditIncomeForm:50. Fields:household_id: HouseholdId,person_id: PersonId(typed newtypes — they deserialize from the form string and are used in the path/membership check),program,address_type: String,line_1,line_2: Option<String>,city,state,zip,county_fips: Option<String>,effective_date,target_section: Option<String>(="address").EditAddressFormalso carriesaddress_id(→fact_id) andend_date: Option<String>(→valid_to, the unedited carrier, mirroring the income edit’sincome_claim_body(…, Some(&form.income_id))atincome.rs:192-203). Parseaddress_typeviaAddressType::from_str,effective_date/end_dateviaNaiveDate::parse_from_str,address_idviaUuid::parse_str— each mapping errors toerror_response(nounwrap/expect; declaringaddress_typeas the enum directly would yield a generic Form-rejection 4xx, bypassing the tested error path). -
Move semantics — single-fact valid-time correction (owner-ratified).
add=fact_id: None.edit/move =fact_id: Some(address_id)= a valid-time correction: the store re-tiles the open window into[old_from, effective_date)(prior value) +[effective_date, ∞)(new value) — exactly a move (services/canopy-persons/src/store/address_versions.rs:276-323;fact_idis the stable identity that survives corrections,:401-402). Uniform with the income/expense editors; no close/DELETE. (A distinct-fact model for a genuine relocation was considered and rejected as inconsistent with every other fact edit; follow-up issue if separate lineages are ever wanted.) -
Redacted addresses render read-only.
Address.line_1isOption<String>—Nonewhen the street was crypto-shredded (addresses.rs:78,redacted == true). The editor must not let a worker overwrite a shredded street with blank: whenaddress.redacted, render the row read-only (coarse locality only + a "street redacted" note, no edit form). Adding a new address is always allowed. Only non-redacted rows get an edit form. -
Render home — deserialize the public
/fullcontract (NOT the private local view).async fn render_address_tab(clients, household_id, program, csrf_token) → String(private, incase_detail/sections/address.rs— see the as-built note) readsGET /v1/households/{id}/fullinto the publicHouseholdFull(member typeMemberFull, which carriesaddresses: Vec<Address>,batch.rs:44; the persons builder populates it,services/canopy-persons/src/store/batch.rs:63,102), iterates members, and rendersTabAddressTemplate(also in the section module). Do NOT importcase_detail.rs’s `HouseholdFullView/MemberView— they are module-private (:2240,2222) andMemberViewhas noaddressesfield; the Household tab reads addresses via a separate per-person call. The tiny name/row/badge helpers are reimplemented locally over the typed enums (ClaimStatus/Authoraccessors). Using the public contract keepscase_detail.rsout of the render path (B1) while compiling. The handler’s Authorization membership check makes its ownHouseholdFullfetch. -
Section identifiers — three distinct slugs (mirror expenses exactly).
The
focus_sectionslug must be added toALLOWED_FOCUS_SECTIONS(case_detail.rs:325) orsafe_focus_section(:353) silently falls back todetermination. Bump theassert_eq!(ALLOWED_FOCUS_SECTIONS.len(), 21)test (:4353) to 22 and fix its narrating comment (:4348-4352). This entry + assertion are the onlycase_detail.rsedits and don’t change its over-500 status (B1 count unchanged). -
Composition registration — its OWN full-width row (do not skip; do not share expenses' row). Case-detail sections are explicitly listed per jurisdiction in
rulesets/{default,georgia}/composition/case_detail.toml, one section per row atspan = 12(full width); the loader rejects a row exceeding 12 columns, so two span-12 sections cannot share a row. The current maximum iscase-detail-renewals-sectionatrow = 19(:134-136). Add aitem = "case-detail-address-section"withrow = 20, span = 12(its own new row — NOT expenses' row 13) to both files. Omitting this = the tab never renders → thedispatch_fetcharm is unreachable → the PRG lands ondeterminationand the e2egotoWorkerCaseSection('address')times out. -
Plugin.toml[data] endpointsare literal URL strings, not Rust consts.endpoints = ["/v1/persons/{person_id}/addresses"](mirrorexpenses/Plugin.toml).
Budgets
-
B1 — route modules >500 LOC (locked 18). As built:
api/address.rs= handlers + forms + helpers + handler tests (~490 LOC, under 500); the render half lives in the section module (off B1) — see the as-built note.case_detail.rsis touched only by the one-lineALLOWED_FOCUS_SECTIONSentry + its assertion (already over 500, stays counted) → B1 count unchanged (18). Verified:cargo xtask quality-budgetsreports B1 = 18 (LOCKED). -
B2 — fns >100 LOC (locked 122). Handlers ≤100 (the shared
author_address_claim~55, each handler ~30);render_address_tab~40. Verified LOCKED (actual 118). -
B3a — literal
serde_json::Valuein src (locked 745). Typed body adds 0 — but note the B3a regex matches the literal tokenserde_json::Valueeven inside comments/doc-comments: an early doc-comment mentioning it tripped +1, reworded to "untyped JSON literals". Verified LOCKED (745). -
B5 —
.unwrap_or_default()in src (locked 301). The renderOption<String>→Stringconversions are centralized through oneopthelper (a singleunwrap_or_default), and that +1 is offset in the same MR by converting a silentunwrap_or_defaultinauth/mod.rs(token-error body read) into a logged fallback (coding-conventions: no silent failure). Verified LOCKED (301, net 0 — offset, lock not raised, per the "offset, don’t raise" directive).
Steps
Step 1: api/address.rs — forms, typed body-builder, handlers (with membership check), render
Files: services/canopy-web/src/api/address.rs (new), services/canopy-web/src/api/mod.rs (pub(crate) mod address;)
-
SPDX first line; module doc mirroring
income.rs:1-15. -
Imports per Design (incl.
use std::str::FromStr;,HouseholdFull). -
AddAddressForm/EditAddressForm(typedHouseholdId/PersonId) +address_claim_body(typed,Author::worker, parse-with-error_response). -
add_address(fact_id: None) andedit_address(fact_id: Some(parse address_id),end_date→valid_to) following theincome.rs:117-174flow plus the household-membership 403 gate (Authorization). -
pub(crate) render_address_tab— deserialize/fullinto the publicHouseholdFull, iterate members, skip/read-only redacted rows, build rows, renderTabAddressTemplate. -
pub(crate) mod address;beside the siblingmod income;decls inapi/mod.rs:28-32.
Step 2: Routes + Address section plugin + composition registration
Files: services/canopy-web/src/api/mod.rs; services/canopy-web/src/case_detail/sections/address.rs (new) + …/sections/address/Plugin.toml (new); services/canopy-web/src/case_detail/sections.rs; services/canopy-web/src/api/case_detail.rs (allowlist + assertion only); rulesets/default/composition/case_detail.toml; rulesets/georgia/composition/case_detail.toml
-
Register
.route("/actions/address/add", post(address::add_address))+.route("/actions/address/edit", post(address::edit_address))next to income/expense (api/mod.rs:193-204). -
Section plugin mirroring
expenses.rs:#[canopy_plugin(slug = "case-detail-address", manifest = "src/case_detail/sections/address/Plugin.toml")],DISPLAY_NAME = "Address",pub async fn fetch(…)→use crate::api::address::render_address_tab;→render_address_tab(…).await→finalize_section_html(…). -
Plugin.tomlmirroringexpenses/Plugin.toml:[plugin.exports] case_sections = ["case-detail-address-section"];[case_sections.case-detail-address-section];[data] source = "canopy-persons" endpoints = ["/v1/persons/{person_id}/addresses"];[permissions];[i18n]. -
sections.rs: mod-list (:47-71), adispatch_fetcharm keyed on the section id"case-detail-address-section"(:282), andassert_registered(:405-429). -
Composition: add
item = "case-detail-address-section"withrow = 20, span = 12to bothrulesets/default/composition/case_detail.tomlandrulesets/georgia/composition/case_detail.toml. -
case_detail.rs: add"address"toALLOWED_FOCUS_SECTIONS(:325), bump the length assertion 21→22 (:4353), fix its comment (:4348-4352).
Step 3: Editor UI (Askama form)
Files: services/canopy-web/templates/cases/tab_address.html (new)
-
Mirror
tab_expenses.html:54-127: native<details>/<summary>,<form action="/actions/address/{add|edit}" method="post">(plain POST → 303 PRG). -
CSRF: hidden
_csrf={{ csrf_token }}(threadedSectionContext.csrf_token→ sectionfetch→render_address_tab→ template). -
Hidden
household_id/program/person_id/target_section(=address). -
Render
AddressFactValuefields:address_type<select>(residential/mailing),line_1(required),line_2,city,state(2-char),zip,county_fips,effective_date. Edit carries hiddenaddress_id+end_date. -
Redacted rows render read-only (coarse locality + "street redacted" note, no edit form).
-
Each row carries
data-address-row,data-address-id="{fact_id}", anddata-address-person-id="{person_id}"so the e2e can target a specific person’s edit form unambiguously (multiple add/edit forms exist per member).
Step 4: Handler unit tests
Files: services/canopy-web/src/api/address.rs (co-located #[cfg(test)], mirror fact_editor.rs:68-136)
-
address_claim_bodybuilds the expectedAddressClaimRequest— assert once peraddress_typevariant (residential and mailing). -
Scope-deny: worker outside the form’s program →
deny_unless_in_scope→ 403. -
Membership-deny: a
person_idnot in the household’s/fullmembers → 403 (the Authorization control). -
PRG: success →
Redirectto…focus_section=address¬ice=eligibility-changed; error → theerror_response422 fragment (both args escaped). -
No parsing newtype is introduced → proptest N/A (note it).
Step 5: e2e — seed-then-edit address in the shelter-cascade journey
Files: tests/e2e/lib/helpers.ts (changeAddressViaUi); tests/e2e/lib/given/snap.ts + given/index.ts (seedAddress given-lib add); tests/e2e/specs/journey-snap-shelter-cascade.spec.ts
-
The
SnapCaseBuilderseeds no address (given/snap.tsauthors only income + expense + membership), so editing requires a baseline first. Add aseedAddress(householdId, personId, {…})given-lib fn (aPOST …/addresses/claimsadd,fact_id: None, mirroring the income/expense given writers atsnap.ts:289,343), OR have STEP 3a add-then-edit — either way the journey exercises the edit/correction path (thefact_idflow), not just add. -
changeAddressViaUi(page, { householdId, program, personId, …address }, onBeforeSubmit?)mirroringaddExpenseViaUi(helpers.ts:646-680):gotoWorkerCaseSection(page, { householdId, program, section: 'address' })→ open the person’s edit<details>via[data-address-person-id="${personId}"](not the ambiguousinput[name=person_id]) → fill → PRG wait (Promise.all([ page.waitForURL(/focus_section=address/), submit.click() ])). CarryonBeforeSubmit(page)for the walkthroughshot(…). -
Insert STEP 3a between STEP 3 (baseline determination) and STEP 4 (rent step,
spec.ts:136):seedAddress(baseline) thenchangeAddressViaUi(the move), capturestep-03a-address-changed.png. (3asorts after 3, before 4 — no renumber of STEP 4–6.) The rent step stays. Oracle = read-back of the entered address (relational), perspec.ts:22-40. -
Rewrite the spec-header honest-scope bullet (
spec.ts:44-47) and the stale STEP 4 inline comment (spec.ts:141, "There is no in-UI address editor").
Step 6: Inventory + docs
Files: docs/modules/ROOT/pages/walkthroughs/journey-snap-shelter-cascade.adoc, …/walkthroughs/index.adoc, compliance/scenario-inventory/snap.toml, CHANGELOG.adoc, docs/modules/ROOT/pages/services.adoc, worker case-detail Antora page + docs/modules/ROOT/pages/guide/caseworker.adoc
-
Walkthrough: rewrite the
[NOTE]honest-scope block (:37-49) — drop "no household-address editor yet (#983)"; state the move is now recorded through the editor, the rent increase remaining the shelter-cost driver. Keep the deduction-recompute + derived-monotonic-oracle nuances. Insert a03astep row (between 03 and 04) with; drop "there is no in-UI address editor" from row 04’s Action cell (
:101). Everyimage:needs a committed PNG. -
walkthroughs/index.adoc: drop the "separate gap, #983" aside if present. -
snap.toml: refresh the comment naming #983 as the missing editor (~:729-743) — the row staysCovered [Journey]; keep the scenario idsnap.change.address-change-shelter-cascade(:719) and thedescribebinding (:746) verbatim. Nowalkthrough_blocked_by, no new binding. -
CHANGELOG.adoc== Unreleased / === Added: the worker address editor (routes + typed/claimsauthoring + membership gate), the journey step, the walkthrough flip. -
services.adoc: the new canopy-web capability. No new persons endpoint. -
Worker case-detail page + caseworker guide: the new action.
Follow-ups (filed)
-
Security (#996, filed): household-membership IDOR check on the existing income/expense/asset editors (same gap this plan fixes for address) — and/or membership enforcement in canopy-persons
claim_*. -
Feature (#891, pre-existing): address in the fact change-history tab (
FactResourceKind::Address+ history query + web tab) — already tracked; related to #983. -
Deferred affordance (#997, filed): in-UI address remove/close (
DELETE …/claims/{fact_id}).
Files Touched
| File | Change |
|---|---|
|
Forms (typed IDs), |
|
|
|
Address section plugin + |
|
B5 offset: silent |
|
mod-list + |
|
|
|
|
|
Editor form: disclosure, CSRF, PRG, residential/mailing select, redacted read-only, |
|
|
|
|
|
STEP 3a seed+edit + |
|
Honest-scope rewrite + |
|
Comment refresh (bindings unchanged; no |
|
Unreleased entry + capability + guide (Step 6) |
|
Committed screenshot (Steps 5, 6) |
|
Nav-link this plan under the epic-&61 entries (commit-time) |
Verification
| Surface | Command | Asserts |
|---|---|---|
Rust unit (web) |
|
|
Lint/fmt/budgets |
|
clean; B1 count unchanged; B2 ≤ lock; B3a 0 new |
ADR-011 literals |
|
clean (no new hardcoded Decimal/ |
Journey e2e |
|
STEP 3a seeds then edits an address (the correction/ |
API-docs drift |
|
no drift (BFF routes are not OpenAPI; no persons endpoint added) |
Pairing gate |
|
row stays |
Documentation Updates
-
Antora —
services.adoc(canopy-web address-editor capability);api/canopy-web.adoc(the/actions/address/*action rows + IDOR-guard note);guide/caseworker.adoc(new Address Tab section) -
CHANGELOG.adoc— entry under== Unreleased -
Walkthrough —
journey-snap-shelter-cascade.adochonest-scope rewrite +03arow;walkthroughs/index.adocaside drop -
Scenario inventory —
snap.tomlcomment refresh (nowalkthrough_blocked_by, bindings unchanged) -
nav.adoc— plan already nav-linked under epic &61 (landed with the plan MR !778) -
Follow-up issues filed/linked (security membership check → #996; address change-history → #891 pre-existing; UI remove affordance → #997; all related to #983)
-
Epic &61 updated; #983 closing comment (impl SHA + bare merge SHA, changed files, checked-off criteria, deferrals + their follow-up issues)