Plan: Worker intake + program independence (SNAP + TANF)
On this page
- Status
- Context
- Locked decisions
- Architecture
- Wire contracts (MR1 — Plan 3 unblocker, ships FIRST)
- Schema changes (canopy-applications)
- UI changes (canopy-web)
- PII gate on outbox events
- Idempotency
- RBAC + CSRF
- Cross-service ref validator (ADR-025)
- Demo script (Plan 1 partial-demo path)
- PAMMS section citations (ADR-011 scope clarification)
- Resolved questions
- Verification (end-to-end gate on MR6)
- Risks & mitigations
- References
- Out of scope (handled by other plans)
Status
| MR | Description | Status |
|---|---|---|
1 |
|
Done (2026-05-28) — MR !390 |
2 |
|
Done (2026-05-28) — MR !391 |
3 |
|
Done (2026-05-28) — this MR |
4a |
|
Done (2026-05-28) — this MR |
4b |
|
Done (2026-05-28) — this MR |
5a |
|
Done (2026-05-28) — this MR |
5b |
|
Done (2026-05-28) — this MR |
6 |
|
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:
-
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."
-
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.
Locked decisions
| Decision | Choice |
|---|---|
Intake page surface |
New NOTE: [Erratum 2026-05-28 — supersedes every reference to a |
Intake-section storage |
New |
Intake-section metadata |
|
Data-model boundary |
Shared: ONE applications row per applicant submission ( Per-program: ONE Per-program: Per-program: documents (canopy-applications scopes by Per-worker visibility: MyQueue + the intake URL surface scope to |
Per-program work-queue |
Add |
Keycloak fixtures |
Add two users to |
Invalid claim policy |
Fail-closed: if the |
Determination trigger |
NEW route: |
State machine |
Per-program on |
Verification gate scope |
Per-application, NOT per-(application, program). The |
Verification API extension |
|
Audit case-detail section |
Replace the |
Audit chain endpoint |
|
Audit chain endpoint response shape |
Currently |
Audit Plugin.toml drift fix |
Existing |
Application status vocabulary |
Extend |
processing |
determined |
withdrawn |
denied |
approved |
data_collected`. Extend |
processing |
data_collected |
determined |
approved |
denied |
withdrawn`. Both with same-migration backfill of rogue values + |
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 |
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— currentlyhx-post="/cases/{{ household_id }}/run-determination"; update tohx-post="/applications/{{ application_id }}/run-determination?program={{ active_program }}". -
services/canopy-web/templates/case_detail/_top_bar_actions.html:46— currentlyhx-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_itemssignature:pub async fn fetch_items(clients: &ServiceClients, session: &SessionData) → Vec<WorkQueueItem>(currently(clients)only — line 35). -
fetchalready takes_sessionat line 152 — wire the param intofetch_items. -
Cross-pollinated callers:
services/canopy-web/src/api/cases.rs:83and the command-palette path also usefetch_items. MR3 updates BOTH call sites to passsession. -
ListParamsplural support (NEW): existingListParams(crates/canopy-contracts-applications/src/applications.rs:100) has singularstatus: Option<String>andprogram: Option<String>only. MR3 ADDSstatuses: Vec<String>andprograms: 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 atservices/canopy-applications/src/store/mod.rs:139extends withANY($plural::text[])predicates (andprograms_requested && $plural::text[]array-overlap for programs). -
Status filter: MyQueue calls
/v1/applications?statuses=submitted&statuses=processing&limit=10. Axum’sQuery<ListParams>deserializes repeated-key syntax via#[serde(default)]Vec automatically. -
Programs filter from session: append
&programs=snap&programs=tanffor each value insession.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 oversession.primary_programscalling per-program. ~100 LOC of the MR is the canopy-renewals change. -
Appeals queue:
/v1/appeals/queuehas 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) becomesformat!("/applications/{app_id}/intake/{program_slug}")whereprogram_slugis the worker’s matching primary_program for this app. MR3 derives it: ifprograms_requested ∩ session.primary_programshas exactly one element, use it; if multiple, use the first; if zero (no claim), use the first ofprograms_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:
-
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; -
Contract:
AuditEvent(crates/canopy-contracts-security/src/events.rs:25-45) gainspub household_id: Option<HouseholdId>.AuditListParams(line 57-62) gainspub household_id: Option<HouseholdId>. -
ParsedAuditEventgains a dedicated household_id field (services/canopy-security/src/event_parsing.rs). The dedicated extractor looks ONLY for thehousehold_idfield 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 } } -
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. -
Endpoint filter:
services/canopy-security/src/api/mod.rs::list_eventsaddshousehold_idextraction fromAuditListParams; SQLWHERE ($1::uuid IS NULL OR household_id = $1) …. utoipa decorator updated. -
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-sideON 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 carryAuthenticatedWorkerWithCsrf(mirrorcase_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). UseClaims::require_service_or_caseworker_or_above()atclaims.rs:240— NOT the worker-onlyrequire_caseworker_or_above()atclaims.rs:157. -
Worker role slugs that the combined guard accepts:
caseworker,eligibility_specialist,supervisor,admin,quality_control. -
The audit section endpoint inherits
case-detail-auditplugin’srequired_rolesfrom 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
-
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. -
SSN storage location — RESOLVED 2026-05-27: canopy-persons is the system of record per ADR-001 + ADR-002.
IdentityPayloadreferencesPersonIdand stores only workflow metadata. SSN / DOB / legal name NEVER appear in canopy-applications. Pattern applies to ALL 12 section payloads (workflow-record concept). -
Run-determination selector — RESOLVED 2026-05-27: new route
POST /applications/{id}/run-determination?program={p}per-program scoped. Oldcase_detail.rs:2516handler with the "first-app-by-household" #579 TODO removed. -
Invalid Keycloak claim policy — RESOLVED 2026-05-27: fail-closed (401 on any unparseable slug).
-
State machine column choice — RESOLVED 2026-05-27: per-program on
application_programs.statuswithapplications.statuscontainer-recompute. -
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_programsclaim 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.htmltoken discipline; no inline styles; explicittransitionCSS properties on animated sections (feedback_modal_transition_csp). -
Audit endpoint index cost: adding
household_idfilter 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 sharedvalid_section_payload!macro that mirrors APIvalidator::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,104—CreateApplicationRequest,ListParams(existingprogram: Option<String>singular field kept; newprograms: Vec<String>plural added additively) -
services/canopy-applications/src/api/mod.rs:96-141—routes(); append section routes -
services/canopy-applications/src/store/mod.rs:362-385—record_determination(existing per-program write path; Plan 1 reuses) -
services/canopy-web/src/api/mod.rs:60-63—/applications/{id}/processregistration pattern to mirror for/intake -
services/canopy-web/src/api/applications.rs:55-230—get_process_applicationpattern -
services/canopy-web/src/api/case_detail.rs:2516-2614—run_determinationto REMOVE (replaced by new/applications/{id}/run-determinationroute) -
services/canopy-web/src/dashboard/panels/my_queue.rs:35,71,150-160—fetch_items(flat file); link target/process→/intake/{program_slug} -
services/canopy-web/src/api/cases.rs:83—fetch_itemsreuse call site (must pass session in MR3) -
services/canopy-web/src/session.rs:22,92-126—WorkerRole+SessionDatato extend withprimary_programs -
services/canopy-web/src/case_detail/sections.rs:31-45,69-92,107,166—SectionContext,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:26— EDIT 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:14— EDIT in MR4: retarget to new route -
services/canopy-web/templates/case_detail/_top_bar_actions.html:46— EDIT in MR4: retarget to new route -
services/canopy-security/src/api/mod.rs:285-307—verify_chainretype toChainVerificationResponse -
services/canopy-security/src/event_parsing.rs:12-22—ParsedAuditEventgains dedicatedhousehold_idfield -
services/canopy-security/src/store/mod.rs:81— INSERT binding update for new column -
crates/canopy-contracts-security/src/events.rs:25-45,57-62—AuditEvent,AuditListParams -
crates/canopy-contracts-security/src/paths.rs:24—VERIFY_CHAIN(the right endpoint) -
crates/canopy-auth/src/claims.rs:21,156-251—Claimsstruct to extend; correct RBAC role names live here -
crates/canopy-common/src/id.rs—define_id!macro; MR1 addsDocumentId -
services/canopy-verification/src/api/verifications.rs:24,35— LIST handler; MR2 addsapplication_idfilter -
services/canopy-renewals/…—/v1/renewals/snap/due→/v1/renewals/{program}/due(MR3) -
devstack/keycloak/canopy-realm.json:120-335— Keycloak mappers (addoidc-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.md—cargo nextestonly (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— invokecargo 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.