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:
-
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.)
-
Scope-THEN-limit — the household predicate applies before
ORDER BY … LIMIT(post-filtering the top-N could starve a caseworker to zero). -
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.) -
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 |
|---|---|---|
|
caseworker-tier: path worker must equal |
Scoped feed: assignments lookup → scoped query |
|
human |
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
WorkerRolematch: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-lib —
list_cross_program_alerts_for_worker+list_cross_program_alerts_allreplace the single method. -
Deterministic seed provisioning — assignments modeled in
SeedData, rendered byte-identically intocanopy_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 |
|
Done (2026-08-19) — MR !1171, merge 61bfe980 |
2 |
|
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.