Plan: Worker portal household/person address editor (#983)

On this page
NOTE

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 api/address.rs: forms, typed body-builder, add/edit handlers with a household-membership check (render moved to the section module — see the as-built note in Decisions)

Done (2026-07-07)

2

/actions/address/{add,edit} routes + the per-member Address section plugin, its composition-TOML row in both rulesets, and the address focus slug

Done (2026-07-07)

3

Askama tab_address.html editor (disclosure, CSRF, PRG, residential/mailing select, redacted-row handling, data-address-* hooks)

Done (2026-07-07)

4

Co-located #[cfg(test)] handler + render tests (typed body per address type; membership predicate + 403 fragment; empty-optional normalize; redacted read-only)

Done (2026-07-07)

5

e2e changeAddressViaUi + a journey STEP 3a that seeds then edits an address in journey-snap-shelter-cascade.spec.ts + step-03a screenshot

Done (2026-07-07)

6

Inventory + docs: walkthrough honest-scope flip + 03a row; CHANGELOG; services.adoc; api/canopy-web.adoc; caseworker guide; snap.toml comment

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/claimsclaim_address (services/canopy-persons/src/api/mod.rs:888-925) — validates the value, rejects a System author (422), enforces fact-ownership on a correction (require_fact_ownership, only when fact_id present), derives claim_status via auto_accept_status (worker → accepted_verified), returns 201 ClaimResponse. It does NOT verify the person belongs to any household (see the Authorization decision).

  • GET /v1/persons/{id}/addresseslist_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 types AddressClaimRequest/AddressFactValue/Address/AddressType (crates/canopy-contracts-persons/src/addresses.rs:151,120,71,42); response ClaimResponse (crates/canopy-contracts-persons/src/claims.rs:20). The legacy flat addresses table 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 existing fact_id. Author = this worker (origin: "worker_portal", source: SelfAttestation, server-derived accepted_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 AddressType variants (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 tabFactResourceKind (crates/canopy-contracts-security/src/fact_history.rs:29) has only Income/Asset/Expense; address events carry resource_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 an Address kind + its history query + tab wiring. Noted so it’s an explicit deferral, not a silent gap.

  • PersonsClient address convenience methods (crates/canopy-persons-client) — canopy-web uses its own InternalClient; 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.

NOTE
Considered alternative — rejected: in-place on the Household tab

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 (private mod address; in api/mod.rs, SPDX first line) holds the form structs, the typed body-builder, the parse/membership helpers, and the add/edit handlers.

    NOTE
    As-built deviation (B1) — render lives in the section module, not api/address.rs

    The plan first put render_address_tab + TabAddressTemplate in api/address.rs as pub(crate), imported by the section. As built, with the handler tests co-located, api/address.rs came to ~730 LOC — over the 500-LOC B1 route-module threshold (B1 counts services//src/api/.rs files > 500). So the render half (render_address_tab, the view structs, and the row/badge helpers, plus the render tests) moved into the section module services/canopy-web/src/case_detail/sections/address.rs, which is not a src/api/.rs file → off B1. Consequences: render_address_tab is now a *private fn local to the section (no cross-module import), so mod address; is a plain private mod (not pub(crate)). api/address.rs = handlers + forms + helpers + handler tests (~490 LOC); the section module = plugin + fetch + render + view + render tests. case_detail.rs still 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 that form.person_id belongs to form.household_id, and claim_address verifies only fact-ownership-on-correction, never household membership. So a scoped worker could post an address onto an arbitrary person_id by tampering the hidden field. Add a membership check: after the scope gate, read the household bundle (GET /v1/households/{id}/fullHouseholdFull; the render path reads it anyway) and return error_response(StatusCode::FORBIDDEN, …) unless full.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 typed AddressClaimRequest and post ::<AddressClaimRequest, ClaimResponse>0 new serde_json::Value literals. (The income/expense editors post ::<Value, Value> with json!, justified by a now-stale "canopy-web has no contracts-persons dep" comment, income.rs:91-92; canopy-web does depend on canopy-contracts-persons, Cargo.toml:38.) Exact imports — canopy-web depends on canopy-contracts-persons but not canopy-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 ctor Author::worker(worker_sub)not Author::Worker { sub: worker_sub.to_owned() }: Author::Worker.sub is a KeycloakSub(pub String) newtype (canopy_contracts_facts::lib.rs:68), so a bare String is 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. Extractors AuthenticatedWorker + WritePermission + Extension<Arc<ServiceClients>> + Extension<ServiceTokenSource> + Form<..>, then:

    1. deny_unless_in_scope(&worker, &form.program, &form.household_id)?

    2. clients.with_service_identity(&svc_token).await

    3. membership check — fetch HouseholdFull, 403 unless person_id is a member (Authorization)

    4. parse address_type/effective_date/end_date/address_iderror_response on any parse error; build AddressFactValue (filter empty line_2/county_fipsNone) + address_claim_body(…​)

    5. clients.persons.post::<AddressClaimRequest, ClaimResponse>(&format!("/v1/persons/{person_id}/addresses/claims"), &body)

    6. Errtracing::error! + error_response(…) (:52); Oktracing::info! + redirect_to_case(…) (:39, PRG → ?program=..&focus_section=address&notice=eligibility-changed)

      Return type Result<Redirect, (StatusCode, Html<String>)>.

  • Form structs AddAddressForm / EditAddressForm (#[derive(Deserialize)], axum::extract::Form), mirroring AddIncomeForm: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"). EditAddressForm also carries address_id (→ fact_id) and end_date: Option<String> (→ valid_to, the unedited carrier, mirroring the income edit’s income_claim_body(…​, Some(&form.income_id)) at income.rs:192-203). Parse address_type via AddressType::from_str, effective_date/end_date via NaiveDate::parse_from_str, address_id via Uuid::parse_str — each mapping errors to error_response (no unwrap/expect; declaring address_type as 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_id is 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_1 is Option<String>None when the street was crypto-shredded (addresses.rs:78, redacted == true). The editor must not let a worker overwrite a shredded street with blank: when address.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 /full contract (NOT the private local view). async fn render_address_tab(clients, household_id, program, csrf_token) → String (private, in case_detail/sections/address.rs — see the as-built note) reads GET /v1/households/{id}/full into the public HouseholdFull (member type MemberFull, which carries addresses: Vec<Address>, batch.rs:44; the persons builder populates it, services/canopy-persons/src/store/batch.rs:63,102), iterates members, and renders TabAddressTemplate (also in the section module). Do NOT import case_detail.rs’s `HouseholdFullView/MemberView — they are module-private (:2240,2222) and MemberView has no addresses field; 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/Author accessors). Using the public contract keeps case_detail.rs out of the render path (B1) while compiling. The handler’s Authorization membership check makes its own HouseholdFull fetch.

  • Section identifiers — three distinct slugs (mirror expenses exactly).

    Identifier Address value Where it appears

    Plugin slug

    case-detail-address

    #[canopy_plugin(slug = …)] on the section struct

    Exported section id

    case-detail-address-section

    Plugin.toml [plugin.exports] case_sections, the dispatch_fetch arm (key on this, not the plugin slug, or it renders unknown_section), and the composition-TOML item

    focus_section slug

    address

    the PRG ?focus_section=, the form’s hidden target_section, the e2e gotoWorkerCaseSection({section})

    The focus_section slug must be added to ALLOWED_FOCUS_SECTIONS (case_detail.rs:325) or safe_focus_section (:353) silently falls back to determination. Bump the assert_eq!(ALLOWED_FOCUS_SECTIONS.len(), 21) test (:4353) to 22 and fix its narrating comment (:4348-4352). This entry + assertion are the only case_detail.rs edits 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 at span = 12 (full width); the loader rejects a row exceeding 12 columns, so two span-12 sections cannot share a row. The current maximum is case-detail-renewals-section at row = 19 (:134-136). Add a item = "case-detail-address-section" with row = 20, span = 12 (its own new row — NOT expenses' row 13) to both files. Omitting this = the tab never renders → the dispatch_fetch arm is unreachable → the PRG lands on determination and the e2e gotoWorkerCaseSection('address') times out.

  • Plugin.toml [data] endpoints are literal URL strings, not Rust consts. endpoints = ["/v1/persons/{person_id}/addresses"] (mirror expenses/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.rs is touched only by the one-line ALLOWED_FOCUS_SECTIONS entry + its assertion (already over 500, stays counted) → B1 count unchanged (18). Verified: cargo xtask quality-budgets reports 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::Value in src (locked 745). Typed body adds 0 — but note the B3a regex matches the literal token serde_json::Value even 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 render Option<String>String conversions are centralized through one opt helper (a single unwrap_or_default), and that +1 is offset in the same MR by converting a silent unwrap_or_default in auth/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 (typed HouseholdId/PersonId) + address_claim_body (typed, Author::worker, parse-with-error_response).

  • add_address (fact_id: None) and edit_address (fact_id: Some(parse address_id), end_datevalid_to) following the income.rs:117-174 flow plus the household-membership 403 gate (Authorization).

  • pub(crate) render_address_tab — deserialize /full into the public HouseholdFull, iterate members, skip/read-only redacted rows, build rows, render TabAddressTemplate.

  • pub(crate) mod address; beside the sibling mod income; decls in api/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(…​).awaitfinalize_section_html(…​).

  • Plugin.toml mirroring expenses/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), a dispatch_fetch arm keyed on the section id "case-detail-address-section" (:282), and assert_registered (:405-429).

  • Composition: add item = "case-detail-address-section" with row = 20, span = 12 to both rulesets/default/composition/case_detail.toml and rulesets/georgia/composition/case_detail.toml.

  • case_detail.rs: add "address" to ALLOWED_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 }} (threaded SectionContext.csrf_token → section fetchrender_address_tab → template).

  • Hidden household_id/program/person_id/target_section (=address).

  • Render AddressFactValue fields: address_type <select> (residential/mailing), line_1 (required), line_2, city, state (2-char), zip, county_fips, effective_date. Edit carries hidden address_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}", and data-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_body builds the expected AddressClaimRequest — assert once per address_type variant (residential and mailing).

  • Scope-deny: worker outside the form’s program → deny_unless_in_scope → 403.

  • Membership-deny: a person_id not in the household’s /full members → 403 (the Authorization control).

  • PRG: success → Redirect to …focus_section=address&notice=eligibility-changed; error → the error_response 422 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 SnapCaseBuilder seeds no address (given/snap.ts authors only income + expense + membership), so editing requires a baseline first. Add a seedAddress(householdId, personId, {…}) given-lib fn (a POST …/addresses/claims add, fact_id: None, mirroring the income/expense given writers at snap.ts:289,343), OR have STEP 3a add-then-edit — either way the journey exercises the edit/correction path (the fact_id flow), not just add.

  • changeAddressViaUi(page, { householdId, program, personId, …​address }, onBeforeSubmit?) mirroring addExpenseViaUi (helpers.ts:646-680): gotoWorkerCaseSection(page, { householdId, program, section: 'address' }) → open the person’s edit <details> via [data-address-person-id="${personId}"] (not the ambiguous input[name=person_id]) → fill → PRG wait (Promise.all([ page.waitForURL(/focus_section=address/), submit.click() ])). Carry onBeforeSubmit(page) for the walkthrough shot(…​).

  • Insert STEP 3a between STEP 3 (baseline determination) and STEP 4 (rent step, spec.ts:136): seedAddress (baseline) then changeAddressViaUi (the move), capture step-03a-address-changed.png. (3a sorts after 3, before 4 — no renumber of STEP 4–6.) The rent step stays. Oracle = read-back of the entered address (relational), per spec.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 a 03a step row (between 03 and 04) with …; drop "there is no in-UI address editor" from row 04’s Action cell (:101). Every image: 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 stays Covered [Journey]; keep the scenario id snap.change.address-change-shelter-cascade (:719) and the describe binding (:746) verbatim. No walkthrough_blocked_by, no new binding.

  • CHANGELOG.adoc == Unreleased / === Added: the worker address editor (routes + typed /claims authoring + 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

services/canopy-web/src/api/address.rs (new)

Forms (typed IDs), address_claim_body, add/edit handlers + membership gate (is_household_member), handler #[cfg(test)] (Steps 1, 4)

services/canopy-web/src/api/mod.rs

mod address; (private — render is not imported from here) + /actions/address/{add,edit} (Steps 1, 2)

services/canopy-web/src/case_detail/sections/address.rs + …​/address/Plugin.toml (new)

Address section plugin + fetch + render_address_tab + TabAddressTemplate + row/badge helpers + render #[cfg(test)] (moved here from api/address.rs for B1) (Steps 1, 2, 4)

services/canopy-web/src/auth/mod.rs

B5 offset: silent unwrap_or_default on the token-error body read → logged fallback

services/canopy-web/src/case_detail/sections.rs

mod-list + dispatch_fetch (section-id key) + assert_registered (Step 2)

rulesets/default/composition/case_detail.toml, rulesets/georgia/composition/case_detail.toml

row 20 span 12 composing the Address section (Step 2)

services/canopy-web/src/api/case_detail.rs

ALLOWED_FOCUS_SECTIONS entry + assertion/comment only (render stays out) (Step 2)

services/canopy-web/templates/cases/tab_address.html (new)

Editor form: disclosure, CSRF, PRG, residential/mailing select, redacted read-only, data-address-* (Step 3)

tests/e2e/lib/helpers.ts

changeAddressViaUi (Step 5)

tests/e2e/lib/given/snap.ts, given/index.ts

seedAddress baseline given-lib add (Step 5)

tests/e2e/specs/journey-snap-shelter-cascade.spec.ts

STEP 3a seed+edit + step-03a screenshot + header + stale :141 comment flip (Step 5)

docs/modules/ROOT/pages/walkthroughs/journey-snap-shelter-cascade.adoc, …​/index.adoc

Honest-scope rewrite + 03a row (Step 6)

compliance/scenario-inventory/snap.toml

Comment refresh (bindings unchanged; no walkthrough_blocked_by) (Step 6)

CHANGELOG.adoc, docs/modules/ROOT/pages/services.adoc, worker case-detail page, …​/guide/caseworker.adoc

Unreleased entry + capability + guide (Step 6)

docs/modules/ROOT/assets/images/walkthroughs/journey-snap-shelter-cascade/step-03a-address-changed.png (new)

Committed screenshot (Steps 5, 6)

docs/modules/ROOT/nav.adoc

Nav-link this plan under the epic-&61 entries (commit-time)

Verification

Surface Command Asserts

Rust unit (web)

cargo nextest run -p canopy-web

address_claim_body (both types); scope-deny → 403; non-member person_id → 403; PRG (focus_section=address); error-fragment escape; ALLOWED_FOCUS_SECTIONS.len() assertion updated

Lint/fmt/budgets

cargo clippy --all-targets + --profile test; cargo fmt --check --all; cargo xtask quality-budgets

clean; B1 count unchanged; B2 ≤ lock; B3a 0 new

ADR-011 literals

cargo xtask policy audit-literals

clean (no new hardcoded Decimal/dec! literals)

Journey e2e

cargo xtask e2e --devstack-profile full — specs/journey-snap-shelter-cascade.spec.ts --project journey

STEP 3a seeds then edits an address (the correction/fact_id path); the Address section actually renders (composition row present); read-back reflects the entered address; benefit still rises after the rent step; step-03a PNG captured

API-docs drift

cargo xtask api-docs (devstack up)

no drift (BFF routes are not OpenAPI; no persons endpoint added)

Pairing gate

cargo xtask scenarios audit

row stays Covered [Journey]; both bindings intact; no OrphanSpec/MissingWalkthrough

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.adoc honest-scope rewrite + 03a row; walkthroughs/index.adoc aside drop

  • Scenario inventory — snap.toml comment refresh (no walkthrough_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)

Edit this page · default