Plan: cross-program alerts scoped by household assignments (#596)

On this page

Issue: #596 · Approved: 2026-08-19 (two external review rounds; this artifact is the frozen plan) · Spec authority: the maintainer-ratified spec comment on #596 (2026-08-10)

Context

GET /v1/eligibility/cross-program-alerts returns the jurisdiction-wide top-N alert determinations to every authorized caller; #590’s identity gate stops impersonation but not disclosure — a caseworker still sees every household. PUB-1075 AC-6 least-privilege requires assignment scoping. The household_assignments substrate exists in canopy-applications (#408).

The maintainer-ratified spec, implemented verbatim:

  1. Sync per-request lookup — eligibility calls applications' assignments read under its own service token; the household set is pushed into SQL. (Event-fed read-model rejected: no assignment events exist; eventual consistency is wrong for an authz filter.)

  2. Scope-THEN-limit — the household predicate applies before ORDER BY … LIMIT (post-filtering the top-N could starve a caseworker to zero).

  3. Endpoint restructure (#590 Option 2) — caseworker path caller-scoped; the unscoped view moves to a supervisor-only path; supervisor narrowing rides the same lookup. Pre-1.0 breaking; all consumers updated in the same MRs. (Supersedes the issue body’s stale ?worker_id=<self> AC example.)

  4. Fail closed — applications unreachable ⇒ 502, never the unscoped list. No deployment override.

External review deltas folded in before approval: retire-the-old-path deployment safety, service-caller allowlisting, the IDs-only bounded assignment protocol, the scale-safe index + LATERAL query shape, the signature_verified quarantine fix, panel cache hardening, deterministic seed provisioning, the aggregate access-audit event, and the in-process test harness.

Eligibility stays on legacy claims guards — no ReceiverContract adoption (that is #1430; the BFF-trust posture is explicitly interim until it).

Design

D1. Routes — the old path is RETIRED (deployment-safe by construction)

GET /v1/eligibility/cross-program-alerts is removed (pre-1.0, no alias). Two new paths replace it:

Route Gate Behavior

GET /v1/eligibility/workers/{worker_id}/cross-program-alerts?limit=

caseworker-tier: path worker must equal claims.sub else 403. supervisor/admin: any worker (triage narrowing). service: service_id() == "canopy-web" only (403 service_not_allowlisted otherwise)

Scoped feed: assignments lookup → scoped query

GET /v1/eligibility/cross-program-alerts/all?limit=

human require_supervisor_or_above() OR service_id() == "canopy-web"

The unscoped feed. Never touches the assignments client — supervisor triage survives an applications outage

Mixed deployments and rollback fail closed in ANY order: an old eligibility replica 404s the new paths (no service-bypass leak); a new replica 404s the old path for an old BFF. The worker is a typed path param — no Option<Uuid> query ambiguity. resolve_effective_worker(claims, path_worker) replaces enforce_worker_id_identity; a non-UUID claims.sub fails closed 403 (enrollment precedent). Both paths keep the Vec<CrossProgramAlert> feed shape and clamp(1,50) default 10 with the pagination-deviation justification comment (bounded top-N triage feed; rows have identity so ordering gains the id DESC tiebreak, but cursoring a 50-row feed is speculative machinery).

D2. Bounded IDs-only assignment protocol

canopy-applications: GET /v1/workers/{worker_id}/assignments/household-ids — §B4 IDs-only projection, §B2 keyset page ordered by household_id (the active-only partial unique index is covering). Service-gated like its #408 siblings.

canopy-eligibility src/assignments.rs (lib crate): fetch_assigned_household_ids pages to exhaustion under one absolute 3s tokio::time::timeout (token mint + pages
decode — the BFF panel budget is 5s); hard cap MAX_ASSIGNED_HOUSEHOLDS = 5_000 (exceed ⇒ 502 assignment_set_too_large, never silent truncation); ALL failure arms ⇒ ApiError::BadGateway(fixed_client_safe_msg).with_code("applications_unreachable") (upstream bodies never echoed); a consecutive-failure breaker (5 → open 30s) so BFF retries cannot amplify an outage; empty set ⇒ [] without SQL. Metrics: lookup outcome/latency, assignment cardinality, scoped-query latency.

D3. Scoped query + index; the signature_verified quarantine fix

Bulk provenance failures persist quarantined verdicts with signature_verified = false; the alert queries filtered on status alone, so a REJECTED signed denial could surface as a panel alert. Both feed queries gain AND signature_verified (in-scope by plan approval — the same predicate + index this MR rebuilds).

New forward-only migration: replace idx_program_determinations_alert_status with (determined_at DESC, id DESC) WHERE status IN (six) AND signature_verified; add (household_id, determined_at DESC, id DESC) with the same predicate (the scoped-path index — without it a caseload with no recent alerts walks the whole jurisdiction alert history). Scoped SQL: per-household bounded LATERAL over unnest($2::uuid[]) (inner ORDER BY determined_at DESC, id DESC LIMIT $1 on the household index) → outer global top-N. A unit parity test pins both consts to the identical predicate (they are the index predicates). Revocation semantics: the assignments lookup is the authorization linearization point.

D4. Config + threading

applications_url required beside persons_url, boot-validated (url::Url, http(s), no query/fragment, trailing slash normalized); value in config/canopy-eligibility/default.yaml; threaded as the ApplicationsBaseUrl newtype extension (bundle via FromRequestParts if the arg-count budget trips).

D5. Consumers

  • BFF panel — exhaustive WorkerRole match: Supervisor | Admin | StudioAdmin/all?limit=10; Caseworker | EligibilitySpecialist/workers/{session.worker_id}/…?limit=10; Analyst | Auditor | Unprivileged → empty state WITHOUT calling eligibility. The /all branch is client-side trust under the BFF service token (interim ADR-019 posture until #1430). Cache hardening: this authorization-filtered panel bypasses the panel cache unconditionally + an invariant test rejects composition TTL overrides for it; an assign → fetch → unassign → refresh test proves revocation.

  • Worker identity constraint — the substrate keys on UUID-projected issuer subjects; deployments MUST run a single UUID-sub issuer for workers (documented; non-UUID subs fail closed 403). The canonical issuer+subject redesign is #1008 (related).

  • test-liblist_cross_program_alerts_for_worker + list_cross_program_alerts_all replace the single method.

  • Deterministic seed provisioning — assignments modeled in SeedData, rendered byte-identically into canopy_applications.sql; seed-verify gains the household-FK check; seeded households PARTITIONED between two caseworker fixtures so UAT demonstrates mutual exclusion.

D6. Access audit (aggregate)

Both handlers publish one ids-only eligibility.cross_program_alerts.accessed event {actor, effective_worker, scope, assignment_count, result_count}; canopy-security’s wildcard subscriber ingests it. Per-household deny events deliberately NOT emitted (result-set scoping, not a per-household deny).

D7. Production cutover (runbook)

No unscoped-fallback flag exists (ratified). Activation is by provisioning order: provision real assignments BEFORE deploying (zero-assignment workers see an empty panel by design); runbook preflight query (% of alert-active households carrying an active assignment); monitor the new metrics + applications_unreachable rate. Deploy order is free; single-service rollback fails closed.

D8. Deliberate non-goals

Program-level scoping (household assignment is intentionally a whole-household, cross-program grant — the #408 enrollment-gate model); keyset pagination of the feed; ReceiverContract (#1430); assignment events / read-model (rejected in spec); per-household audit events; the full-row assignments route’s ordering.

Tests

Primary harness is deterministic and in-process, DECOMPOSED (recorded deviation from the approved draft’s full-router phrasing): the store legs run the exported SQL against an EphemeralSchema, the client legs run fetch_assigned_household_ids against an in-test axum listener standing in for canopy-applications, and the handler gate matrix is unit-tested — the full-router wiring is covered by the live-devstack legs instead of an in-test auth stack (which the fleet has no precedent for and which would pin nothing the three layers above don’t). Key legs (all landed in tests/alert_scoping_test.rs + tests/determination_index_scan_test.rs
api/handlers.rs unit tests): the limit=1 starvation-proof (newer unassigned alerts must NOT displace the older assigned one — defeats global-limit-then-post-filter); A/B isolation; quarantined rows never appear (both feeds); all failure arms ⇒ coded 502 with the fixed detail; the absolute deadline; the set-size cap; the breaker short-circuit (upstream hit-count proof); caseworker /all ⇒ 403; old path ⇒ 404; both EXPLAIN pins (global rebuild + the scoped LATERAL riding the household partial index, Sort allowed on the bounded outer top-N only); SQL-predicate parity; BFF role-branch URL construction (exhaustive over WorkerRole) + the composition cache-TTL invariant; the two contract-changed integration tests rebuilt; the worker-dashboard e2e tightened — the alerts panel must render populated-or-empty, never the error state.

Delivery

MR Branch Status

1

feat/596-assignments-ids-route — D2’s applications route + contracts + test-lib (Relates to #596)

Done (2026-08-19) — MR !1171, merge 61bfe980

2

feat/596-alerts-assignment-scoping — eligibility restructure + BFF + seed + docs (Closes #596)

Done (2026-08-19) — MR !1172, merge b94ac4e0

Docs in MR-2: OpenAPI regen + the 17→18 path-count assertion; the eligibility api page (gate table, fail-closed + breaker semantics, AC-6 citation, single-issuer constraint); configuration-reference (applications_url); authorization-inventory rows + route census; the stale-comment census (contracts paths.rs, the panel header, roadmap); the D7 runbook section; CHANGELOG Changed/Removed/Fixed.

Verification

In-process suite green; live: partitioned caseworkers see disjoint panels, supervisor /all unaffected while canopy-applications is stopped (caseworker panel shows the error state, never the unscoped list); battery: OpenAPI drift, both EXPLAIN pins, mq-topology (the D6 key), seed-harness byte-identical replay.

Edit this page · default