Plan: Worker intake + program independence (SNAP + TANF)

On this page

Status

MR Description Status

1

feat(applications+common): DocumentId newtype + SectionName + 12 SectionPayload structs + paths constants — contracts crate only. Adds DocumentId via define_id! macro in crates/canopy-common/src/id.rs; all 12 SectionPayloads spelled out per §4 (workflow-record concept per ADR-001/ADR-002 — PersonId references, never SSN/DOB/legal name). Plan 3 unblocker; ships FIRST.

Done (2026-05-28) — MR !390 7297307d

2

feat(applications+verification): application_sections table + status checks + section CRUD endpoints + canopy-verification application_id filter + applications.status container recompute — two migrations + handler with integrity guards + backend section-completeness gate on COMPLETE_DATA_COLLECTION + applications.status container recompute (wrapped around application_programs.status writes); canopy-verification API extension; service-token RBAC.

Done (2026-05-28) — MR !391 8971f5d0

3

feat(auth+web+renewals): Claims.primary_programs + Keycloak mapper + fixtures + MyQueue rewire + ListParams plural + drive-by status filter + canopy-renewals program-parameterized due endpoint — typed primary_programs claim with fail-closed parse helper; new Keycloak oidc-usermodel-attribute-mapper; 2 fixture users; ListParams plural fields; MyQueue rewire; canopy-renewals /v1/renewals/snap/due/v1/renewals/{program}/due.

Done (2026-05-28) — this MR

4a

feat(web): new per-program POST /applications/{id}/run-determination + old case_detail.rs:2516 removal + 2 call-site templates + plumb application_id through case-detail — splits out the run_determination move + old handler removal + #579 selector cleanup from the (large) intake-page UI work tracked in MR4b. _top_bar_actions.html and tab_determination.html retargeted to the new per-application URL; SectionContext.application_id added; case-detail handler resolves the household’s most-recent application once.

Done (2026-05-28) — this MR

4b

feat(web): intake page UI + 12 typed section partials + step 7/9 wiring + Playwright intake.spec.ts — new GET /applications/{id}/intake/{program} Askama handler + intake.html shell + shared _intake_section_form.html parameterised over SectionName (9 SNAP / 10 TANF) + section save proxy + complete-data-collection proxy + intake CSS (step bar + accordion + semantic banner palette) + 9 unit tests + 6-case Playwright intake.spec.ts. process.html retarget folded into the existing MR4a tab_determination.html + _top_bar_actions.html retargets (process.html’s Approve button never called the legacy run_determination handler, so no retarget needed there).

Done (2026-05-28) — this MR

5a

feat(security): audit_events.household_id migration + AuditEvent contract field + ParsedAuditEvent dedicated extractor + sink INSERT binding + ADR-025 ledger entry — forward migration adds household_id UUID to audit_events and audit_events_archive + partial index; ParsedAuditEvent (event_parsing.rs:12) gains dedicated household_id field + dedicated extractor (not via resource_id absorption); INSERT binding update at store/mod.rs:81 (with non-UUID-safe parsing fallback to NULL — hash chain hashes payload + timestamp per ADR-014, unaffected); list_audit_events + list_events handler + utoipa decorator accept the household_id filter; canopy-seed model::AuditEvent + sql.rs INSERT updated; cargo xtask demo verify adds the audit_events.household_id → households.id (nullable) entry. 4 new unit tests on ParsedAuditEvent extractor + 2 new integration tests on the /v1/security/events?household_id= filter.

Done (2026-05-28) — this MR

5b

feat(security+web): ChainVerificationResponse contract + case-detail Audit section + Plugin.toml fix — new ChainVerificationResponse struct in crates/canopy-contracts-security/src/chain.rs (uses skip_serializing_if so the on-wire bytes stay byte-identical to the prior ad-hoc emission); verify_chain handler retypes to Json<ChainVerificationResponse> + utoipa schema update; canopy-test-lib client retypes; canopy-security hash_chain_verification_endpoint test consumes the typed shape. New audit.rs::fetch (matching dispatch_fetch signature) consumes GET /v1/security/events?household_id=&limit=50 (MR5a’s filter) + GET /v1/security/verify-chain in parallel; new _audit.html renders the events table + chain pill; dispatcher entry replaces the stub. Plugin.toml endpoint drift fix (/v1/audit/events/v1/security/events + /v1/security/verify-chain). 7 unit tests on audit-row formatting + URL building + ChainVerificationResponse round-trip; insta snapshot regen for typed shape. Partially closes #562.

Done (2026-05-28) — this MR

6

chore(seed+web): phase9 multi-program intake demo + partial-demo Playwright passtools/canopy-seed::demo::generate::phase9_multi_program_intake_demo mints ONE additional applications row (programs_requested = ['snap','tanf'], status='submitted') on the Maria Lopez household plus TWO application_programs rows + FOUR application_sections rows (Identity + HouseholdComposition × SNAP × TANF); section payloads pre-validated against canopy_contracts_applications::sections::payloads::*Payload + validator::Validate in unit test. tools/canopy-seed/src/model.rs gains ApplicationSection struct + SeedData.application_sections; sql.rs adds INSERT emission + CASCADE truncate join. New tests/e2e/auth/setup.ts entries for jane.snap-worker + jane.tanf-worker storage states; new Playwright projects snap-worker + tanf-worker; new intake-partial-demo.spec.ts (3 cases × 2 projects = 6 tests) verifies per-program label + section count + cross-program isolation + seeded Not-started pills.

Done (2026-05-28) — this MR

Sister plans: ELE 1-year flag expansion (Plan 2 — ele-1-year-flag-extension.adoc, forthcoming) and applicant intake + verification (Plan 3 — applicant-intake-and-verification.adoc, forthcoming).
Meta-plan handoff: ~/.claude/projects/-home-bitskrieg-code-canopy/memory/project_demo_video_3plan_handoff.md (durable memory).
Branch: feat/worker-intake-program-independence (epic) with per-MR feature branches.
Labels: priority::high, program::snap, program::tanf, service::applications, service::web, service::security, service::verification, service::shared-crates, service::devstack, type::feature, workflow::ready.

Context

Two pressures drive this plan:

  1. The recorded 10-minute demo video (deadline ~2026-06-15) must show SNAP and TANF worked separately by different workers without each other’s data leaking. User’s headline (2026-05-27): "The big thing you must display is that SNAP and TANF can be worked separately and they do not impact each other. This is the number one reason for being behind in work right now."

  2. canopy-web has no real intake surface today. /applications/{id}/process (services/canopy-web/src/api/applications.rs:55-230) is a post-determination Approve/Deny page. There is no page where a worker walks an applicant’s submitted application through data collection. The demo flow needs it; the team needs it; the partial-demo path (Plan 1 alone) needs it.

Plan 1 owns the worker-facing intake surface, the per-program independence affordances, the storage backing intake sections, and the case-detail Audit section. It is independently shippable: it lands the intake page, per-program work queues, and audit section against the canopy-seed demo profile (extended with multi-program personas per MR6). A partial demo (worker only, no applicant round-trip, no ELE) is recordable from this plan alone.

This plan does NOT own the applicant portal (Plan 3) or the ELE 1-year flag expansion (Plan 2).

This plan replaces the previously-archived combined plan snap-tanf-ele-demo-video.adoc, which was split into three plans (Plan 1 / Plan 2 / Plan 3) following an external reviewer pass that found 20+ findings against the single mega-plan. The split was directed by the user 2026-05-27.

Pre-commit Q1-Q8

Per .claude/docs/delivery-protocol.md + feedback_precommit_questions. Per-MR Q1-Q8 answers go to stdout for user review (per feedback_no_q1q8_in_commit); not pasted into commit messages or this plan body.

Locked decisions

Decision Choice

Intake page surface

New GET /applications/{id}/intake/{program} route in canopy-web — program in the URL path so the page is per-program scoped. One-off Askama template mirroring the existing /applications/{id}/process direct-handler pattern (services/canopy-web/src/api/mod.rs:60-63). Composition runtime (services/canopy-web/src/api/composition.rs:79-87 lists worker_dashboard, supervisor_dashboard, analyst_dashboard, case_detail, sign_in) is NOT involved — intake is not a composition surface; no #[canopy_plugin] registration is required. Handler validates program ∈ application.programs_requested and returns 404 otherwise.

NOTE: [Erratum 2026-05-28 — supersedes every reference to a session.primary_programs access gate in this plan, incl. the Per-worker visibility cell, the ASCII flow, and the RBAC/Q sections.] The originally-specified additional conjunct program ∈ session.primary_programs (a per-worker access gate → 403/404) was not implemented. The four intake/determination handlers gate only on application-membership + role; session.primary_programs drives only MyQueue display filtering, never an access decision. The per-worker program-scope gate is tracked in #632: L1 = a worker_in_program_scope helper on the 4 canopy-web handlers → 403; L2 = upstream actor-claim enforcement in canopy-applications, which folds into the ADR-019 / #424 service-identity work because clients.rs forwards only canopy-web’s own service token (no X-Canopy-Actor), so the worker’s program claim never reaches canopy-applications today.

Intake-section storage

New application_sections table in canopy-applications, keyed by (application_id, program, section_name) with soft-delete via active BOOLEAN.

Intake-section metadata

SectionName enum + per-section SectionPayload typed structs in crates/canopy-contracts-applications. NO new plugin manifest key (composition runtime doesn’t dispatch to intake surfaces).

Data-model boundary

Shared: ONE applications row per applicant submission (programs_requested: TEXT[], e.g. ['snap','tanf'] matches the actual schema at migrations/20260401000000_create_applications_tables.sql:9).

Per-program: ONE application_programs row per program in the request (state machine lives here, per the user-locked decision).

Per-program: application_sections keyed by (application_id, program, section_name).

Per-program: documents (canopy-applications scopes by (application_id, program); TANF worker can’t see SNAP’s docs).

Per-worker visibility: MyQueue + the intake URL surface scope to session.primary_programs so a SNAP worker only sees SNAP’s per-program state in their queue (display scoping). Per-program data separation is enforced by the per-program tables (application_programs / application_sections / documents), NOT by separate application rows. NOTE: the per-worker access gate — blocking a SNAP worker who hand-navigates to a TANF intake/section/determination URL — was NOT implemented; only display scoping shipped. See the Erratum on the Intake-page-surface row above and #632.

Per-program work-queue

Add primary_programs: Vec<String> to Claims (typed) AND SessionData. Populated from a NEW Keycloak oidc-usermodel-attribute-mapper. fetch_items(clients, session) filters by session.primary_programs.

Keycloak fixtures

Add two users to devstack/keycloak/canopy-realm.json: jane.snap-worker (attribute primary_programs=["snap"]) and jane.tanf-worker (attribute primary_programs=["tanf"]). Existing users keep no attribute → claim absent → see all programs (back-compat-but-pre-1.0).

Invalid claim policy

Fail-closed: if the primary_programs claim is present but contains any unparseable slug, login returns 401 with "malformed primary_programs claim, contact admin". Parse failure of a security attribute is treated as security failure.

Determination trigger

NEW route: POST /applications/{id}/run-determination?program={p} taking application_id from path AND program from query. Replaces the existing case_detail.rs:2516-2614::run_determination handler entirely (pre-1.0, no back-compat). Old handler’s "first-app-by-household" selector (literally a #579 TODO comment in code) is removed. All four call sites (intake, /process, tab_determination.html:14, _top_bar_actions.html:46) update to the new route in MR4.

State machine

Per-program on application_programs.status. Existing record_determination at store/mod.rs:362 already writes here. Plan 1 adds: Step 7 "Complete Data Collection" sets application_programs.status='data_collected' (one program at a time). Step 9 reuses existing record_determination flow → application_programs.status='determined'. Container-status recompute: every per-program state transition (Step 7 complete; Step 9 record_determination) runs an explicit recompute pass on applications.status in the same transaction: submittedprocessing (when ANY program transitions out of pending) → determined (when ALL active program rows are terminal: determined/approved/denied/withdrawn). MR2’s complete_data_collection handler does the recompute; MR4 wraps record_determination with the same recompute. MyQueue filters applications.status IN ('submitted','processing') so terminal cases leave the queue. (MR2 lock: terminal aggregate is determined, matching the applications.status CHECK in 20260601000001_extend_applications_status.sql — the closed shorthand in earlier drafts was inconsistent with the CHECK vocabulary.)

Verification gate scope

Per-application, NOT per-(application, program). The verifications schema at services/canopy-verification/migrations/20260526001500_create_verification_tables.sql:23-24 has application_id UUID + household_id UUID NOT NULL but no program column — verifications are conceptually cross-program (income verifies for both SNAP and TANF; SAVE verifies citizenship cross-program). Step 9 enables when application_programs.status='data_collected' AND GET /v1/verifications?application_id={id}&status=pending&limit=1 returns empty. No ?program= parameter on the gate query.

Verification API extension

verifications schema already has application_id (line 23) — extending the API filter requires NO migration. MR2 adds an application_id: Option<Uuid> query param to the existing /v1/verifications LIST handler at services/canopy-verification/src/api/verifications.rs:24,35 + the store filter at store.rs:89. utoipa decorator updated.

Audit case-detail section

Replace the #562 stub at services/canopy-web/src/case_detail/sections/audit.rs with a real fetch+render using the actual dispatcher signature (fetch(clients, household_id, session, program, item) → RenderedSection — see §6). Extend canopy-security with household_id column on audit_events + query-param filter.

Audit chain endpoint

GET /v1/security/verify-chain (general audit-events chain, per crates/canopy-contracts-security/src/paths.rs:24). NOT /v1/security/fti/chain-status (that’s FTI-specific for TANF/Medicaid, paths.rs:29).

Audit chain endpoint response shape

Currently verify_chain at services/canopy-security/src/api/mod.rs:285-307 returns raw Json<serde_json::Value> in two shapes: {"valid": true, "events_verified": count} or {"valid": false, "broken_at": id, "message": …​}. MR5b adds a contract struct ChainVerificationResponse { valid: bool, events_verified: Option<u64>, broken_at: Option<String>, message: Option<String> } to crates/canopy-contracts-security so the audit section deserializes a typed shape instead of accessing a string field on a serde_json::Value (avoids the "badge defaults to false" silent failure mode).

Audit Plugin.toml drift fix

Existing services/canopy-web/src/case_detail/sections/audit/Plugin.toml:26 declares endpoints = ["/v1/audit/events"] — wrong (actual route is /v1/security/events). MR5b fixes the manifest.

Application status vocabulary

Extend applications.status CHECK to `submitted

processing

determined

withdrawn

denied

approved

data_collected`. Extend application_programs.status CHECK to `pending

processing

data_collected

determined

approved

denied

withdrawn`. Both with same-migration backfill of rogue values + pg_advisory_xact_lock(51776).

Sections list count

9 SNAP intake sections, 10 TANF intake sections (7 shared + 3 TANF-only). Grounded in PAMMS section taxonomy (§13).

Demo dataset

Plan 1’s partial-demo uses canopy-seed demo profile + MR6 multi-program persona extensions. No throwaway fixtures.

Architecture

┌─────────────────────── canopy-web ───────────────────────┐
│  GET /applications/{id}/intake/{program}                 │
│   └─ get_intake_application() — NEW handler              │
│       Validates {program} is in the app's                │
│       programs_requested AND in session.primary_programs.│
│       Returns 404 otherwise.                             │
│       └─ Askama: templates/applications/intake.html      │
│           ├─ 9-step stepper (top)                        │
│           └─ Single-program section accordion            │
│              (9 SNAP sections OR 10 TANF — exactly       │
│              ONE program per page render).               │
│                                                          │
│  PUT /applications/{id}/sections/{program}/{section}     │
│   └─ save_intake_section() — proxies to canopy-apps      │
│                                                          │
│  POST /applications/{id}/programs/{program}/complete     │
│   └─ complete_data_collection() — proxies; canopy-apps   │
│      validates all required sections completed.          │
│                                                          │
│  POST /applications/{id}/run-determination?program={p}   │
│   └─ run_determination() — NEW handler. Replaces         │
│      `case_detail.rs:2516` handler entirely (pre-1.0).   │
│      Takes application_id from path AND program from     │
│      query string — per-program scoped. Posts to         │
│      canopy-eligibility with the single program.         │
└──────────────────────────────────────────────────────────┘
                            │
                            ▼ (HTTP via ServiceClients)
┌─────────────────── canopy-applications ───────────────────┐
│  PUT /v1/applications/{id}/sections/{program}/{section}   │
│   └─ upsert_section() — NEW. Integrity guards:            │
│       (i) program is in the app's active                  │
│           application_programs.program set;               │
│       (ii) section_name in SectionName::                  │
│            applicable_programs(program).                  │
│       Returns 422 on either violation.                    │
│  GET /v1/applications/{id}/sections — NEW                 │
│  POST /v1/applications/{id}/programs/{program}/complete   │
│   └─ NEW. Backend GATE: verifies that every section in    │
│      SectionName::applicable_programs(program) has a row  │
│      in application_sections with completed_at IS NOT NULL│
│      for this (application_id, program). Returns 422      │
│      "incomplete sections: [list]" if any are missing.    │
│      On pass: sets application_programs.status=           │
│      'data_collected' AND recomputes applications.status  │
│      ('submitted'→'processing' on first transition;       │
│      'processing'→'closed' if all program rows terminal). │
│      Emits application_section.completed.                 │
│                                                           │
│  Table: application_sections                              │
│   UNIQUE (application_id, program, section_name)          │
│         WHERE active = true                               │
│                                                           │
│  Outbox emit: application_section.updated                 │
│   (PII allowlist INCLUDES household_id — see §7)          │
└───────────────────────────────────────────────────────────┘

Audit section flow:

GET /cases/{household_id} (existing case-detail composition)
  ▼ dispatched via dispatch_fetch(item, ctx) at sections.rs:166
audit::fetch(clients, household_id, session, program, item)
  ▼ (HTTP)
GET /v1/security/events?household_id={hh}&limit=50
GET /v1/security/verify-chain   ← general audit chain (not FTI)
  ▼
finalize_section_html(html, item, DISPLAY_NAME) → RenderedSection

Per-program independence flow:

Login as jane.snap-worker        Login as jane.tanf-worker
  │ Keycloak attr                  │ Keycloak attr
  │ primary_programs=["snap"]      │ primary_programs=["tanf"]
  ▼ oidc-usermodel-attribute-      ▼ oidc-usermodel-attribute-
    mapper → JWT claim               mapper → JWT claim
Claims.primary_programs           Claims.primary_programs
= vec!["snap"]                    = vec!["tanf"]
  │                                │
  ▼                                ▼
SessionData.primary_programs    SessionData.primary_programs
  │                                │
  ▼                                ▼
MyQueue.fetch_items(_, session) MyQueue.fetch_items(_, session)
  │ filter by primary_programs    │ filter by primary_programs
  │ link → /intake/snap           │ link → /intake/tanf
  ▼                                ▼
Sees ONLY SNAP intake page      Sees ONLY TANF intake page
on the shared application       on the SAME shared application

Same household, same applications row, separate application_programs
rows, separate application_sections rows, separate documents.
Independence is enforced by per-program tables + per-worker URL
scoping, NOT by separate application rows.

Wire contracts (MR1 — Plan 3 unblocker, ships FIRST)

Prerequisite addition: crates/canopy-common/src/id.rs defines IDs via the define_id! macro. DocumentId is NOT in the current set (verified). MR1 adds:

// crates/canopy-common/src/id.rs — append next to existing newtypes
define_id!(
    /// Unique identifier for an applicant-uploaded document
    /// (canopy-applications documents store; Plan 3 surface).
    DocumentId
);

Then crates/canopy-contracts-applications/src/sections.rs (new file):

// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
use validator::Validate;
use canopy_common::id::{ApplicationId, HouseholdId, PersonId};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SectionName {
    HouseholdComposition,
    Identity,
    Citizenship,
    Residency,
    IncomeEmployment,
    Resources,                   // SNAP-only
    ExpensesShelter,             // SNAP-only
    WorkRegistration,
    SpecialCircumstances,
    TanfChildSupport,            // TANF-only
    TanfPersonalResponsibility,  // TANF-only
    TanfTimeLimits,              // TANF-only
}

impl SectionName {
    // Return slugs (not `Program` typed) so comparison with
    // applications.programs_requested: TEXT[] is direct.
    pub fn applicable_programs(&self) -> &'static [&'static str] {
        match self {
            Self::Resources | Self::ExpensesShelter => &["snap"],
            Self::TanfChildSupport
            | Self::TanfPersonalResponsibility
            | Self::TanfTimeLimits => &["tanf"],
            _ => &["snap", "tanf"], // 7 shared sections
        }
    }
    pub fn display_name_key(&self) -> &'static str { /* fluent key */ }
    pub fn icon(&self) -> &'static str { /* lucide slug */ }
    /// Documentary advisory only (§13). NOT validated by xtask policy.
    pub fn pamms_citation(&self) -> &'static str { /* e.g. "PAMMS dfcs-snap §2050" */ }
}

/// Wire-side payload — typed-per-section. The handler dispatches the
/// JSON body to the right struct based on the `section_name` URL path
/// segment, NOT a serde-tag discriminator.
pub mod payloads {
    use super::*;

    /// Workflow-record payload — captures the worker's verification
    /// ACTION on the named person, not the underlying identity facts.
    /// Per ADR-001 + ADR-002: SSN / DOB / legal name are owned by
    /// canopy-persons (system of record, encrypted at rest). The intake
    /// page surfaces canopy-persons CRUD via a separate "Edit person
    /// record" affordance; that flow PUTs to canopy-persons directly
    /// and never traverses canopy-applications.
    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Validate)]
    pub struct IdentityPayload {
        pub person_id: PersonId,
        pub verification_method: IdentityVerificationMethod,
        pub document_evidence_id: Option<canopy_common::id::DocumentId>,
        pub verified_at: chrono::DateTime<chrono::Utc>,
        #[validate(length(max = 500))]
        pub worker_notes: Option<String>,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
    #[serde(rename_all = "snake_case")]
    pub enum IdentityVerificationMethod {
        DriversLicense, StateId, Passport, BirthCertificate,
        TribalId, CollateralContact, ApplicantStatement,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Validate)]
    pub struct HouseholdCompositionPayload {
        #[validate(length(min = 1))]
        pub members: Vec<HouseholdMemberRef>,
        pub head_of_household_person_id: PersonId,
        pub relationships_confirmed: bool,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
    pub struct HouseholdMemberRef {
        pub person_id: PersonId,
        pub relationship_to_head: String,
        pub purchases_and_prepares_meals_with_head: Option<bool>,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Validate)]
    pub struct CitizenshipPayload {
        pub person_id: PersonId,
        pub verification_method: CitizenshipVerificationMethod,
        pub save_case_number: Option<String>,
        pub document_evidence_id: Option<canopy_common::id::DocumentId>,
        pub verified_at: chrono::DateTime<chrono::Utc>,
        #[validate(length(max = 500))]
        pub worker_notes: Option<String>,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
    #[serde(rename_all = "snake_case")]
    pub enum CitizenshipVerificationMethod {
        BirthCertificate, Passport, Naturalization,
        SaveQuery, CollateralContact, ApplicantStatement,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Validate)]
    pub struct ResidencyPayload {
        pub address_person_id: PersonId,
        pub verification_method: ResidencyVerificationMethod,
        pub same_as_application_address: bool,
    }

    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
    #[serde(rename_all = "snake_case")]
    pub enum ResidencyVerificationMethod {
        UtilityBill, RentalAgreement, MortgageStatement, MailFromGovAgency,
        CollateralContact, ApplicantStatement,
    }

    // Remaining 8 payload structs (IncomeEmployment, Resources,
    // ExpensesShelter, WorkRegistration, SpecialCircumstances,
    // TanfChildSupport, TanfPersonalResponsibility, TanfTimeLimits)
    // follow the same architectural principle:
    //   - Reference (PersonId | HouseholdId | IncomeRecordId | ...) into
    //     canopy-persons / canopy-applications-owned data;
    //   - Record only WORKFLOW metadata (verification method, document
    //     references, worker notes, timestamps);
    //   - Never duplicate the underlying facts.
    // MR1 spells each out (~30-40 LOC per struct). Total contracts
    // module ~700 LOC.
}

pub mod paths {
    pub const PUT_SECTION: &str =
        "/v1/applications/{id}/sections/{program}/{section}";
    pub const LIST_SECTIONS: &str = "/v1/applications/{id}/sections";
    pub const COMPLETE_DATA_COLLECTION: &str =
        "/v1/applications/{id}/programs/{program}/complete-data-collection";
}

Schema changes (canopy-applications)

Forward-only per ADR-016. Two migrations.

20260601000000_create_application_sections.sql:

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 1 MR2 — per-program intake-section storage.

CREATE TABLE application_sections (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    application_id UUID NOT NULL REFERENCES applications(id),
    program TEXT NOT NULL,
    section_name TEXT NOT NULL,
    payload JSONB NOT NULL,
    completed_at TIMESTAMPTZ,
    last_edited_by UUID NOT NULL,  -- Keycloak `sub`; not FK'd (workers aren't in canopy-persons)
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX application_sections_unique
    ON application_sections (application_id, program, section_name)
    WHERE active = true;

CREATE INDEX application_sections_app_program_idx
    ON application_sections (application_id, program)
    WHERE active = true;

CREATE INDEX application_sections_last_edited_by_idx
    ON application_sections (last_edited_by) WHERE active = true;

20260601000001_extend_applications_status.sql:

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 1 MR2 — formalize status vocabulary.

-- Advisory lock so a concurrent INSERT cannot slip a rogue status
-- value between the UPDATE backfill and the ALTER ADD CONSTRAINT.
-- Lock ID 0xCA40 = 51776 (CA = Canopy, 40 = applications-status migration).
SELECT pg_advisory_xact_lock(51776);

-- applications.status: existing 'pending' rows (~9 PendingVerification
-- personas per generate.rs:401-413) normalize to 'submitted'.
UPDATE applications SET status = 'submitted'
WHERE status NOT IN (
    'submitted','processing','data_collected','determined',
    'withdrawn','denied','approved'
);

-- application_programs.status: include 'processing' defensively even
-- though no current writer exists for that value (verified via grep).
UPDATE application_programs SET status = 'pending'
WHERE status NOT IN (
    'pending','processing','data_collected','determined','approved','denied','withdrawn'
);

ALTER TABLE applications ADD CONSTRAINT applications_status_check
    CHECK (status IN (
        'submitted','processing','data_collected','determined',
        'withdrawn','denied','approved'
    ));

ALTER TABLE application_programs ADD CONSTRAINT application_programs_status_check
    CHECK (status IN (
        'pending','processing','data_collected','determined','approved','denied','withdrawn'
    ));

ADR-016 risk note: expand-only (new table; new CHECKs with same-migration backfill + advisory lock). No retype, rename, or drop.

UI changes (canopy-web)

GET /applications/{id}/intake/{program}

Direct Askama handler in services/canopy-web/src/api/applications.rs next to get_process_application. Renders templates/applications/intake.html:

┌─ Topbar (existing) ─────────────────────────────────┐
│ Intake — MARIA LOPEZ — SNAP                          │
│ Breadcrumb: Dashboard › Applications › abcd1234     │
├─────────────────────────────────────────────────────┤
│  Step bar (9 steps; current highlighted)            │
│  ① Submitted ② Identity ③ Household ④ Citizenship   │
│  ⑤ Residency ⑥ Income   ⑦ Complete  ⑧ Verify ⑨ Det.│
├─────────────────────────────────────────────────────┤
│  Single-program section accordion (9 SNAP / 10 TANF):│
│  ▼ Identity              (saved 2m ago, Sarah W.)   │
│    [section partial: identity inputs]               │
│    [Save section]  (htmx hx-put → PUT_SECTION)      │
│                                                     │
│  ▶ Household composition (in progress)              │
│  ▶ Citizenship           (not started)              │
│  ...                                                │
│                                                     │
│  Below per-program accordion:                       │
│  [✓ Complete Data Collection]  (Step 7;             │
│     POSTs COMPLETE_DATA_COLLECTION; sets            │
│     application_programs.status='data_collected';   │
│     enabled when all program-applicable sections    │
│     have completed_at IS NOT NULL)                  │
│  [▶ Run Determination]  (Step 9; POSTs new          │
│     /applications/{id}/run-determination?program={p}│
│     enabled when application_programs.status=       │
│     'data_collected' AND GET /v1/verifications?     │
│     application_id={id}&status=pending&limit=1      │
│     returns empty — per-application gate)           │
└─────────────────────────────────────────────────────┘

Content-Security-Policy: htmx + Alpine.js for accordion open/close. All inline styles externalized to Orchard utility classes (per feedback_modal_transition_csp).

POST /applications/{id}/run-determination?program={p} (NEW handler — per-program scoped)

// services/canopy-web/src/api/applications.rs (new function)
pub async fn run_determination(
    AuthenticatedWorkerWithCsrf { worker, csrf_token }: AuthenticatedWorkerWithCsrf,
    Extension(clients): Extension<Arc<ServiceClients>>,
    Extension(svc_token): Extension<canopy_auth::ServiceTokenSource>,
    Path(application_id): Path<String>,
    Query(query): Query<RunDeterminationQuery>, // REQUIRED ?program=snap (validated)
) -> Html<String> {
    let clients = clients.with_service_identity(&svc_token).await;
    let program = query.program; // String, validated against canopy_reference::Program
    let app = clients.applications
        .get::<serde_json::Value>(&format!("/v1/applications/{application_id}"))
        .await
        .map_err(|e| render_determination_error(&format!("{e}"), &application_id))?;
    let household_id = app["household_id"].as_str().unwrap_or_default().to_owned();
    // Validate: requested program is in this application's programs_requested.
    let programs_requested: Vec<String> = app["programs_requested"].as_array()
        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
        .unwrap_or_default();
    if !programs_requested.iter().any(|p| p == &program) {
        return render_determination_error(
            &format!("Program '{program}' not in this application's request set."),
            &application_id,
        );
    }
    // Verification gate (per-application, NOT per-program — verifications
    // schema has no `program` column; cross-program verifications block
    // all programs until resolved).
    let open = clients.verification
        .get::<Vec<serde_json::Value>>(&format!(
            "/v1/verifications?application_id={application_id}&status=pending&limit=1"
        ))
        .await
        .unwrap_or_default();
    if !open.is_empty() {
        return render_determination_error(
            "Cannot run determination: open verifications exist for this application.",
            &application_id,
        );
    }
    let body = build_determine_body(&application_id, &household_id, &[program.clone()], &worker.worker_name);
    match clients.eligibility.post::<_, serde_json::Value>("/v1/eligibility/determine", &body).await {
        Ok(_) => /* htmx swap: render the determination result fragment */,
        Err(e) => render_determination_error(&format!("{e}"), &application_id),
    }
}

Old case_detail.rs:2516-2614::run_determination (the one with the "first-app-by-household" #579 TODO) is REMOVED. ALL call sites updated in MR4:

  • services/canopy-web/templates/applications/intake.html (new) — calls /applications/{id}/run-determination?program={p}

  • services/canopy-web/templates/applications/process.html (existing) — update to call new route

  • services/canopy-web/templates/cases/tab_determination.html:14 — currently hx-post="/cases/{{ household_id }}/run-determination"; update to hx-post="/applications/{{ application_id }}/run-determination?program={{ active_program }}".

  • services/canopy-web/templates/case_detail/_top_bar_actions.html:46 — currently hx-post="/cases/{{ household_id }}/run-determination?program={{ active_program }}"; update target to /applications/{{ application_id }}/run-determination?program={{ active_program }}.

Per-template thread-through of application_id may add 30-50 LOC across the two templates' calling handlers; folded into MR4 LOC estimate.

MyQueue filter wiring (MR3)

services/canopy-web/src/dashboard/panels/my_queue.rs (flat file; my_queue/Plugin.toml is the sibling manifest).

Changes:

  • fetch_items signature: pub async fn fetch_items(clients: &ServiceClients, session: &SessionData) → Vec<WorkQueueItem> (currently (clients) only — line 35).

  • fetch already takes _session at line 152 — wire the param into fetch_items.

  • Cross-pollinated callers: services/canopy-web/src/api/cases.rs:83 and the command-palette path also use fetch_items. MR3 updates BOTH call sites to pass session.

  • ListParams plural support (NEW): existing ListParams (crates/canopy-contracts-applications/src/applications.rs:100) has singular status: Option<String> and program: Option<String> only. MR3 ADDS statuses: Vec<String> and programs: Vec<String> additively (via #[serde(default)] so absent param → empty vec); existing singular fields preserved for back-compat with non-MyQueue callers. SQL filter logic at services/canopy-applications/src/store/mod.rs:139 extends with ANY($plural::text[]) predicates (and programs_requested && $plural::text[] array-overlap for programs).

  • Status filter: MyQueue calls /v1/applications?statuses=submitted&statuses=processing&limit=10. Axum’s Query<ListParams> deserializes repeated-key syntax via #[serde(default)] Vec automatically.

  • Programs filter from session: append &programs=snap&programs=tanf for each value in session.primary_programs.

  • Renewals queue: MR3 also extends canopy-renewals (/v1/renewals/snap/due/v1/renewals/{program}/due, path-parameterized — mirrors the existing program-parameterized routes in the same service). MyQueue iterates over session.primary_programs calling per-program. ~100 LOC of the MR is the canopy-renewals change.

  • Appeals queue: /v1/appeals/queue has no per-program filter today. Plan-1 limitation: TANF workers see all appeals (no filter); MR3 does NOT change canopy-appeals (out of scope).

  • Link target: format!("/applications/{app_id}/process") (line 71) becomes format!("/applications/{app_id}/intake/{program_slug}") where program_slug is the worker’s matching primary_program for this app. MR3 derives it: if programs_requested ∩ session.primary_programs has exactly one element, use it; if multiple, use the first; if zero (no claim), use the first of programs_requested.

Claims extension (MR3 — in canopy-auth)

// crates/canopy-auth/src/claims.rs — extend the existing typed Claims struct
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Claims {
    // ... existing fields ...
    /// Multi-valued user attribute from Keycloak (NEW for Plan 1).
    /// Absent → see all programs (back-compat). Present-but-malformed
    /// → login is rejected upstream by the parser; this field is only
    /// populated when every slug parses cleanly.
    #[serde(default)]
    pub primary_programs: Vec<String>,
}

impl Claims {
    /// Fail-closed: if the claim is present but ANY slug is unparseable,
    /// return Err with a structured 401 message.
    pub fn parsed_primary_programs(&self) -> Result<Vec<canopy_reference::Program>, ApiError> {
        if self.primary_programs.is_empty() { return Ok(vec![]); }
        let mut parsed = Vec::with_capacity(self.primary_programs.len());
        for slug in &self.primary_programs {
            match canopy_reference::Program::from_str(slug) {
                Ok(p) => parsed.push(p),
                Err(_) => return Err(ApiError::unauthorized(
                    "malformed primary_programs claim, contact admin"
                )),
            }
        }
        Ok(parsed)
    }
}

Keycloak protocol mapper (MR3)

devstack/keycloak/canopy-realm.json currently only declares oidc-audience-mapper instances (lines 120-335). Add ONE new mapper definition for the worker-portal client:

{
  "name": "primary_programs",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-usermodel-attribute-mapper",
  "config": {
    "user.attribute": "primary_programs",
    "claim.name": "primary_programs",
    "jsonType.label": "String",
    "multivalued": "true",
    "access.token.claim": "true",
    "id.token.claim": "false",
    "userinfo.token.claim": "false"
  }
}

Plus two new users (in the realm users array):

{
  "username": "jane.snap-worker",
  "email": "jane.snap-worker@canopy.test",
  "attributes": { "primary_programs": ["snap"] },
  "credentials": [{"type": "password", "value": "..."}],
  "realmRoles": ["caseworker"]
},
{
  "username": "jane.tanf-worker",
  "email": "jane.tanf-worker@canopy.test",
  "attributes": { "primary_programs": ["tanf"] },
  "credentials": [{"type": "password", "value": "..."}],
  "realmRoles": ["caseworker"]
}

Case-detail Audit section (MR5b)

Replace services/canopy-web/src/case_detail/sections/audit.rs::fetch body. The signature MUST match the dispatcher’s calling convention at services/canopy-web/src/case_detail/sections.rs:166:

pub async fn fetch(
    clients: &ServiceClients,
    household_id: &str,
    _session: &SessionData,
    _active_program: Program,
    item: &ComposedItem,
) -> RenderedSection {
    let events: Vec<AuditEvent> = clients.security
        .get(&format!("/v1/security/events?household_id={household_id}&limit=50"))
        .await
        .unwrap_or_default();

    // ADR-014: general audit hash-chain verifier. /v1/security/verify-chain
    // (paths.rs:24) — NOT /v1/security/fti/chain-status.
    let chain_ok = clients.security
        .get::<canopy_contracts_security::ChainVerificationResponse>(
            "/v1/security/verify-chain"
        )
        .await
        .map(|r| r.valid)
        .unwrap_or(false);

    let tmpl = AuditSectionTemplate { events, chain_ok, household_id: household_id.to_string() };
    let html = tmpl.render().unwrap_or_else(|e|
        format!("<div class=\"service-error\">{e}</div>")
    );
    finalize_section_html(html, item, DISPLAY_NAME)
}

New ChainVerificationResponse contract struct in crates/canopy-contracts-security/src/chain.rs (MR5b):

#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ChainVerificationResponse {
    pub valid: bool,
    pub events_verified: Option<u64>,
    pub broken_at: Option<String>,
    pub message: Option<String>,
}

services/canopy-security/src/api/mod.rs:285-307::verify_chain updates to return Json<ChainVerificationResponse> instead of Json<serde_json::Value>. utoipa decorator updated with body = ChainVerificationResponse.

RenderedSection is a STRUCT (sections.rs:69) with fields slug, short_slug, display_name, span, row, has_badge, badge_label, flag_kind, html — NOT an enum. finalize_section_html is the public helper at sections.rs:107 that constructs the struct.

Dispatcher addition at sections.rs::dispatch_fetch:

"case-detail-audit-section" => {
    audit::fetch(ctx.clients, ctx.household_id, ctx.session, ctx.active_program, item).await
}

New template templates/case_detail/sections/_audit.html renders the event table + chain badge.

Plugin.toml endpoint fix (MR5b): services/canopy-web/src/case_detail/sections/audit/Plugin.toml:26 declares endpoints = ["/v1/audit/events"] — wrong. Change to ["/v1/security/events"].

Canopy-security extension (MR5a)

audit_events does NOT have a household_id column today (verified across all 10 migrations under services/canopy-security/migrations/). ALSO: ParsedAuditEvent at services/canopy-security/src/event_parsing.rs:12-22 has NO household_id field — only resource_id: Option<String> which uses household_id as one of several CANDIDATE fields for resource_id. MR5a fixes the entire chain:

  1. New forward migration 20260601000010_add_household_id_to_audit_events.sql (ADR-016 forward-only; NULL acceptable for pre-existing rows):

    -- SPDX-License-Identifier: AGPL-3.0-or-later
    ALTER TABLE audit_events ADD COLUMN household_id UUID;
    ALTER TABLE audit_events_archive ADD COLUMN household_id UUID;
    CREATE INDEX audit_events_household_idx
        ON audit_events (household_id) WHERE household_id IS NOT NULL;
  2. Contract: AuditEvent (crates/canopy-contracts-security/src/events.rs:25-45) gains pub household_id: Option<HouseholdId>. AuditListParams (line 57-62) gains pub household_id: Option<HouseholdId>.

  3. ParsedAuditEvent gains a dedicated household_id field (services/canopy-security/src/event_parsing.rs). The dedicated extractor looks ONLY for the household_id field on the payload — does not absorb other identifiers:

    pub struct ParsedAuditEvent {
        // ... existing fields ...
        pub household_id: Option<String>,  // NEW
    }
    pub fn parse_event(envelope: &EventEnvelope) -> ParsedAuditEvent {
        // ... existing extractors ...
        let household_id = extract_string_field(&envelope.payload, &["household_id"]);
        ParsedAuditEvent { /* ... */, household_id }
    }
  4. INSERT binding update at services/canopy-security/src/store/mod.rs:81: the audit_events INSERT statement gains the new column + bind. Same change for the archive table.

  5. Endpoint filter: services/canopy-security/src/api/mod.rs::list_events adds household_id extraction from AuditListParams; SQL WHERE ($1::uuid IS NULL OR household_id = $1) …​. utoipa decorator updated.

  6. Hash-chain unaffected: chain hashes canonical timestamp + payload (per ADR-014).

PII gate on outbox events

Per ADR-014 + project convention, application_section.updated payload is allowlist-only. household_id is INCLUDED (it’s the case identifier, not PII-sensitive):

ApplicationSectionUpdatedEvent {
    application_id: ApplicationId,
    household_id: HouseholdId,    // INCLUDED — case identifier
    program: Program,
    section_name: SectionName,
    completed: bool,
    last_edited_by: WorkerId,
    occurred_at: DateTime<Utc>,
    // NO payload, NO diff, NO field values, NO SSN, NO DOB
}

application_section.completed (emitted from POST complete-data-collection) follows the same shape.

Idempotency

crates/canopy-api/src/idempotency.rs:344 is POST-only (if request.method() != Method::POST { return None }). Handled:

  • PUT /v1/applications/{id}/sections/{program}/{section} (upsert): PUT is idempotent by HTTP semantics. DB-side ON CONFLICT (application_id, program, section_name) WHERE active = true DO UPDATE SET payload = EXCLUDED.payload, updated_at = now() coalesces races. No middleware.

  • POST /v1/applications/{id}/programs/{program}/complete-data-collection: middleware applies. canopy-web generates a deterministic key per (application_id, program).

  • POST /applications/{id}/run-determination: middleware applies. canopy-web generates a deterministic key per (application_id, request_intent_id) where the intent_id is a per-form-render UUID.

RBAC + CSRF

Correct role names per crates/canopy-auth/src/claims.rs:156-251:

  • All canopy-web /applications/{id}/intake* routes carry AuthenticatedWorkerWithCsrf (mirror case_detail.rs:2516).

  • canopy-applications endpoints called from canopy-web must accept service tokens because canopy-web proxies via ServiceClients.with_service_identity(&svc_token). Use Claims::require_service_or_caseworker_or_above() at claims.rs:240 — NOT the worker-only require_caseworker_or_above() at claims.rs:157.

  • Worker role slugs that the combined guard accepts: caseworker, eligibility_specialist, supervisor, admin, quality_control.

  • The audit section endpoint inherits case-detail-audit plugin’s required_roles from Plugin.toml.

Cross-service ref validator (ADR-025)

New FK relationships to register in crates/canopy-validators (plural — verified at crates/canopy-validators/src/lib.rs):

  • application_sections.application_id → applications.id (intra-service, already FK’d)

  • audit_events.household_id → persons.households.id (cross-service, nullable — MR5a; validator skips null rows per ADR-025 convention)

Explicitly NOT registered: application_sections.last_edited_by stores the worker’s Keycloak sub UUID. Workers are NOT persisted in canopy-persons; precedent at applications.submitted_by which is also an unconstrained worker UUID.

Demo script (Plan 1 partial-demo path)

Without Plans 2 + 3, against canopy-seed demo profile AFTER MR6 extends Maria Lopez’s household (Sofia age 4 months, postpartum WIC narrative) with ONE NEW applications row (programs_requested = ['snap','tanf'], status='submitted') and TWO application_programs rows (one per program). Each (application_id, program) pair gets seeded Identity + HouseholdComposition intake sections:

0:00  Log in as jane.snap-worker.
0:20  MyQueue filtered to apps containing SNAP. Open Maria Lopez's app →
       link target /applications/{app_id}/intake/snap (per-program URL
       — the SNAP worker lands ONLY on the SNAP-scoped page).
0:40  Walk 3 SNAP sections (Identity, Household, Income). htmx
       auto-save badge confirms each.
1:30  Click "Complete Data Collection". Backend gates: every SNAP-
       applicable section has completed_at IS NOT NULL → 200; sets
       application_programs.status='data_collected' for SNAP; recomputes
       applications.status='processing' (TANF row still 'pending').
1:45  Click "Run Determination". Per-application verification gate
       (GET /v1/verifications?application_id=&status=pending&limit=1)
       returns empty → POST /applications/{app_id}/run-determination?
       program=snap triggers eligibility; verdict populates.
2:15  Click Audit tab on case detail. Hash-chain-verified events for
       THIS household via /v1/security/verify-chain (typed
       ChainVerificationResponse); chain badge "Verified".
2:45  Log out. Log in as jane.tanf-worker.
3:00  MyQueue shows the SAME application (programs_requested contains
       TANF, applications.status='processing'). Click → lands on
       /applications/{app_id}/intake/tanf (per-program URL — TANF
       worker never sees SNAP's data). Notice: SNAP's completed
       sections do NOT appear; the TANF tab's sections are
       independently 'not started'.
3:45  Walk 2 TANF sections (Identity, ChildSupport).
4:15  Audit tab now shows a SEPARATE causal trail entry for the TANF
       work — both chains valid; neither leaks.
4:30  Cut. (Plan 1 partial demo: ~4.5 min; Plans 2+3 contribute the
       remaining 5.5 min via applicant submission + ELE flag.)

PAMMS section citations (ADR-011 scope clarification)

citations.toml (rulesets/georgia/citations.toml) tracks [citations."dotted.key"] entries that mirror values in jurisdiction.toml (verified at crates/canopy-policy/src/citation.rs:14-60). cargo xtask policy audit validates these. Intake-process anchors are NOT numeric thresholds and have no jurisdiction.toml mirror; extending citations.toml here would be dead documentation.

MR1 scope: SectionName::pamms_citation() returns a static string like "PAMMS dfcs-snap §2050" used in code comments, debug logging, and UI tooltips. NO citations.toml extension. NO policy audit claim on these strings. Future plan can re-scope ADR-011 to cover process anchors; out of Plan 1.

Documentary section ↔ PAMMS mapping:

Variant Program(s) PAMMS manual / section

HouseholdComposition

SNAP

dfcs-snap §2200

HouseholdComposition

TANF

dfcs-tanf §1200

Identity

both

dfcs-snap §2050 / dfcs-tanf §1100

Citizenship

both

dfcs-snap §2050 / dfcs-tanf §1100

Residency

both

dfcs-snap §2050 / dfcs-tanf §1100

IncomeEmployment

SNAP

dfcs-snap §2400

IncomeEmployment

TANF

dfcs-tanf §1400

Resources

SNAP

dfcs-snap §2500

ExpensesShelter

SNAP

dfcs-snap §2600

WorkRegistration

both

dfcs-snap §2700 / dfcs-tanf §1500

TanfChildSupport

TANF

dfcs-tanf §1310

TanfPersonalResponsibility

TANF

dfcs-tanf §1345-1370

TanfTimeLimits

TANF

dfcs-tanf §1600

SpecialCircumstances

both

dfcs-snap §2050 / dfcs-tanf §1100 (MR1 write-time choice — household-level flags belong in the general eligibility-conditions sections)

Count check: SNAP = 9 (7 shared + Resources + ExpensesShelter). TANF = 10 (7 shared + TanfChildSupport + TanfPersonalResponsibility + TanfTimeLimits).

Resolved questions

  1. Renewals queue scope — RESOLVED 2026-05-27: extend canopy-renewals to program-parameterized /v1/renewals/{program}/due (mirrors the existing program-parameterized route pattern). ~100 LOC folded into MR3.

  2. SSN storage location — RESOLVED 2026-05-27: canopy-persons is the system of record per ADR-001 + ADR-002. IdentityPayload references PersonId and stores only workflow metadata. SSN / DOB / legal name NEVER appear in canopy-applications. Pattern applies to ALL 12 section payloads (workflow-record concept).

  3. Run-determination selector — RESOLVED 2026-05-27: new route POST /applications/{id}/run-determination?program={p} per-program scoped. Old case_detail.rs:2516 handler with the "first-app-by-household" #579 TODO removed.

  4. Invalid Keycloak claim policy — RESOLVED 2026-05-27: fail-closed (401 on any unparseable slug).

  5. State machine column choice — RESOLVED 2026-05-27: per-program on application_programs.status with applications.status container-recompute.

  6. Verification gate scope — RESOLVED v7: per-application (not per-program); verifications schema has no program column; cross-program verifications block all programs until resolved.

Verification (end-to-end gate on MR6)

cargo xtask dev refresh
cargo xtask seed --profile demo --reset

# DB sanity — MR6 seeds 2 sections per (application, program) pair across
# the 3 extended archetypes × 2 programs each = at minimum 12 rows.
psql -c "SELECT COUNT(*) FROM application_sections WHERE active = true;"
# Expect: ≥ 12 rows

# API surface
TOKEN=$(./scripts/get-token.sh jane.snap-worker)
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8083/v1/applications/{id}/sections
curl -H "Authorization: Bearer $TOKEN" -X PUT \
  http://localhost:8083/v1/applications/{id}/sections/snap/identity \
  -d '{...}'
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8084/v1/security/events?household_id={hh}&limit=5"
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8084/v1/security/verify-chain"

# Test gates
cargo nextest run -p canopy-applications -p canopy-web -p canopy-security -p canopy-auth -p canopy-verification -p canopy-renewals
cargo xtask e2e -- intake.spec.ts

# Project gates
cargo xtask validate
cargo xtask policy audit            # ADR-011 (existing thresholds)
cargo xtask policy audit-literals
cargo xtask policy audit-unwraps
cargo xtask demo verify             # ADR-025 cross-service ref validator

Acceptance: all gates pass; intake.spec.ts walks login → intake → save section → complete data collection → run determination → audit section renders with chain-verified badge; ≥10 new test cases on canopy-applications, ≥5 on canopy-web, ≥3 on canopy-security, ≥2 on canopy-auth (Claims parse including fail-closed malformed-claim case).

Canonical seed path: tools/canopy-seed/src/demo/personas.rs. Seed payloads use a shared valid_section_payload! macro from the contracts crate mirroring API-side validator::Validate.

Risks & mitigations

  • ADR-016 CHECK constraint risk: rogue rows could reject between UPDATE backfill and ALTER ADD. Mitigation: same-migration backfill + pg_advisory_xact_lock(51776) at top of migration.

  • MyQueue back-compat: existing users without primary_programs claim must see everything. Mitigation: absent claim → empty Vec → no filter (MR3).

  • htmx accordion + CSP: intake is canopy-web’s most CSS-heavy surface. Mitigation: mirror process.html token discipline; no inline styles; explicit transition CSS properties on animated sections (feedback_modal_transition_csp).

  • Audit endpoint index cost: adding household_id filter needs an index. Mitigation: MR5a migration ships it.

  • canopy-seed bypass: seed writes via raw INSERT (project_demo_dataset_next_session). Mitigation: MR6 seed payloads use shared valid_section_payload! macro that mirrors API validator::Validate. ADR-025 cross-service validator (cargo xtask demo verify) must pass.

  • MR4 LOC ceiling: 19 section partials + page chrome ≈ 1300 LOC pushes the ceiling. Mitigation: pre-emptive split into MR4a (template + 9 SNAP partials + COMPLETE_DATA_COLLECTION) + MR4b (10 TANF partials + run_determination rewrite + all four template updates) if reviewer flags.

  • Fail-closed Keycloak claim parse: a typo in a user attribute locks them out. Mitigation: error message ("malformed primary_programs claim, contact admin") + write-time test case + ops runbook page for the rotation/recovery path.

References

Existing code to extend

  • services/canopy-applications/migrations/20260401000000_create_applications_tables.sql — schema baseline

  • crates/canopy-contracts-applications/src/applications.rs:62-73,104CreateApplicationRequest, ListParams (existing program: Option<String> singular field kept; new programs: Vec<String> plural added additively)

  • services/canopy-applications/src/api/mod.rs:96-141routes(); append section routes

  • services/canopy-applications/src/store/mod.rs:362-385record_determination (existing per-program write path; Plan 1 reuses)

  • services/canopy-web/src/api/mod.rs:60-63/applications/{id}/process registration pattern to mirror for /intake

  • services/canopy-web/src/api/applications.rs:55-230get_process_application pattern

  • services/canopy-web/src/api/case_detail.rs:2516-2614run_determination to REMOVE (replaced by new /applications/{id}/run-determination route)

  • services/canopy-web/src/dashboard/panels/my_queue.rs:35,71,150-160fetch_items (flat file); link target /process/intake/{program_slug}

  • services/canopy-web/src/api/cases.rs:83fetch_items reuse call site (must pass session in MR3)

  • services/canopy-web/src/session.rs:22,92-126WorkerRole + SessionData to extend with primary_programs

  • services/canopy-web/src/case_detail/sections.rs:31-45,69-92,107,166SectionContext, RenderedSection, finalize_section_html, dispatch_fetch

  • services/canopy-web/src/case_detail/sections/audit.rs — stub to replace

  • services/canopy-web/src/case_detail/sections/audit/Plugin.toml:26EDIT in MR5b: endpoint drift

  • services/canopy-web/src/case_detail/sections/household.rs — section pattern to mirror for audit

  • services/canopy-web/templates/cases/tab_determination.html:14EDIT in MR4: retarget to new route

  • services/canopy-web/templates/case_detail/_top_bar_actions.html:46EDIT in MR4: retarget to new route

  • services/canopy-security/src/api/mod.rs:285-307verify_chain retype to ChainVerificationResponse

  • services/canopy-security/src/event_parsing.rs:12-22ParsedAuditEvent gains dedicated household_id field

  • services/canopy-security/src/store/mod.rs:81 — INSERT binding update for new column

  • crates/canopy-contracts-security/src/events.rs:25-45,57-62AuditEvent, AuditListParams

  • crates/canopy-contracts-security/src/paths.rs:24VERIFY_CHAIN (the right endpoint)

  • crates/canopy-auth/src/claims.rs:21,156-251Claims struct to extend; correct RBAC role names live here

  • crates/canopy-common/src/id.rsdefine_id! macro; MR1 adds DocumentId

  • services/canopy-verification/src/api/verifications.rs:24,35 — LIST handler; MR2 adds application_id filter

  • services/canopy-renewals/…​/v1/renewals/snap/due/v1/renewals/{program}/due (MR3)

  • devstack/keycloak/canopy-realm.json:120-335 — Keycloak mappers (add oidc-usermodel-attribute-mapper); users array (append 2 new)

  • tools/canopy-seed/src/demo/personas.rs:380 — Maria Lopez archetype (extend with SNAP+TANF apps in MR6)

Conventions

  • .claude/docs/delivery-protocol.md — Q1-Q8 + Pre-Implementation Design (feedback_precommit_questions)

  • .claude/docs/coding-conventions.md — 7-arg ceiling, struct-not-tuple-args (feedback_no_clippy_papering)

  • .claude/docs/testing.mdcargo nextest only (feedback_nextest_only); Playwright for E2E

  • .claude/docs/git-workflow.md — Co-Authored-By footer with actual model version (feedback_co_authored_by_footer)

  • feedback_no_q1q8_in_commit — Q1-Q8 are stdout-for-user, not commit body

  • feedback_xtask_not_docker_compose — invoke cargo xtask dev, never raw docker compose

Out of scope (handled by other plans)

  • Applicant submission via canopy-portal (Dioxus 0.7+ per ADR-008) — Plan 3

  • Applicant auth via application_id + passcode (user clarification 2026-05-27) — applicants have NO permanent login; submission returns an application_id + a passcode (design-provided shape; NEVER DOB — too widely available in leaked data). The pair becomes the return-visit credential. Plan 3 owns this entirely. ADR-008’s DOB-as-second-factor sketch is superseded.

  • Verification request UI on canopy-web — ALREADY EXISTS at services/canopy-web/src/api/case_detail.rs:2644-2781; Plan 3 only owns applicant inbox + case-detail Verifications + Documents sections.

  • ELE 1-year flag tables + JDM ruleset + renewal scheduler — Plan 2

  • Scripted IEVS + SAVE adapters (TOML keyed by household_id) — Plan 3

  • Document upload (real S3 storage from applicant) — Plan 3

Marginal Plan 1 surface affected by applicant-auth clarification: NONE. Worker portal does not display the passcode.

Edit this page · default