Fleet authorization-branch inventory (OIDC F1a)

On this page

Program: epic &52 — OIDC validation at service boundaries (#1418, F1a). Generated against: main merge 4ecbd749 (2026-08-16), read-verified file:line per entry (the plan-time require_* grep counts were the index, not the target — each section records its delta). This manifest seeds every later migration slice (F1b→C1): a receiver flip consults its service section here; each slice re-verifies and completes its own rows before enforcing.

Machine-readable projection (F4, #1422): canopy_test_lib::conformance::manifest() carries the executable subset of this inventory — route + method + classification + guard citation — and the conformance matrix (Testing › "boundary-auth conformance matrix") asserts each row’s auth classification against the live devstack on every battery. Slices extend the manifest with their service’s routes as they flip.

The four no-actor patterns

How today’s code reads a request whose Claims carry no X-Canopy-Actor:

  1. no-actor-passes — a bare service bearer passes a user-facing decision (exemplar: canopy-web assignments.rs:36).

  2. no-actor-passes-with-audit — passes, but the absence is recorded (exemplar: enrollment gate_household_actor_access, api/mod.rs:123).

  3. no-actor-rejects — refused outright (exemplar: applications documents_scan.rs:29 verified_reviewer).

  4. attribution-resolution — decides WHO to record, never allow/deny (actor().map_or(claims.sub, |a| a.sub) shapes).

An exchanged worker bearer carried claims.actor() == None with the identity in the token itself — all four readings misclassified it; F1b’s EffectiveUser became the single resolution.

Post-C1 status (#1443, 2026-08-24): the four patterns no longer exist outside EffectiveUser internals, and the actor channel is retired (the middleware 401s any request carrying X-Canopy-Actor; Claims::actor and EffectiveUser::ViaActor are deleted — the resolution is total over Direct/System). Where each pattern went:

  1. no-actor-passes → retired outright: the applications assignment mutations are service-caller-only provisioning surfaces (post-C1 a service bearer never transports a human, so the delegated-supervisor bar could never fire again and was deleted).

  2. no-actor-passes-with-audit → enrollment’s gate_household_actor_access already rode EffectiveUser (user_claims() with a system-passes arm, S-enrollment).

  3. no-actor-rejects → the document review trio’s require_service_or_exchanged + in-handler human projection (documents_scan::reviewer_uuid / releasing_supervisor — bare service 403s at the projection), and canopy-api’s admin replay require_admin_human — since #1571 (2026-08-24) a USER-ONLY route on the mounting service’s receiver contract (require_user_only(admin): the AdminRoutes family takes the contract at construction, so all six mounts compose the §C exchanged arm and the S6 broad-audience kill; conformance rows pin every mount).

  4. attribution-resolutionEffectiveUser::attribution_sub() everywhere (sections editor_uuid, the canopy-api idempotency principal, the tanf/medicaid/snap/persons/security attribution sites).

The per-service tables below are the F1a point-in-time record (read-verified at 4ecbd749, 2026-08-16) the slices consumed; they are NOT re-verified against post-C1 HEAD.

Classification vocabulary used below: the four patterns, plus pure-role-gate (role check with no actor semantics), ownership-gate (household/person scope check), audit-only (records, never decides), other (see notes).

Per-service authorization branches

canopy-appeals (31 branches)

Site Construct Classification Route Notes

services/canopy-appeals/src/api/mod.rs:331

require_service_caller

no-actor-passes

POST /v1/appeals (file_appeal)

Bare service bearer passes; claims.actor() never consulted. No worker attribution recorded for the filing act — only household-side requestor_person_id from the body; 'request_received' timeline event is anonymous.

services/canopy-appeals/src/api/mod.rs:586

require_service_caller

no-actor-passes

GET /v1/appeals (list_appeals)

Read-only list; no actor semantics.

services/canopy-appeals/src/api/mod.rs:619

require_service_caller

no-actor-passes

GET /v1/appeals/queue (appeals_queue)

Worker-queue read consumed by the web BFF; a no-actor service bearer sees the jurisdiction-wide queue.

services/canopy-appeals/src/api/mod.rs:672

require_service_caller

no-actor-passes

GET /v1/appeals/hearings/upcoming (upcoming_hearings)

Supervisor-dashboard read; no actor semantics.

services/canopy-appeals/src/api/mod.rs:700

require_service_caller

no-actor-passes

GET /v1/appeals/{id} (get_appeal)

Full appeal + timeline read; no household-ownership gate — any service bearer reads any appeal.

services/canopy-appeals/src/api/mod.rs:742

require_service_caller

no-actor-passes

GET /v1/appeals/{id}/hearing-view (get_appeal_hearing_view)

FTI-safe projection proxy to canopy-snap using appeals' OWN service identity (SnapHearingClient); the worker never reaches the program endpoint. No actor recorded for the access.

services/canopy-appeals/src/api/mod.rs:798

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/schedule (schedule_hearing)

MUTATION WITH ZERO ATTRIBUTION: ScheduleHearingRequest has no actor field; the 'hearing_scheduled' timeline event is anonymous.

services/canopy-appeals/src/api/mod.rs:991

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/decision (record_decision)

Attribution via caller-supplied body string req.actor persisted at :1010 (decision_actor) and echoed in the :1036 timeline note — unverified string, not claims. Also forwarded to enrollment via execute_decision_action_command :1123→send_action_command :1191.

services/canopy-appeals/src/api/mod.rs:1371

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/final-appeal (record_final_appeal)

Body-string req.actor persisted at :1400 (final_appeal_actor) and :1419 timeline; also forwarded to enrollment restay at :1290 (regrant_stay).

services/canopy-appeals/src/api/mod.rs:1560

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/final-decision (record_final_appeal_decision)

Body-string req.actor persisted at :1575 and :1592 timeline; forwarded to enrollment veto/release via send_action_command :1626.

services/canopy-appeals/src/api/mod.rs:1743

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/withdraw (withdraw_appeal)

Body-string req.actor persisted at :1769 (withdrawal_actor) and in the :1776-1796 timeline notes.

services/canopy-appeals/src/api/mod.rs:1837

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/withdraw/confirm (confirm_withdrawal)

Body-string req.actor persisted at :1848 and :1867 timeline.

services/canopy-appeals/src/api/mod.rs:1902

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/withdraw/reinstate (reinstate_appeal)

Body-string req.actor persisted at :1916 and :1928 timeline.

services/canopy-appeals/src/api/mod.rs:2002

require_service_caller

no-actor-passes

PUT /v1/appeals/{id}/withdraw/finalize (finalize_withdrawal)

Body-string req.actor persisted at :2036 and :2054-2059 timeline; forwarded to the post-commit enrollment stay release at :2125. This is the ONLY transition that releases the enrollment stay.

services/canopy-appeals/src/api/mod.rs:2208

require_service_caller

no-actor-passes

POST /v1/appeals/{id}/postponements (record_postponement)

Body-string req.actor persisted at :2228 (store requested_by) and :2245 timeline.

services/canopy-appeals/src/api/mod.rs:2272

require_service_caller

no-actor-passes

POST /v1/internal/appeals/clock-check (trigger_clock_check)

Internal ops trigger; no attribution of who triggered.

services/canopy-appeals/src/api/mod.rs:2304

require_service_caller

no-actor-passes

POST /v1/internal/appeals/reconcile (trigger_reconcile)

Internal ops trigger; 503s when no OIDC service identity is configured (ADR-019). No attribution of who triggered.

services/canopy-appeals/src/cb_stay.rs:82

STAY_ACTOR const ("canopy-appeals", defined :35) passed to client.stay()

audit-only

background stay-retry worker (no inbound route)

Background worker self-labels its stay commands with the hardcoded service name — correct for a worker with no inbound bearer; the ADR-019 service JWT is the authentication.

services/canopy-appeals/src/clients.rs:476

AdverseActionsClient::command(actor: &str) — string-param actor forwarded cross-service

audit-only

outbound PUT enrollment /v1/adverse-actions/{id}/stays/{appeal_id}

THE appeals string-param actor: caller-supplied &str serialized into StayCommandRequest{actor} (:493-496) as a label on stay/restay/release/veto commands. stay() :421-428 and restay() :462-469 delegate here. Enrollment records the authenticated service JWT sub alongside — 'caller-supplied identity is labeling, not authentication' (cb_stay.rs:32-34, #1093). API handlers feed it from unverified body strings (req.actor).

services/canopy-appeals/src/ipv/api.rs:80

require_service_caller

no-actor-passes

POST /v1/ipv/cases (create_referral)

Attribution via typed body field referred_by: PersonId (caller-asserted, persisted :96 and in the :110 timeline) — same trust model as the string actor, just typed.

services/canopy-appeals/src/ipv/api.rs:152

require_service_caller

no-actor-passes

GET /v1/ipv/cases?person_id= (list_cases)

Read; no person/household ownership gate — any service bearer queries any person’s IPV cases.

services/canopy-appeals/src/ipv/api.rs:178

require_service_caller

no-actor-passes

GET /v1/ipv/cases/{id} (get_case)

Read; no ownership gate.

services/canopy-appeals/src/ipv/api.rs:216

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/schedule-adh (schedule_adh)

MUTATION WITH ZERO ATTRIBUTION: no actor field in ScheduleAdhRequest; anonymous timeline event.

services/canopy-appeals/src/ipv/api.rs:264

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/send-notice (send_notice)

MUTATION WITH ZERO ATTRIBUTION (no request body at all).

services/canopy-appeals/src/ipv/api.rs:359

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/record-decision (ipv record_decision)

MUTATION WITH ZERO ATTRIBUTION: RecordAdhDecisionRequest carries decision only; the adh_decision/cleared timeline events name no recorder.

services/canopy-appeals/src/ipv/api.rs:464

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/waiver (record_waiver)

MUTATION WITH ZERO ATTRIBUTION: body bound as _req (ignored); anonymous waiver_signed timeline event.

services/canopy-appeals/src/ipv/api.rs:506

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/impose-disqualification (impose_disqualification)

MUTATION WITH ZERO ATTRIBUTION — the highest-stakes IPV action (12/24-month or permanent disqualification) records no imposing actor anywhere.

services/canopy-appeals/src/ipv/api.rs:625

require_service_caller

no-actor-passes

PUT /v1/ipv/cases/{id}/withdraw (withdraw_case)

MUTATION WITH ZERO ATTRIBUTION (no request body).

services/canopy-appeals/src/ipv/api.rs:664

require_service_caller

no-actor-passes

GET /v1/ipv/disqualifications/active?person_id= (check_active_disqualification)

Read consumed by program services during eligibility; no ownership gate.

services/canopy-appeals/src/ipv/store.rs:22

referred_by: PersonId persisted on ipv_cases

audit-only

store layer (create_referral)

Typed but caller-asserted referral attribution; bound at :47. No verification against claims.

services/canopy-appeals/src/store.rs:284

actor: &str attribution params persisted to appeal_requests columns

audit-only

store layer (all mutating handlers)

Attribution-persistence family, all plain &str with no resolution branch: record_decision :284 (decision_actor field :273, bound :304), record_final_appeal :349/:353, record_final_appeal_decision :400/:405, request_withdrawal :430/:437, confirm_withdrawal :465/:470, reinstate_appeal :495/:498, finalize_withdrawal :523/:527, record_postponement :671 (requested_by, timeline-only — the UPDATE persists no actor column). No store-level role or household-ownership guard exists anywhere in store.rs or ipv/store.rs.

canopy-applications (59 branches)

Site Construct Classification Route Notes

services/canopy-applications/src/api/assignments.rs:36

require_supervisor_actor: claims.actor() None arm ⇒ Ok(())

no-actor-passes

POST /v1/workers/{worker_id}/assignments + DELETE /v1/assignments/{id}

THE in-service no-actor-passes exemplar: a bare service bearer with no X-Canopy-Actor passes a MUTATING assignment decision. Module doc (lines 16-18) declares it intentional for 'pure system traffic — seeding, scheduled assignment workflows'.

services/canopy-applications/src/api/assignments.rs:37

actor.has_role("supervisor") || actor.has_role("admin")

pure-role-gate

POST /v1/workers/{worker_id}/assignments + DELETE /v1/assignments/{id}

Actor-present path: supervisor/admin passes; line 38 Some(_) ⇒ ApiError::Forbidden rejects any other actor.

services/canopy-applications/src/api/assignments.rs:64

require_service_caller

pure-role-gate

POST /v1/workers/{worker_id}/assignments (create_assignment)

services/canopy-applications/src/api/assignments.rs:65

require_supervisor_actor(&claims) call

no-actor-passes

POST /v1/workers/{worker_id}/assignments (create_assignment)

Call site of the line-36 helper.

services/canopy-applications/src/api/assignments.rs:90

require_service_caller

pure-role-gate

DELETE /v1/assignments/{id} (delete_assignment)

services/canopy-applications/src/api/assignments.rs:91

require_supervisor_actor(&claims) call

no-actor-passes

DELETE /v1/assignments/{id} (delete_assignment)

services/canopy-applications/src/api/assignments.rs:121

require_service_caller

pure-role-gate

GET /v1/workers/{worker_id}/assignments (list_assignments_by_worker)

No actor gate on reads — no-actor service bearers read any worker’s caseload.

services/canopy-applications/src/api/assignments.rs:146

require_service_caller

pure-role-gate

GET /v1/households/{household_id}/assignments (list_assignments_by_household)

Hot path consumed by canopy-enrollment.

services/canopy-applications/src/api/authorized_reps.rs:58

require_service_caller

pure-role-gate

POST /v1/households/{household_id}/authorized-representatives (create_rep)

services/canopy-applications/src/api/authorized_reps.rs:85

require_service_caller

pure-role-gate

GET /v1/households/{household_id}/authorized-representatives (list_reps_by_household)

services/canopy-applications/src/api/authorized_reps.rs:112

require_service_caller

pure-role-gate

GET /v1/authorized-representatives/{id} (get_rep)

services/canopy-applications/src/api/authorized_reps.rs:142

require_service_caller

pure-role-gate

PUT /v1/authorized-representatives/{id} (update_rep)

services/canopy-applications/src/api/authorized_reps.rs:170

require_service_caller

pure-role-gate

DELETE /v1/authorized-representatives/{id} (delete_rep)

services/canopy-applications/src/api/documents.rs:143

require_service_caller

pure-role-gate

POST /v1/applications/{id}/documents (upload_document)

Applicant uploads arrive via portal service token with no actor (no applicant actor JWT exists — module doc lines 6-16); uploaded_by_source is caller-supplied form data, not claims-derived.

services/canopy-applications/src/api/documents.rs:315

require_service_caller

pure-role-gate

GET /v1/applications/{id}/documents (list_documents)

services/canopy-applications/src/api/documents.rs:359

require_service_caller

pure-role-gate

GET /v1/applications/{id}/documents/{document_id}/content (get_document_content)

Byte egress gated only on service-class + scan-viewability (line 376 quarantine gate is data-state, not principal); no actor, no ownership check at origin.

services/canopy-applications/src/api/documents.rs:433

require_service_caller

pure-role-gate

POST /v1/applications/{id}/documents/{document_id}/accept (accept_document)

services/canopy-applications/src/api/documents.rs:434

verified_reviewer(&claims) call

no-actor-rejects

POST /v1/applications/{id}/documents/{document_id}/accept

accepted_by from the VERIFIED actor claim, never a request body (#1009).

services/canopy-applications/src/api/documents.rs:467

require_service_caller

pure-role-gate

POST /v1/applications/{id}/documents/{document_id}/reject (reject_document)

services/canopy-applications/src/api/documents.rs:468

verified_reviewer(&claims) call

no-actor-rejects

POST /v1/applications/{id}/documents/{document_id}/reject

rejected_by from the verified actor claim.

services/canopy-applications/src/api/documents_scan.rs:29

verified_reviewer: claims.actor().ok_or(ApiError::Forbidden)

no-actor-rejects

accept/reject document (called from documents.rs:434/468)

The prompt’s no-actor-rejects exemplar (#1009): review without a verifiable human actor is refused; lines 30-35 return 422 when the verified actor sub is not a worker UUID.

services/canopy-applications/src/api/documents_scan.rs:48

verified_supervisor: claims.actor().ok_or(ApiError::Forbidden)

no-actor-rejects

POST /v1/applications/{id}/documents/{document_id}/scan-override

services/canopy-applications/src/api/documents_scan.rs:49

SCAN_OVERRIDE_ROLES.iter().any(|r| actor.has_role(r)) — supervisor|admin

pure-role-gate

POST /v1/applications/{id}/documents/{document_id}/scan-override

Origin-side role enforcement on the VERIFIED actor JWT (const at line 41); the BFF affordance gate is explicitly not the boundary.

services/canopy-applications/src/api/documents_scan.rs:52

Uuid::parse_str(&actor.sub) → 422

attribution-resolution

POST /v1/applications/{id}/documents/{document_id}/scan-override

Projects verified actor sub onto the overridden_by worker-UUID column.

services/canopy-applications/src/api/documents_scan.rs:90

require_service_caller

pure-role-gate

POST /v1/applications/{id}/documents/{document_id}/scan-override (scan_override_document)

services/canopy-applications/src/api/documents_scan.rs:91

verified_supervisor(&claims) call

no-actor-rejects

POST /v1/applications/{id}/documents/{document_id}/scan-override

overridden_by lands on the row + the scan_overridden event (reason digest only, ADR-004).

services/canopy-applications/src/api/documents_scan.rs:168

require_service_caller

pure-role-gate

POST /v1/applications/{id}/documents/{document_id}/rescan (rescan_document)

Mutates scan state and revokes acceptance with NO actor identity required — contrast scan-override. Event trigger recorded as 'manual' only.

services/canopy-applications/src/api/mod.rs:356

require_service_caller

pure-role-gate

POST /v1/applications:batchGet (batch_get_applications)

Deliberately service-tier-only per #1249 least-privilege posture (§B4 bulk read); interactive per-application GET stays broader.

services/canopy-applications/src/api/mod.rs:397

require_service_or_applicant_or_caseworker_or_above

pure-role-gate

POST /v1/applications/{id}/ele-consent (record_ele_consent)

The only route in this service accepting a direct applicant human bearer; also service-class and caseworker-or-above.

services/canopy-applications/src/api/mod.rs:427

consent_recorded_by = Uuid::parse_str(&claims.sub).unwrap_or_else(|_| app.submitted_by.into())

attribution-resolution

POST /v1/applications/{id}/ele-consent

Attribution ONLY — but unlike sections.rs it never consults claims.actor(): a worker attesting through the BFF (service token + actor JWT) is recorded as the SUBMITTER, not the worker; non-UUID sub silently falls back instead of 422ing. Inconsistent with the sections/documents attribution patterns.

services/canopy-applications/src/api/mod.rs:478

require_service_caller

pure-role-gate

POST /v1/applicants/verify-credential (verify_credential)

Applicant has no token at lookup time — this IS how they authenticate; real allow/deny is the store credential check (store/credentials.rs:26).

services/canopy-applications/src/api/mod.rs:509

require_service_caller

pure-role-gate

POST /v1/applicants/drafts (create_draft)

services/canopy-applications/src/api/mod.rs:554

require_service_caller

pure-role-gate

PATCH /v1/applicants/drafts/{id} (patch_draft)

services/canopy-applications/src/api/mod.rs:610

require_service_caller

pure-role-gate

GET /v1/applicants/drafts/{id} (get_draft)

IDOR boundary is explicitly the portal BFF (doc lines 588-592): {id} must come from a freshly verified credential.

services/canopy-applications/src/api/mod.rs:650

require_service_caller

pure-role-gate

POST /v1/applicants/drafts/reap (reap_drafts)

Operator tooling, not applicant-reachable.

services/canopy-applications/src/api/mod.rs:799

require_service_caller

pure-role-gate

POST /v1/applicants/drafts/{id}/finalize (finalize_draft)

services/canopy-applications/src/api/mod.rs:934

require_service_caller

pure-role-gate

POST /v1/applications (create_application)

submitted_by_role is caller-supplied body data validated against a closed set (lines 956-969), NOT claims-derived — spoofable by any service-class caller.

services/canopy-applications/src/api/mod.rs:1067

require_service_caller

pure-role-gate

GET /v1/applications (list_applications)

services/canopy-applications/src/api/mod.rs:1171

require_service_caller

pure-role-gate

GET /v1/applications/caseload-trend (get_caseload_trend)

services/canopy-applications/src/api/mod.rs:1230

require_service_caller

pure-role-gate

GET /v1/applications/{id} (get_application)

services/canopy-applications/src/api/mod.rs:1258

require_service_caller

pure-role-gate

PUT /v1/applications/{id} (update_application)

services/canopy-applications/src/api/mod.rs:1331

require_service_caller

pure-role-gate

DELETE /v1/applications/{id} (withdraw_application)

services/canopy-applications/src/api/mod.rs:1362

require_service_caller

pure-role-gate

POST /v1/applications/{id}/interview/waive (waive_interview)

services/canopy-applications/src/api/mod.rs:1385

require_service_caller

pure-role-gate

POST /v1/applications/{id}/interview/complete (complete_interview)

services/canopy-applications/src/api/mod.rs:1413

require_service_caller

pure-role-gate

POST /v1/applications/{id}/programs/{program}/determination (record_determination)

services/canopy-applications/src/api/recovery.rs:57

require_service_caller

pure-role-gate

POST /v1/applicants/recover/initiate (recover_initiate)

services/canopy-applications/src/api/recovery.rs:70

target.is_recovery_blocked() confidential/kill-lock gate

other

POST /v1/applicants/recover/initiate

Data-state protection gate (not claims-based): confidential cases and kill-switch-locked cases refuse self-serve recovery (impl store/recovery.rs:71-72). Allow/deny decision a migration must keep in scope even though it never touches Claims.

services/canopy-applications/src/api/recovery.rs:172

require_service_caller

pure-role-gate

POST /v1/applicants/recover/kill/{token} (recover_kill)

Real authz is possession of the 256-bit kill-switch token.

services/canopy-applications/src/api/recovery.rs:218

require_service_caller

pure-role-gate

GET /v1/applicants/recover/{recovery_id} (recover_get)

Internal read for the canopy-notices subscriber; exposes kill_switch_token + contact — never applicant-reachable.

services/canopy-applications/src/api/sections.rs:33

editor_uuid: claims.actor().map(|a| a.sub).unwrap_or(claims.sub)

attribution-resolution

PUT /v1/applications/{id}/sections/{program}/{section}

Decides WHO to record as last_edited_by, not allow/deny. Lines 36-40: 422 when the resolved sub is not a UUID (the prompt’s sections.rs:31-41 attribution site). A service bearer with no actor records the service account’s own UUID sub — silent misattribution if a BFF omits the actor header.

services/canopy-applications/src/api/sections.rs:112

require_service_or_caseworker_or_above

pure-role-gate

PUT /v1/applications/{id}/sections/{program}/{section} (upsert_section)

Dual gate: service-class OR caseworker/eligibility_specialist/supervisor/quality_control/admin human bearer.

services/canopy-applications/src/api/sections.rs:113

editor_uuid(&claims) call

attribution-resolution

PUT /v1/applications/{id}/sections/{program}/{section}

Editor UUID feeds the row’s last_edited_by and the application_section.updated event.

services/canopy-applications/src/api/sections.rs:187

require_service_or_caseworker_or_above

pure-role-gate

GET /v1/applications/{id}/sections (list_sections)

services/canopy-applications/src/api/sections.rs:222

require_service_or_caseworker_or_above

pure-role-gate

POST /v1/applications/{id}/programs/{program}/complete-data-collection (complete_data_collection)

No attribution capture on this state transition (event carries no editor).

services/canopy-applications/src/finalize_saga.rs:677

submitted_by_role: "applicant" hardcoded on portal finalize

audit-only

POST /v1/applicants/drafts/{id}/finalize

Constant attribution, no branch: every finalized draft records role 'applicant' (vs create_application where the caller supplies the role).

services/canopy-applications/src/guard.rs:62

require_scanned_uploads boot guard

other

NOT request authz: boot-time fail-closed config guard (noop scanner refused outside development unless CANOPY_APPLICATIONS__ALLOW_INSECURE_SCANNER=true, ADR-041 override pattern). Called from main.rs:118. Plan-time require_* greps sweep it in.

services/canopy-applications/src/main.rs:75

boot.auth.with_actor_verifier(ActorVerifyingKeyRegistry) wiring

attribution-resolution

Middleware-level X-Canopy-Actor verification (#1009/ADR-019): ONLY canopy-web’s web-actor public key is registered (lines 62-74); any other or reformatted key fails closed as UnknownKid, so claims.actor() is None unless canopy-web minted the JWT.

services/canopy-applications/src/store/credentials.rs:26

verify_credential (HH code + Argon2id passcode check)

other

POST /v1/applicants/verify-credential

The applicant’s actual authn/authz decision — credential possession, not Claims. Uniform None on all failure modes; timing-equalized dummy verify for unknown codes (lines 58-64).

services/canopy-applications/src/store/recovery.rs:71

RecoveryTarget::is_recovery_blocked

other

POST /v1/applicants/recover/initiate

confidentiality.disables_self_serve_recovery() || recovery_locked.

canopy-caps (15 branches)

Site Construct Classification Route Notes

services/canopy-caps/src/api/handlers.rs:63

require_service_caller

pure-role-gate

POST /v1/determine

ADR-019 hard cutover (#439): orchestrator-only, service-to-service, not a user-facing decision.

services/canopy-caps/src/api/handlers.rs:165

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}

Dual read; service bearer with no actor passes.

services/canopy-caps/src/api/handlers.rs:203

require_data_steward

pure-role-gate

POST /v1/determinations/{id}/redact

Dedicated-role gate (ADR-036 Decision M); admins do NOT auto-hold it. Principal-class-agnostic: a service token granted the data_steward realm role would also pass — the gate never checks is_service()/actor(). One of the two sites missing from the plan-time count.

services/canopy-caps/src/api/handlers.rs:238

audit-attribution (Some(claims.sub) into determination.redacted event)

attribution-resolution

POST /v1/determinations/{id}/redact

Records claims.sub directly — NOT actor().map_or(claims.sub, …​). If a service bearer ever holds data_steward, the tamper-evident audit event attributes the service, not the human. events.rs:80-92 takes actor_sub: Option<&str> (string param).

services/canopy-caps/src/api/handlers.rs:269

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/authorizations/{id}

Dual read; service bearer passes without actor.

services/canopy-caps/src/api/handlers.rs:299

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations?household_id=X

Household-scoped list; the household_id filter is a query param, not an ownership check — any passing caller can enumerate any household (LIMIT 100).

services/canopy-caps/src/api/handlers.rs:354

require_service_caller

pure-role-gate

POST /v1/authorizations/active:batchGet

Deliberately service-tier (#1249 least-privilege posture, §B4 bulk read): caseworkers excluded by design; 500-id cap.

services/canopy-caps/src/api/handlers.rs:383

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}/authorizations

Dual read; service bearer passes without actor.

services/canopy-caps/src/api/handlers.rs:409

require_service_caller

no-actor-passes

PUT /v1/authorizations/{id}

USER-FACING mutation behind a bare service gate: canopy-web actions_caps.rs:181 PUTs with with_service_identity() and NO X-Canopy-Actor. Worker identity travels as body field updated_by, which canopy-caps EXPLICITLY IGNORES (contracts authorizations.rs:63-65: 'audit lives on canopy-web’s tracing log').

services/canopy-caps/src/api/handlers.rs:440

require_service_caller

no-actor-passes

PUT /v1/authorizations/{id}/provider

Same pattern as update_authorization: BFF-driven worker mutation, service bearer, no actor; switched_by body field ignored server-side (contracts authorizations.rs:85-88).

services/canopy-caps/src/api/providers.rs:49

require_service_caller

pure-role-gate

POST /v1/providers

Provider registry create (#396). No in-repo caller found in canopy-web/portal src — likely seeding/ops; service-to-service in practice.

services/canopy-caps/src/api/providers.rs:92

require_service_caller

no-actor-passes

GET /v1/providers/{id}

Worker-facing read: canopy-web determination_view.rs:874 fetches it to render provider detail, with service identity and no actor.

services/canopy-caps/src/api/providers.rs:117

require_service_caller

pure-role-gate

PUT /v1/providers/{id}

No in-repo caller found; service-tier registry maintenance.

services/canopy-caps/src/api/providers.rs:156

require_service_caller

pure-role-gate

DELETE /v1/providers/{id}

Soft-delete (status=inactive); no in-repo caller found.

services/canopy-caps/src/api/providers.rs:180

require_service_caller

pure-role-gate

GET /v1/providers

List; no in-repo caller found (only the {id} GET is called by canopy-web).

canopy-eligibility (38 branches)

Site Construct Classification Route Notes

services/canopy-eligibility/src/api/bulk_runs.rs:65

claims.require_admin()

pure-role-gate

POST /v1/eligibility/bulk-runs

Human admin role required; a service bearer (no admin role) fails — actor never consulted.

services/canopy-eligibility/src/api/bulk_runs.rs:120

created_by: &claims.sub, created_authz_basis: "role:admin" (store::create_cohort_run)

attribution-resolution

POST /v1/eligibility/bulk-runs

Raw claims.sub recorded; no actor().map_or shape anywhere in this service.

services/canopy-eligibility/src/api/bulk_runs.rs:132

record_action("create", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs

H22 ledger row (actor=claims.sub, basis=basis_of).

services/canopy-eligibility/src/api/bulk_runs.rs:172

claims.require_admin()

pure-role-gate

POST /v1/eligibility/bulk-runs/{id}/enact

Also settings.accept_downstream config-refusal 403 at 179-181 (deployment gate, not caller authz).

services/canopy-eligibility/src/api/bulk_runs.rs:217

store::enact_rearm(…​, &claims.sub, "role:admin", …​)

attribution-resolution

POST /v1/eligibility/bulk-runs/{id}/enact

enacted_by / enact_authz_basis columns (store.rs:1394-1413).

services/canopy-eligibility/src/api/bulk_runs.rs:232

record_action("enact", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs/{id}/enact

Includes accept_preview_failures override detail — audited operator decision (B11).

services/canopy-eligibility/src/api/bulk_runs.rs:271

claims.require_supervisor_or_above()

pure-role-gate

POST /v1/eligibility/bulk-runs/{id}/pause

Supervisor may pause (stays live when bulk core disabled, H18).

services/canopy-eligibility/src/api/bulk_runs.rs:283

record_action("pause", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs/{id}/pause

services/canopy-eligibility/src/api/bulk_runs.rs:318

claims.require_supervisor_or_above()

pure-role-gate

POST /v1/eligibility/bulk-runs/{id}/resume

services/canopy-eligibility/src/api/bulk_runs.rs:331

record_action("resume", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs/{id}/resume

services/canopy-eligibility/src/api/bulk_runs.rs:364

claims.require_admin()

pure-role-gate

POST /v1/eligibility/bulk-runs/{id}/cancel

services/canopy-eligibility/src/api/bulk_runs.rs:367

store::start_drain(…​, Some(&claims.sub))

attribution-resolution

POST /v1/eligibility/bulk-runs/{id}/cancel

canceled_by column (store.rs:1157-1174).

services/canopy-eligibility/src/api/bulk_runs.rs:376

record_action("cancel", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs/{id}/cancel

services/canopy-eligibility/src/api/bulk_runs.rs:415

claims.require_admin()

pure-role-gate

POST /v1/eligibility/bulk-runs/{id}/retry-failures

services/canopy-eligibility/src/api/bulk_runs.rs:460

record_action("retry_failures", &claims, …​)

audit-only

POST /v1/eligibility/bulk-runs/{id}/retry-failures

services/canopy-eligibility/src/api/bulk_runs_read.rs:42

require_reader(&claims)

no-actor-passes

GET /v1/eligibility/bulk-runs/{id}

services/canopy-eligibility/src/api/bulk_runs_read.rs:80

require_reader(&claims)

no-actor-passes

GET /v1/eligibility/bulk-runs

services/canopy-eligibility/src/api/bulk_runs_read.rs:122

require_reader(&claims)

no-actor-passes

GET /v1/eligibility/bulk-runs/{id}/failures

services/canopy-eligibility/src/api/bulk_runs_read.rs:163

require_reader(&claims)

no-actor-passes

GET /v1/eligibility/bulk-runs/{id}/actions

Audit-ledger READ surface — serves actor + authz_basis rows.

services/canopy-eligibility/src/api/bulk_runs_support.rs:74

require_reader: if claims.is_service() { Ok } else require_supervisor_or_above()

no-actor-passes

all four bulk-run GET routes

ANY service bearer with no actor reads run status, failures pages (household_id + application_id rows), and the H22 audit ledger. Human arm is supervisor-or-above.

services/canopy-eligibility/src/api/bulk_runs_support.rs:433

basis_of: claims.has_role("admin") ? "role:admin" : "role:supervisor"

attribution-resolution

bulk-run mutation ledger rows

Decides which basis string to ledger, never allow/deny. Two-valued: a future service-caller mutation would be mislabeled role:supervisor.

services/canopy-eligibility/src/api/bulk_runs_support.rs:446

record_action → store::append_action(&claims.sub, basis_of(claims), …​)

audit-only

bulk-run mutations

Best-effort AFTER the committed transition — a failed append is only an ERROR log (446-471); the mutation stands with a ledger gap.

services/canopy-eligibility/src/api/handlers.rs:120

claims.require_service_or_caseworker_or_above()

no-actor-passes

POST /v1/eligibility/determine

Service arm (is_service) passes with no actor; actor is never consulted (no actor verifier configured in this service, so Claims::actor is always None).

services/canopy-eligibility/src/api/handlers.rs:140

claims.service_id() != Some("canopy-eligibility") → 403

other

POST /v1/eligibility/determine (bulk arm)

Exact-service-identity gate (#1213 D-5): only the service’s own bulk-consumer self-call may carry a bulk-marked body. service_id() falls back to azp when no service: role suffix exists (canopy-auth claims.rs:241-247) — subtle azp-fallback dependence. Rejection is warn-logged with caller id (handlers.rs:141-144).

services/canopy-eligibility/src/api/handlers.rs:195

claims.require_service_or_caseworker_or_above()

no-actor-passes

POST /v1/eligibility/determine/dry-run

Service bearer, no actor, allowed; dry-run is write-free.

services/canopy-eligibility/src/api/handlers.rs:234

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/eligibility/requests/{id}

No tenancy/ownership scoping — any service bearer reads any request.

services/canopy-eligibility/src/api/handlers.rs:258

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/eligibility/requests/{id}/determinations

Same service-open unscoped read.

services/canopy-eligibility/src/api/handlers.rs:281

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/eligibility/results/{application_id}

Same service-open unscoped read.

services/canopy-eligibility/src/api/handlers.rs (resolve_effective_worker)

caseworker-tier: path worker must equal own UUID sub; supervisor/admin: any worker; service: service_id() == "canopy-web" only

ownership-gate

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

#596 replaced the #590 identity gate: the result set is now FILTERED to the effective worker’s active household_assignments (live canopy-applications lookup, fail-closed 502 — never the unscoped list). Foreign services are 403 service_not_allowlisted (least privilege until #1430).

services/canopy-eligibility/src/api/handlers.rs (require_all_feed_caller)

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

no-actor-passes

GET /v1/eligibility/cross-program-alerts/all

The unscoped jurisdiction view moved to this explicit supervisor-only path (#596); never consults assignments. The old /v1/eligibility/cross-program-alerts path is retired (404).

services/canopy-eligibility/src/api/handlers.rs (scoped_cross_program_alerts)

claims.require_service_or_caseworker_or_above() (entry gate before resolve_effective_worker)

no-actor-passes

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

Entry gate; each read publishes the aggregate eligibility.cross_program_alerts.accessed audit event.

services/canopy-eligibility/src/api/handlers.rs:443

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/eligibility/case-status

Unscoped by household ownership; any service bearer can read any household’s status.

services/canopy-eligibility/src/api/handlers.rs:499

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/eligibility/determinations?household_id=

PORTAL-TARGET route: canopy-portal home.rs:247 calls this with a bare service bearer (fetch_json bearer_auth only, no X-Canopy-Actor) — passes on the service arm with no actor. No household-ownership check server-side; the portal BFF scopes household_id itself.

services/canopy-eligibility/src/bulk/admission.rs:171

requested_by = format!("system:cola-redetermination:{run_id}")

attribution-resolution

bulk arm of POST /v1/eligibility/determine

System-principal attribution for bulk-admitted eligibility_requests rows; consumer.rs:256 sets the same string on the frozen dispatch body.

services/canopy-eligibility/src/bulk/arm.rs:179

verify_dispatch_binding: outer_bound requires request.requested_by == system_principal (+ app/household/programs match, 180-198)

other

bulk arm of POST /v1/eligibility/determine

Context-binding integrity gate on the self-call body vs the durable case row — not claims-based; 422 bulk_context_mismatch on drift.

services/canopy-eligibility/src/bulk/store.rs:1040

append_action(actor, authz_basis, reason, …​) INSERT bulk_run_actions

audit-only

bulk-run mutations (store level)

The H22 ledger table write; actor column is a plain string.

services/canopy-eligibility/src/bulk/store.rs:1394

enact_rearm sets enacted_by/enact_authz_basis/enacted_at

attribution-resolution

POST /v1/eligibility/bulk-runs/{id}/enact (store level)

services/canopy-eligibility/src/store/mod.rs:37

create_eligibility_request(requested_by: &str) → eligibility_requests.requested_by

attribution-resolution

POST /v1/eligibility/determine

STRING-PARAM ACTOR: the value is the caller-supplied BODY field DetermineRequest.requested_by (crates/canopy-contracts-eligibility/src/determine.rs:34, 'for audit attribution'), threaded via orchestrator.rs:1296 — attribution is self-declared, never derived from claims/actor.

canopy-enrollment (27 branches)

Site Construct Classification Route Notes

services/canopy-enrollment/src/api/adverse_actions.rs:81

require_service_caller

no-actor-passes

POST /v1/adverse-actions (schedule_adverse_action)

Bare service gate; see the companion attribution finding at line 151 — the action row’s actor is caller-supplied text with NO authenticated-principal binding.

services/canopy-enrollment/src/api/adverse_actions.rs:151

audit-attribution: params.actor = req.actor.clone() (also exemption.actor at lines 142-145)

attribution-resolution

POST /v1/adverse-actions (schedule_adverse_action)

INCONSISTENT with the sibling commands: the scheduled action row persists caller-supplied req.actor (and exemption authority/actor) verbatim — no claims.sub prefix — while cancel/stay/reopen persist format!("sub={}; actor={}"). Caller-supplied identity is labeling (#1093 lesson cited at adverse_actions.rs:430-432) yet here it is the ONLY attribution on the row.

services/canopy-enrollment/src/api/adverse_actions.rs:283

require_service_caller

no-actor-passes

GET /v1/adverse-actions (list_adverse_actions)

Bare service gate; cursor-paginated global/household list, unaudited.

services/canopy-enrollment/src/api/adverse_actions.rs:335

require_service_caller

no-actor-passes

GET /v1/adverse-actions/{id} (get_adverse_action)

Bare service gate; the lookup canopy-appeals validates filings against.

services/canopy-enrollment/src/api/adverse_actions.rs:373

require_service_caller

no-actor-passes

POST /v1/adverse-actions/{id}/cancel (cancel_adverse_action)

Bare service gate; attribution resolved at line 374.

services/canopy-enrollment/src/api/adverse_actions.rs:374

audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor)

attribution-resolution

POST /v1/adverse-actions/{id}/cancel

Records the SERVICE token’s sub (the BFF/caller service principal) + caller-supplied display text. NOT the actor().map_or(claims.sub, |a| a.sub) shape — claims.actor() is never consulted, so a propagated X-Canopy-Actor human identity is ignored in favor of the request-body string.

services/canopy-enrollment/src/api/adverse_actions.rs:423

require_service_caller

no-actor-passes

PUT /v1/adverse-actions/{id}/stays/{appeal_id} (stay_adverse_action)

Bare service gate on the fenced stay/restay/release/veto commands; attribution resolved at line 433.

services/canopy-enrollment/src/api/adverse_actions.rs:433

audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor)

attribution-resolution

PUT /v1/adverse-actions/{id}/stays/{appeal_id}

Same shape as cancel: authenticated principal = service JWT sub, human actor = untrusted display text ('#1093 taught us caller-supplied identity is labeling', lines 430-432). claims.actor() not consulted.

services/canopy-enrollment/src/api/adverse_actions.rs:479

require_service_caller

no-actor-passes

GET /v1/adverse-actions/{id}/stays/{appeal_id} (get_appeal_stay)

Bare service gate; read-only reconciliation ground truth for canopy-appeals, no signal appended.

services/canopy-enrollment/src/api/enact_sweep.rs:32

require_service_caller

no-actor-passes

POST /v1/adverse-actions/enact-sweep (trigger_enact_sweep)

Bare service gate on the on-demand enact-sweep pass (operator/journey trigger); no actor, no attribution — sweep outcomes carry system attribution internally.

services/canopy-enrollment/src/api/mod.rs:123

claims.actor() (let-else in gate_household_actor_access)

no-actor-passes

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

No actor → return Ok(()) unconditionally ('pure system traffic — scheduled jobs / the applicant-portal BFF'). READ-VERIFIED DISCREPANCY vs the plan’s 'no-actor-passes-with-audit' label: the no-actor arm emits NO audit row recording the absence — audit_household_read (mod.rs:176-204) fires ONLY when an actor is present, and the doc-comment at mod.rs:172-175 explicitly says an actor-less read 'is not audited here'. The audit instrumentation covers only the actor-present arms (allow → enrollment.household_issuance.read; deny → .access_denied).

services/canopy-enrollment/src/api/mod.rs:126

actor.has_role("supervisor") || actor.has_role("admin")

pure-role-gate

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

Worker-actor role bypass of the household-assignment check inside gate_household_actor_access; supervisor/admin pass unaudited at this branch (allow-path audit still fires later via audit_household_read).

services/canopy-enrollment/src/api/mod.rs:129

actor.sub.parse::<Uuid>() → Forbidden on failure

ownership-gate

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

Fail-closed prelude to the assignment lookup: an actor whose sub is not a UUID is denied (tracing::warn + 403) — string-form actor subs are unsupported here. No audit event on this deny arm (the access_denied audit fires only on the assignment-miss path below).

services/canopy-enrollment/src/api/mod.rs:145

is_worker_assigned_to_household (canopy-applications RBAC lookup)

ownership-gate

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

#408 Pub 1075 AC-6 least-privilege: non-supervisor worker actor must hold an active assignment in canopy-applications (clients/mod.rs is_worker_assigned_to_household, called with a freshly minted service token — inbound bearer NOT forwarded). assigned → Ok; not assigned → falls to the audited deny at mod.rs:151-169.

services/canopy-enrollment/src/api/mod.rs:153

events::publish_household_issuance_access_denied + Err(Forbidden) at mod.rs:169

audit-only

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

Deny-path audit: best-effort one-shot tx staging enrollment.household_issuance.access_denied (worker_id, household_id, full roles Vec — events.rs:174-191); every failure is warn-and-continue, then the 403 returns regardless. The deny decision is the API contract; the audit never blocks it.

services/canopy-enrollment/src/api/mod.rs:182

claims.actor() + actor.sub.parse() guard in audit_household_read (audit-attribution)

attribution-resolution

GET /v1/households/{household_id}/issuances + GET /v1/households/{household_id}/annual-summary

Allow-path #408 audit: fires ONLY when a worker actor with a UUID sub is present — records worker_id + comma-joined role_summary via enrollment.household_issuance.read (events.rs:150-167). Actor-less (portal BFF / system) reads are deliberately unaudited; an actor with a non-UUID sub silently skips the audit (cannot occur in practice — the gate already 403’d that shape at mod.rs:129). Best-effort: warn-and-continue on failure.

services/canopy-enrollment/src/api/mod.rs:338

require_service_caller

no-actor-passes

POST /v1/enrollments (create_enrollment)

Bare service gate; actor never consulted. Service-tier write (auto-enroll/orchestrator surface).

services/canopy-enrollment/src/api/mod.rs:396

require_service_caller

no-actor-passes

GET /v1/enrollments?household_id= (list_enrollments)

Bare service gate; actor never consulted; unaudited household-scoped read (contrast the #408-gated per-household issuance reads).

services/canopy-enrollment/src/api/mod.rs:420

require_service_caller

no-actor-passes

GET /v1/enrollments/{id} (get_enrollment)

Bare service gate; actor never consulted.

services/canopy-enrollment/src/api/mod.rs:449

require_service_caller

no-actor-passes

POST /v1/enrollments/{id}/issue (issue_benefits)

Bare service gate on a money-moving EBT issuance; no actor consulted, no attribution recorded on the issuance row.

services/canopy-enrollment/src/api/mod.rs:681

require_service_caller

no-actor-passes

GET /v1/enrollments/{id}/issuances (list_issuances)

Bare service gate; deliberately unaudited service-tier read (per the mod.rs:769-779 doc on the batch endpoint’s posture).

services/canopy-enrollment/src/api/mod.rs:715

require_service_caller (ADR-019 bearer gate, ahead of the #408 gate)

no-actor-passes

GET /v1/households/{household_id}/issuances (list_issuances_for_household)

First of two layers: service-class bearer required, THEN gate_household_actor_access (mod.rs:718-726) modulates by actor; allow path audited via audit_household_read at mod.rs:746.

services/canopy-enrollment/src/api/mod.rs:801

require_service_caller

no-actor-passes

POST /v1/households/issuances:batchGet (batch_get_household_issuances)

§B4 bulk read, deliberately OUTSIDE the #408 gate/audit (doc mod.rs:769-779: emits ZERO #408 events by construction, pinned by test). Reporting-pipeline system read; a migration slice must preserve the actor-less path here.

services/canopy-enrollment/src/api/mod.rs:851

require_service_caller (ahead of the #408 gate at mod.rs:852-860)

no-actor-passes

GET /v1/households/{household_id}/annual-summary (get_household_annual_summary)

Same two-layer shape as the issuance list: service bearer, then gate_household_actor_access; allow path audited at mod.rs:882. This is the ONE enrollment route the applicant portal calls (actor-less BFF read).

services/canopy-enrollment/src/api/mod.rs:913

require_service_caller

no-actor-passes

POST /v1/enrollments/{id}/terminate (terminate_enrollment)

410 Gone tombstone (#1095 — direct termination removed) but still auth-gated: a non-service caller gets 403 before the 410. Keep the gate when migrating so the tombstone doesn’t become an unauthenticated probe surface.

services/canopy-enrollment/src/api/reopen.rs:75

require_service_caller

no-actor-passes

POST /v1/adverse-actions/{id}/reopen (reopen_adverse_action)

Bare service gate; canopy-renewals is the intended caller. Attribution resolved at line 78.

services/canopy-enrollment/src/api/reopen.rs:78

audit-attribution: format!("sub={}; actor={}", claims.sub, req.actor)

attribution-resolution

POST /v1/adverse-actions/{id}/reopen

Same sub+display-text shape as cancel/stay; persisted on the append-only reopen row (adverse_actions.rs:1704-1712) and the 'reopened' signal. claims.actor() not consulted.

canopy-medicaid (40 branches)

Site Construct Classification Route Notes

services/canopy-medicaid/src/api/cmd_handlers.rs:81

require_service_caller

no-actor-passes

POST /v1/cmd/ingest

Service-only (#448, worker-portal-initiated via canopy-web). Actor never consulted; attribution instead rides the request body (submitted_by, line 94).

services/canopy-medicaid/src/api/cmd_handlers.rs:94

audit-attribution (body string req.submitted_by persisted)

other

POST /v1/cmd/ingest

STRING-PARAM ACTOR: caller-supplied submitted_by is inserted into medicaid_cmd_events.submitted_by with no cross-check against Claims/actor — a migration slice should derive this from the verified actor.

services/canopy-medicaid/src/api/cmd_handlers.rs:118

require_service_caller

no-actor-passes

POST /v1/determinations/{id}/requeue

Service-only operator action (#392 quarantine resolution via canopy-web). Actor never consulted.

services/canopy-medicaid/src/api/cmd_handlers.rs:149

audit-attribution (body string req.resolved_by, tracing only)

audit-only

POST /v1/determinations/{id}/requeue

STRING-PARAM ACTOR: resolved_by comes from the request body and is only tracing::info-logged (no DB row); trusted from the caller.

services/canopy-medicaid/src/api/fti_audit_handlers.rs:57

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log

Dedicated fti_auditor role per Pub 1075 par9 (#383); admin explicitly excluded. Bearer realm roles only.

services/canopy-medicaid/src/api/fti_audit_handlers.rs:94

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log/{id}

Same dedicated-role separation.

services/canopy-medicaid/src/api/fti_audit_handlers.rs:121

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log/summary

Same dedicated-role separation.

services/canopy-medicaid/src/api/handlers.rs:69

require_service_caller

no-actor-passes

POST /v1/determine

ADR-019 hard cutover (#439): service-only. Service bearer with no actor passes; actor is optional and only feeds the accessed_by attribution at 84-87. No audit of actor absence.

services/canopy-medicaid/src/api/handlers.rs:84

actor().map_or(claims.sub, |a| a.sub) → accessed_by

attribution-resolution

POST /v1/determine

FTI accessed_by attribution (ADR-028 par52/ADR-014), lines 84-87 — the site named in the task. DEAD-ARM IN PRACTICE: medicaid never wires AuthLayer::with_actor_verifier (bootstrap default AuthLayer::new, crates/canopy-api/src/bootstrap.rs:180), so actor() is ALWAYS None here and accessed_by always records the calling service’s sub, never the on-behalf-of worker; a caller sending X-Canopy-Actor gets 401 from shared middleware (crates/canopy-auth/src/middleware.rs:139-145).

services/canopy-medicaid/src/api/handlers.rs:161

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}

Transitional ADR-019 dual gate: is_service() short-circuits with no actor check; user bearer needs caseworker+ realm role (has_role checks bearer roles only, claims.rs:148-150, 266-274).

services/canopy-medicaid/src/api/handlers.rs:201

require_data_steward

pure-role-gate

POST /v1/determinations/{id}/redact

Dedicated data_steward realm role (ADR-036 Decision M); admins do NOT auto-hold it. Checks bearer realm roles only — a generic service token is rejected; actor never consulted.

services/canopy-medicaid/src/api/handlers.rs:236

audit-attribution (claims.sub into determination.redacted event)

attribution-resolution

POST /v1/determinations/{id}/redact

Records Some(claims.sub) as the redacting steward on the tamper-evident event — NO actor() resolution (inconsistent with the 84-87 pattern). Safe today because the gate is a human role, but a service token granted data_steward would attribute as the service.

services/canopy-medicaid/src/api/handlers.rs:292

require_service_caller (month-arm)

no-actor-passes

GET /v1/determinations?month=

#1249 split gate: the month T-MSIS federal-universe param requires a service caller (reporting extractor); param-conditional arm at 291-295. No actor consulted.

services/canopy-medicaid/src/api/handlers.rs:294

require_service_or_caseworker_or_above (unscoped arm)

no-actor-passes

GET /v1/determinations

Else-arm of the same 291-295 split: interactive unscoped list keeps the dual gate; service bearer passes with no actor.

services/canopy-medicaid/src/api/handlers.rs:364

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/applications/{id}/categories

Dual gate; service arm actor-blind.

services/canopy-medicaid/src/api/handlers.rs:400

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}/explanation

Dual gate; service arm actor-blind.

services/canopy-medicaid/src/api/handlers.rs:433

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/tma

Dual gate; household_id is a raw query param with NO household-ownership check — any caseworker or service token can read any household’s TMA rows.

services/canopy-medicaid/src/api/handlers.rs:468

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/ele/{person_id}

Dual gate; no person/household ownership check.

services/canopy-medicaid/src/api/handlers.rs:496

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/ele/chain-status

Dual gate on a chain-integrity probe (no PII in response).

services/canopy-medicaid/src/api/handlers.rs:545

require_admin_or_quality_control

pure-role-gate

POST /v1/ele/{person_id}/revoke

Manual worker action (Pub-1075-relevant); bearer realm roles only, actor never consulted; generic service tokens rejected.

services/canopy-medicaid/src/api/handlers.rs:573

audit-attribution (Uuid::parse_str(claims.sub) → actor_id)

attribution-resolution

POST /v1/ele/{person_id}/revoke

Lines 573-582: bearer sub parsed as UUID for the hash-chained revoke event’s actor_id; non-UUID sub → actor_id=None with a loud warn (attribution dropped, action proceeds). No actor() resolution.

services/canopy-medicaid/src/api/handlers.rs:661

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/ele/household/{household_id}

Dual gate; no household-ownership check (worker-portal rollup).

services/canopy-medicaid/src/api/handlers.rs:720

require_admin_or_quality_control

pure-role-gate

POST /v1/ele/renewals/run

Admin/QC ops affordance triggering the scheduler tick; the resulting chain events carry actor_id=None (scheduler.rs:305) — the triggering admin is NOT attributed on the rows.

services/canopy-medicaid/src/api/overpayments_handler.rs:89

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments

Dual gate; canopy-reporting drains this with a service token (no actor) per ADR-001 Amendment 1 par-B2/B3.

services/canopy-medicaid/src/api/overpayments_handler.rs:127

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments

WRITE endpoint (files a claim) with the actor-blind service arm; no attribution of who filed is captured from Claims at all.

services/canopy-medicaid/src/api/overpayments_handler.rs:152

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}

Dual gate.

services/canopy-medicaid/src/api/overpayments_handler.rs:181

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/repayment-plans

WRITE with actor-blind service arm; no Claims-derived attribution persisted.

services/canopy-medicaid/src/api/overpayments_handler.rs:209

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/recoupments

WRITE (ledger append) with actor-blind service arm; no Claims-derived attribution persisted.

services/canopy-medicaid/src/api/overpayments_handler.rs:234

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}/ledger

Dual gate.

services/canopy-medicaid/src/determine.rs:619

CallerContext.accessed_by (attribution plumbing)

audit-only

POST /v1/determine

Threads the handler-resolved accessed_by (actor-else-service sub) into persist_determinations (line 703) for the ADR-014 chain writes. Not a gate.

services/canopy-medicaid/src/determine.rs:732

audit-attribution (accessed_by into FtiAuditEntry)

audit-only

POST /v1/determine

medicaid_snapshot_chain_entry (717-747) stamps accessed_by on each determination-snapshot FTI chain entry (FtiAction::Write, resource determination_snapshot).

services/canopy-medicaid/src/determine.rs:846

audit-attribution (chain entry append per snapshot)

audit-only

POST /v1/determine

One chain entry per FTI-bearing snapshot appended in the same all-or-nothing tx (append_determination_chain_entries at 848; mode-split v1/v2 per #1207).

services/canopy-medicaid/src/ele_audit.rs:119

audit-attribution (actor_id canonicalized into chain hash)

audit-only

Lines 119-135: actor_id (or the None sentinel) is hashed into the ADR-014 ELE event hash — attribution is tamper-evident once written.

services/canopy-medicaid/src/main.rs:182

audit-attribution (actor_id: None on source-closed lapse event)

audit-only

Bus subscriber (snap/tanf case-closed) writes Lapsed chain events with actor_id=None — attribution to the originating event/service is only in the JSON payload.

services/canopy-medicaid/src/main.rs:795

audit-attribution (actor_id: None on ELE grant event)

audit-only

SNAP/TANF-approval GRANT subscriber persists Granted events with actor_id=None; provenance carried as source_program/source_determination_id instead.

services/canopy-medicaid/src/scheduler.rs:305

audit-attribution (actor_id: None on renewal chain event)

audit-only

Daily/manual ELE renewal sweep writes system-initiated Renewed events with actor_id=None — no system sentinel, and a manual /ele/renewals/run trigger loses the admin’s identity.

services/canopy-medicaid/src/scheduler.rs:425

audit-attribution (actor_id: None on expiry-lapse chain event)

audit-only

Expiry-driven Lapsed events: system-initiated, actor_id=None.

services/canopy-medicaid/src/store/ele.rs:846

audit-attribution (actor_id bound into ele_grant_events insert)

audit-only

Store-level persistence of the hash-chained event’s actor_id (Option<Uuid>); NULL for all system/subscriber paths.

services/canopy-medicaid/src/store/fti.rs:44

fti_audited(pool, accessed_by, …​) wrapper

audit-only

read_fti_tax_data: every FTI read audited with accessed_by per Pub 1075. Currently #[expect(dead_code)] — wiring tracked by #785; the identity a future caller threads here is a migration concern.

services/canopy-medicaid/src/store/fti.rs:88

fti_audited(pool, accessed_by, …​) wrapper

audit-only

read_fti_for_magi: same audited-read wrapper, also dead_code pending #785.

canopy-notices (9 branches)

Site Construct Classification Route Notes

services/canopy-notices/src/api/mod.rs:124

require_service_caller

no-actor-passes

POST /v1/documents/render

ADR-029 signed-document render (audit citation). Worker-triggered via canopy-web (api/audit_log.rs:624) with a bare service bearer; handler never reads actor(); the JWS signs inputs, not the requesting human.

services/canopy-notices/src/api/mod.rs:197

require_service_caller

pure-role-gate

POST /v1/notices

generate_notice — machine surface (orchestrator/event-driven callers). No user decision flows through; no actor concept in the request.

services/canopy-notices/src/api/mod.rs:239

require_service_caller

no-actor-passes

GET /v1/notices

list_notices — portal BFF lists an applicant household’s notices (portal notices.rs:228) and canopy-web lists for workers, both with bare service tokens. household_id is an UNVERIFIED query filter; no actor requirement, no audit of absence.

services/canopy-notices/src/api/mod.rs:286

require_service_caller

no-actor-passes

GET /v1/notices/{id}

get_notice — any service bearer can read any notice+appeal-rights by id. Portal uses this as its OWN ownership pre-check (portal notices.rs:180); the service enforces nothing per-household.

services/canopy-notices/src/api/mod.rs:321

require_service_caller

no-actor-passes

GET /v1/notices/{id}/pdf

get_notice_pdf — PII-bearing PDF streamed (or on-demand rendered) for any service bearer, no actor, no per-request audit row in this handler.

services/canopy-notices/src/api/mod.rs:390

require_service_caller

no-actor-passes

POST /v1/notices/{id}/mark-read

mark_notice_read — an APPLICANT user action persisted with zero actor attribution. Doc comment (lines 381-384) explicitly delegates the IDOR boundary to the portal BFF; the service records read_at with no record of who.

services/canopy-notices/src/api/mod.rs:416

require_service_caller

pure-role-gate

POST /v1/notices/{id}/resend

resend_notice — no in-tree BFF caller found (grep of canopy-web + canopy-portal); operational/test surface. Re-queues with delivery channel literal "test" (line 426).

services/canopy-notices/src/api/mod.rs:448

require_service_caller

pure-role-gate

GET /v1/notices/queue

list_delivery_queue — machine delivery-worker surface; no user context possible.

services/canopy-notices/src/recipient.rs:60

household-membership check (person.household_id != Some(household_id) → NotInHousehold error)

ownership-gate

NON-claims, worker-side guard on the notice-generation path: recipient person must belong to the target household or generation fails (notice-misdirection defense). Not an HTTP authz branch but a real ownership control a migration must not regress.

canopy-persons (58 branches)

Site Construct Classification Route Notes

services/canopy-persons/src/api/export.rs:170

require_admin_or_quality_control

pure-role-gate

GET /v1/export/persons (export_persons)

Bearer realm-role gate (admin|quality_control); actor never consulted. A service token carrying those realm roles would also pass — user-only by intent, not by is_service() exclusion.

services/canopy-persons/src/api/export.rs:249

audit-attribution (persons.export.requested payload "actor": claims.sub)

audit-only

Export-of-the-export event; publish failure is warn-and-continue (export.rs:264-271) — best-effort, unlike the fail-closed ssn.accessed.

services/canopy-persons/src/api/mod.rs:255

require_service_caller

no-actor-passes

POST /v1/persons (create_person)

Service-tier gate only; claims.actor() never consulted. Downstream Pub-1075 ssn.accessed audit (line 265-272) attributes claims.sub — the service sub when no actor.

services/canopy-persons/src/api/mod.rs:258

require_finalize_caller (conditional: step.is_some())

pure-role-gate

POST /v1/persons (create_person)

Service-identity gate: service_id() must be canopy-applications; runs only when X-Canopy-Finalize-* headers present. Actor never consulted.

services/canopy-persons/src/api/mod.rs:420

audit-attribution (audit_ssn_access, actor_sub = claims.sub at all 7 call sites)

audit-only

Pub-1075 ssn.accessed staged fail-closed AFTER projection. NO actor() resolution anywhere in the service — no actor().map_or(claims.sub,…​) shape exists; an on-behalf-of actor is never recorded. Call sites: mod.rs:265, 500, 539, 584, 869, 954; export.rs:217.

services/canopy-persons/src/api/mod.rs:483

require_service_caller

no-actor-passes

GET /v1/persons (list_persons)

SSN audit at 500-507 attributes claims.sub raw.

services/canopy-persons/src/api/mod.rs:531

require_service_caller

no-actor-passes

GET /v1/persons/{id} (get_person)

PORTAL TARGET: canopy-portal calls this with a bare service bearer and no X-Canopy-Actor. SSN audit at 539-546 attributes claims.sub (portal’s service sub, not the applicant).

services/canopy-persons/src/api/mod.rs:572

require_service_caller

no-actor-passes

PUT /v1/persons/{id} (update_person)

SSN audit at 584-591 attributes claims.sub raw.

services/canopy-persons/src/api/mod.rs:613

require_service_caller

no-actor-passes

DELETE /v1/persons/{id} (delete_person)

Soft delete; no audit event at all — no attribution recorded for the delete.

services/canopy-persons/src/api/mod.rs:642

require_service_caller

no-actor-passes

POST /v1/households (create_household)

services/canopy-persons/src/api/mod.rs:645

require_finalize_caller (conditional)

pure-role-gate

POST /v1/households (create_household)

services/canopy-persons/src/api/mod.rs:710

require_service_caller

no-actor-passes

GET /v1/households/{id} (get_household)

services/canopy-persons/src/api/mod.rs:777

require_fact_ownership (definition)

ownership-gate

ADR-027 D11: supplied fact_id must belong to path person_id else 404 (no cross-person existence leak). Table name is a fixed handler literal. Data-tenancy within a person, NOT caller scoping.

services/canopy-persons/src/api/mod.rs:834

require_service_caller

no-actor-passes

GET /v1/households/{id}/full (get_household_full)

SSN audit at 869-876 attributes claims.sub raw (BatchLookup purpose).

services/canopy-persons/src/api/mod.rs:903

require_service_caller

no-actor-passes

POST /v1/persons:batchGet (batch_get_persons)

SSN audit at 953-962 fires only when projection.ssn requested; attributes claims.sub raw.

services/canopy-persons/src/api/mod.rs:986

require_service_caller

no-actor-passes

POST /v1/households:batchGet (batch_get_households)

#1249 least-privilege posture; deliberately zero Pub-1075 events (no sealed value opened, comment 993-997).

services/canopy-persons/src/api/mod.rs:1010

require_member_ownership (definition)

ownership-gate

Household-scoped twin; returns stored person_id (immutable subject).

services/canopy-persons/src/api/mod.rs:1106

require_service_caller

no-actor-passes

POST /v1/households/{id}/members/claims (claim_household_member)

Attribution comes from body-supplied req.author, never from Claims/actor.

services/canopy-persons/src/api/mod.rs:1109

require_finalize_caller (conditional)

pure-role-gate

POST /v1/households/{id}/members/claims

services/canopy-persons/src/api/mod.rs:1122

require_member_ownership

ownership-gate

POST /v1/households/{id}/members/claims

Correction fact_id must belong to path household (404 on miss, D11); stored person_id authoritative; 422 at 1123-1127 blocks reassignment.

services/canopy-persons/src/api/mod.rs:1177

require_service_caller

no-actor-passes

DELETE /v1/households/{id}/members/claims/{fact_id} (close_household_member_claim)

Close event author is None (events.rs:214 'ADR-019 on-behalf-of limitation').

services/canopy-persons/src/api/mod.rs:1182

require_member_ownership

ownership-gate

DELETE /v1/households/{id}/members/claims/{fact_id}

services/canopy-persons/src/api/mod.rs:1273

require_service_caller

no-actor-passes

POST /v1/persons/{id}/addresses/claims (claim_address)

services/canopy-persons/src/api/mod.rs:1276

require_finalize_caller (conditional)

pure-role-gate

POST /v1/persons/{id}/addresses/claims

services/canopy-persons/src/api/mod.rs:1286

require_fact_ownership

ownership-gate

POST /v1/persons/{id}/addresses/claims

Only when correction fact_id supplied.

services/canopy-persons/src/api/mod.rs:1334

require_service_caller

no-actor-passes

DELETE /v1/persons/{id}/addresses/claims/{fact_id} (close_address_claim)

Close event author None (events.rs:339).

services/canopy-persons/src/api/mod.rs:1337

require_fact_ownership

ownership-gate

DELETE /v1/persons/{id}/addresses/claims/{fact_id}

services/canopy-persons/src/api/mod.rs:1376

require_service_caller

no-actor-passes

GET /v1/persons/{id}/income (list_income)

services/canopy-persons/src/api/mod.rs:1404

require_service_caller

no-actor-passes

GET /v1/persons/{id}/assets (list_assets)

services/canopy-persons/src/api/mod.rs:1430

require_service_caller

no-actor-passes

GET /v1/persons/{id}/expenses (list_expenses)

services/canopy-persons/src/api/mod.rs:1443

reject_system_author (body-supplied Author integrity gate)

other

Blocks Author::System on fact claims (ADR-027 §1). req.author is CALLER-SUPPLIED and never cross-checked against Claims or actor — the service trusts the calling service’s stated author. Call sites: 1116, 1283, 1634, 1700, 1765.

services/canopy-persons/src/api/mod.rs:1624

require_service_caller

no-actor-passes

POST /v1/persons/{id}/income/claims (claim_income)

services/canopy-persons/src/api/mod.rs:1627

require_finalize_caller (conditional)

pure-role-gate

POST /v1/persons/{id}/income/claims

services/canopy-persons/src/api/mod.rs:1642

require_fact_ownership

ownership-gate

POST /v1/persons/{id}/income/claims

services/canopy-persons/src/api/mod.rs:1690

require_service_caller

no-actor-passes

POST /v1/persons/{id}/assets/claims (claim_asset)

services/canopy-persons/src/api/mod.rs:1693

require_finalize_caller (conditional)

pure-role-gate

POST /v1/persons/{id}/assets/claims

services/canopy-persons/src/api/mod.rs:1707

require_fact_ownership

ownership-gate

POST /v1/persons/{id}/assets/claims

services/canopy-persons/src/api/mod.rs:1755

require_service_caller

no-actor-passes

POST /v1/persons/{id}/expenses/claims (claim_expense)

services/canopy-persons/src/api/mod.rs:1758

require_finalize_caller (conditional)

pure-role-gate

POST /v1/persons/{id}/expenses/claims

services/canopy-persons/src/api/mod.rs:1772

require_fact_ownership

ownership-gate

POST /v1/persons/{id}/expenses/claims

services/canopy-persons/src/api/mod.rs:1820

require_service_caller

no-actor-passes

DELETE /v1/persons/{id}/income/claims/{fact_id} (close_income_claim)

Close event author None (events.rs:276-278).

services/canopy-persons/src/api/mod.rs:1824

require_fact_ownership

ownership-gate

DELETE /v1/persons/{id}/income/claims/{fact_id}

services/canopy-persons/src/api/mod.rs:1873

require_service_caller

no-actor-passes

GET /v1/persons/{id}/addresses (list_addresses)

services/canopy-persons/src/api/mod.rs:1928

require_data_steward

pure-role-gate

POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact (post_redact_fact)

Dedicated data_steward role (ADR-036 Decision M; admin does NOT auto-pass). Actor never consulted; redaction attributed to claims.sub at 1946-1955.

services/canopy-persons/src/api/mod.rs:1937

require_fact_ownership

ownership-gate

POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact

kind pre-validated by fact_subject_kind (1890-1898) before SQL table interpolation.

services/canopy-persons/src/api/mod.rs:1987

require_data_steward

pure-role-gate

POST /v1/persons/{id}/redact-ssn (post_redact_ssn)

Redaction attributed to claims.sub at 2010-2016.

services/canopy-persons/src/api/mod.rs:2079

require_service_caller

no-actor-passes

POST /v1/internal/finalize-operations/{op}/{gen}/register

Paired with unconditional require_finalize_caller at 2080.

services/canopy-persons/src/api/mod.rs:2080

require_finalize_caller (unconditional)

pure-role-gate

POST /v1/internal/finalize-operations/{op}/{gen}/register

applications-only (ADR-038).

services/canopy-persons/src/api/mod.rs:2114

require_service_caller

no-actor-passes

POST /v1/internal/finalize-operations/{op}/{gen}/release

services/canopy-persons/src/api/mod.rs:2115

require_finalize_caller (unconditional)

pure-role-gate

POST /v1/internal/finalize-operations/{op}/{gen}/release

services/canopy-persons/src/api/mod.rs:2153

require_service_caller

no-actor-passes

POST /v1/internal/finalize-operations/{op}/{gen}/cancel

services/canopy-persons/src/api/mod.rs:2154

require_finalize_caller (unconditional)

pure-role-gate

POST /v1/internal/finalize-operations/{op}/{gen}/cancel

services/canopy-persons/src/api/mod.rs:2328

require_service_caller

no-actor-passes

GET /v1/internal/finalize-operations/{op} (get_finalize_operation)

services/canopy-persons/src/api/mod.rs:2329

require_finalize_caller (unconditional)

pure-role-gate

GET /v1/internal/finalize-operations/{op}

services/canopy-persons/src/api/mod.rs:2445

require_data_steward

pure-role-gate

POST /v1/households/{household_id}/compensate-finalize-orphan (compensate_finalize_orphan)

Human steward op (ADR-038 MR9). No audit-attribution event carries the steward’s sub in this handler.

services/canopy-persons/src/api/mod.rs:2456

household_self_membership_is_finalize provenance gate

other

Refuses orphan compensation (409) without a finalize-origin self membership — protects non-finalize data from steward auto-shred (#1055). Companion saga-era gate graph_has_receipts at 2467.

services/canopy-persons/src/finalize.rs:89

require_finalize_caller (definition: claims.service_id() == "canopy-applications")

pure-role-gate

service_id() (canopy-auth claims.rs:241-247) falls back to azp when no service: role. Safe today only because every call site runs require_service_caller first (doc comment finalize.rs:86-88); the invariant is per-call-site, not enforced in the helper.

services/canopy-persons/src/store/finalize.rs:46

require_active_generation (definition; store-level FOR SHARE gate)

other

Saga-state gate, not identity authz: refuses a finalize-tagged write whose (operation_id, generation) is absent/cancelled (409). Sole call site: api/mod.rs:314 (resolve_step).

canopy-renewals (34 branches)

Site Construct Classification Route Notes

services/canopy-renewals/src/api/mod.rs:316

require_service_caller

no-actor-passes

POST /v1/renewals/snap/certifications (create_certification)

Worker-initiated via canopy-web BFF (actions_snap.rs:320, plain .post, no X-Canopy-Actor). Service bearer with no actor passes; no attribution of the deciding worker anywhere in the request.

services/canopy-renewals/src/api/mod.rs:427

require_service_caller

no-actor-passes

GET /v1/renewals/snap/certifications?household_id= (get_active_certification)

Household-scoped PII read; household_id is a trusted caller-supplied query param (HouseholdQuery, mod.rs:66-69); no ownership gate, actor never consulted.

services/canopy-renewals/src/api/mod.rs:452

require_service_caller

no-actor-passes

GET /v1/renewals/snap/certifications/{id} (get_certification)

Any service bearer can read any certification by id; no actor, no ownership check.

services/canopy-renewals/src/api/mod.rs:548

require_service_caller

pure-role-gate

POST /v1/renewals/snap/universe-snapshots (create_universe_snapshot)

#1470, machine-to-machine only — called by canopy-eligibility bulk_runs_support.rs:350. No human actor exists by design. Inline comment: 'service-class only, like every renewals federal-universe read'.

services/canopy-renewals/src/api/mod.rs:583

require_service_caller

pure-role-gate

GET /v1/renewals/snap/universe-snapshots/{id}/rows (list_universe_snapshot_rows)

#1470, machine-to-machine only — canopy-eligibility bulk/worker.rs:378 pages it.

services/canopy-renewals/src/api/mod.rs:621

require_service_caller

no-actor-passes

GET /v1/renewals/snap/due (list_due)

Worker MyQueue/dashboard feed (canopy-web renewals.rs:83, dashboard.rs); household case rows served to a no-actor service bearer.

services/canopy-renewals/src/api/mod.rs:654

require_service_caller

no-actor-passes

GET /v1/renewals/{program}/due (list_program_due)

Worker MyQueue per-program fan-out (canopy-web my_queue.rs:363). 422s unknown program slug after the gate; non-snap degrades to empty page.

services/canopy-renewals/src/api/mod.rs:699

require_service_caller

no-actor-passes

GET /v1/renewals/overdue (list_overdue)

Worker-dashboard Overdue-cases panel feed (#520, canopy-web overdue_cases.rs); household-level rows, no actor.

services/canopy-renewals/src/api/mod.rs:812

require_service_caller

pure-role-gate

GET /v1/renewals/caseload-trend (get_caseload_trend)

Aggregate depth series only (no per-household data); doc-comment says 'Service-caller only' (#702/#1218). Supervisor dashboard panel calls with pure service identity.

services/canopy-renewals/src/api/mod.rs:891

require_service_caller

no-actor-passes

GET /v1/renewals/snap/interim-contacts/due (list_interim_contacts_due)

Worker feed; ADR-033 as_of read seam; no actor.

services/canopy-renewals/src/api/mod.rs:918

require_service_caller

no-actor-passes

POST /v1/renewals/snap/certifications/{id}/interim-contact (record_interim_contact)

Worker decision write via BFF (canopy-web actions.rs:87, plain .post). Change-report row records contact_method/notes from body; NO worker identity recorded at all.

services/canopy-renewals/src/api/mod.rs:991

require_service_caller

no-actor-passes

POST /v1/renewals/snap/certifications/{id}/change-report (create_change_report)

Worker decision write via BFF (canopy-web actions.rs:261). No worker identity captured in the row.

services/canopy-renewals/src/api/mod.rs:1060

require_service_caller

no-actor-passes

POST /v1/renewals/{program}/certifications/{id}/interim-contact (record_program_interim_contact)

#448 non-SNAP variant (BFF actions_tanf/medicaid/caps/wic). Gate then validate_program (mod.rs:1061) + snap-rejection branch (mod.rs:1064). cert_id is opaque/unverified; row keyed on body household_id.

services/canopy-renewals/src/api/mod.rs:1111

require_service_caller

no-actor-passes

POST /v1/renewals/{program}/certifications/{id}/change-report (create_program_change_report)

#448 non-SNAP variant; same shape — validate_program at 1112, snap-rejection at 1113; body household_id trusted.

services/canopy-renewals/src/api/mod.rs:1170

require_service_caller

no-actor-passes

GET /v1/renewals/snap/nudges (list_recert_nudges)

Worker case-detail feed; household_id caller-supplied (NudgeListQuery mod.rs:1139-1144).

services/canopy-renewals/src/api/mod.rs:1206

require_service_caller

no-actor-passes

POST /v1/renewals/snap/nudges/{id}/action (action_recert_nudge)

Worker file/dismiss decision. Attribution comes from body req.action_by (see mod.rs:1207 entry) — the claims actor is never read.

services/canopy-renewals/src/api/mod.rs:1207

audit-attribution (body-supplied action_by UUID)

attribution-resolution

POST /v1/renewals/snap/nudges/{id}/action

Worker identity recorded from request body (req.action_by), NEVER from claims.actor() — unverified attribution; a service bearer can stamp any UUID. Persisted at store.rs:1026-1036 (UPDATE …​ action_by = $3 WHERE action_taken IS NULL — idempotency guard preserves first attribution).

services/canopy-renewals/src/api/mod.rs:1237

require_service_caller

no-actor-passes

GET /v1/renewals/snap/periodic-reports (list_periodic_reports)

Household-scoped cycle list, caller-supplied household_id.

services/canopy-renewals/src/api/mod.rs:1263

require_service_caller

no-actor-passes

GET /v1/renewals/snap/periodic-reports/{id} (get_periodic_report)

Any service bearer reads any cycle by id.

services/canopy-renewals/src/api/mod.rs:1318

require_service_caller

no-actor-passes

POST /v1/renewals/snap/periodic-reports/{id}/form (record_periodic_report_form)

Worker records 3730 Step-2 form receipt; no worker identity captured.

services/canopy-renewals/src/api/mod.rs:1361

require_service_caller

no-actor-passes

POST /v1/renewals/snap/periodic-reports/{id}/vcl (send_periodic_report_vcl)

Worker sends VCL (3730 Step 4); no worker identity captured.

services/canopy-renewals/src/api/mod.rs:1417

require_service_caller

no-actor-passes

POST /v1/renewals/snap/periodic-reports/{id}/verified (record_periodic_report_verified)

Worker verification stamp; no identity captured.

services/canopy-renewals/src/api/mod.rs:1458

require_service_caller

no-actor-passes

POST /v1/renewals/snap/periodic-reports/{id}/complete (complete_periodic_report)

Worker processes cycle (locked tx + outbox event); no identity captured in the processed stamp or change-report rows.

services/canopy-renewals/src/api/mod.rs:1579

require_service_caller

no-actor-passes

POST /v1/renewals/snap/periodic-reports/{id}/reopen (reopen_periodic_report)

Worker reopen of a terminated cycle. Actor attribution is body string req.actor forwarded to enrollment (mod.rs:1610 entry). Also a deployment gate at 1580-1588: refuses (500) when no ADR-019 service identity is configured for the enrollment call.

services/canopy-renewals/src/api/mod.rs:1610

audit-attribution (body-supplied actor string)

attribution-resolution

POST /v1/renewals/snap/periodic-reports/{id}/reopen

req.actor (free-form string from request body) forwarded verbatim to canopy-enrollment’s ReopenAdverseActionRequest as the audited reopen actor — string-param actor, unverified.

services/canopy-renewals/src/api/mod.rs:1816

require_service_caller

no-actor-passes

GET /v1/renewals/snap/redeterminations (list_redeterminations)

Worker veto/cancel queue; household_id caller-supplied.

services/canopy-renewals/src/api/mod.rs:1851

require_service_caller

no-actor-passes

POST /v1/renewals/snap/redeterminations/{id}/action (action_redetermination)

Worker redetermined/dismissed decision; attribution from body req.action_by (mod.rs:1852 entry).

services/canopy-renewals/src/api/mod.rs:1852

audit-attribution (body-supplied action_by UUID)

attribution-resolution

POST /v1/renewals/snap/redeterminations/{id}/action

Same shape as nudge action: body action_by persisted at pr_pipeline.rs:785-795 (UPDATE …​ action_by WHERE action_taken IS NULL). Unverified.

services/canopy-renewals/src/api/mod.rs:1916

require_service_caller

pure-role-gate

POST /v1/renewals/scheduler/run (run_scheduler_pass)

#1109 operator/journey trigger; machine action under advisory lock, gated clock, no caller-supplied date. No human attribution expected.

services/canopy-renewals/src/api/mod.rs:1971

require_service_caller

pure-role-gate

POST /v1/renewals/caseload-rollup/refresh (run_caseload_rollup_refresh)

#1218 R4 trigger; same machine-trigger shape as scheduler/run.

services/canopy-renewals/src/main.rs:63

ApiServer::router protected mount (shared canopy-api auth middleware)

other

all /v1/renewals/* routes

All api::routes() mount as protected under /v1 via canopy_api::ApiServer::router — bearer validation + X-Canopy-Actor parsing live in the shared canopy-api/canopy-auth layer, not in this service. No service-local middleware or role construct besides the 27 require_service_caller calls; canopy-auth’s require_service_caller (crates/canopy-auth/src/claims.rs:253-259) checks is_service() only and never consults actor().

services/canopy-renewals/src/pr_pipeline.rs:271

audit-attribution (PIPELINE_ACTOR constant)

audit-only

scheduler drain → enrollment schedule_action (nonfiler termination)

Machine actor constant 'canopy-renewals periodic-report pipeline' (defined pr_pipeline.rs:63) stamped as ExemptionClaim.actor (line 271) and request actor (line 284) on scheduler-initiated adverse actions. Correct for machine actions, but enrollment cannot distinguish it from a spoofed body string.

services/canopy-renewals/src/pr_pipeline.rs:402

audit-attribution (PIPELINE_ACTOR constant)

audit-only

scheduler drain → enrollment schedule_action (VCL-failure termination)

Same constant at lines 402 (ExemptionClaim.actor) and 413 (request actor) on the failure_to_provide_verification arm.

services/canopy-renewals/src/store.rs:1030

guarded attribution UPDATE (WHERE action_taken IS NULL)

audit-only

store::action_recert_nudge

Store-level guard is idempotency/first-decision-wins (doc at store.rs:1017-1020: 'retry can’t overwrite the first decision or its actor'), NOT authorization. No authz predicates exist anywhere in store.rs.

canopy-reporting (28 branches)

Site Construct Classification Route Notes

services/canopy-reporting/src/api/mod.rs:207

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/overpayments

Service bearers fail (no supervisor role); actor never consulted. Same for every gate below.

services/canopy-reporting/src/api/mod.rs:369

claims.require_supervisor_or_above() (+ &claims.sub as requested_by at 377)

pure-role-gate

POST /v1/reporting/snap/fns-388

Attribution: enqueue_run persists claims.sub as report_runs.requested_by.

services/canopy-reporting/src/api/mod.rs:396

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/snap/fns-388

services/canopy-reporting/src/api/mod.rs:420

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/snap/fns-388/{month}

services/canopy-reporting/src/api/mod.rs:453

claims.require_supervisor_or_above() (+ &claims.sub at 461)

pure-role-gate

POST /v1/reporting/snap/qc-universe

services/canopy-reporting/src/api/mod.rs:567

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/snap/qc-universe/{date}

services/canopy-reporting/src/api/mod.rs:593

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/snap/qc-universe/{date}/csv

services/canopy-reporting/src/api/mod.rs:665

claims.require_supervisor_or_above() (+ &claims.sub at 673)

pure-role-gate

POST /v1/reporting/tanf/acf-199

services/canopy-reporting/src/api/mod.rs:697

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/tanf/acf-199

services/canopy-reporting/src/api/mod.rs:721

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/tanf/acf-196

services/canopy-reporting/src/api/mod.rs:734

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/tanf/wpr

services/canopy-reporting/src/api/mod.rs:768

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/tanf/acf-199/csv

services/canopy-reporting/src/api/mod.rs:813

claims.require_supervisor_or_above()

pure-role-gate

POST /v1/reporting/tanf/acf-196

services/canopy-reporting/src/api/mod.rs:870

claims.require_supervisor_or_above()

pure-role-gate

POST /v1/reporting/tanf/wpr

services/canopy-reporting/src/api/mod.rs:937

claims.require_supervisor_or_above() (+ &claims.sub at 945)

pure-role-gate

POST /v1/reporting/medicaid/tmsis

services/canopy-reporting/src/api/mod.rs:971

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/medicaid/tmsis

Decrypts sealed T-MSIS attributes under the KEK for the response — PHI read gate.

services/canopy-reporting/src/api/mod.rs:995

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/medicaid/cms-64

services/canopy-reporting/src/api/mod.rs:1023

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/medicaid/tmsis/csv

Streams the full decrypted federal file — highest-sensitivity read behind this gate.

services/canopy-reporting/src/api/mod.rs:1076

claims.require_supervisor_or_above()

pure-role-gate

POST /v1/reporting/medicaid/cms-64

services/canopy-reporting/src/api/mod.rs:1134

claims.require_supervisor_or_above() (+ &claims.sub at 1147)

pure-role-gate

POST /v1/reporting/medicaid/cms-416

services/canopy-reporting/src/api/mod.rs:1205

claims.require_supervisor_or_above()

pure-role-gate

GET /v1/reporting/medicaid/cms-416

services/canopy-reporting/src/api/mod.rs:1273

claims.require_service_or_caseworker_or_above()

no-actor-passes

GET /v1/reporting/overpayments/summary

Deliberately service-open for BFF panels (comment 1269-1273: canopy-web hits it with a service-class token, no actor); direct caseworker traffic also passes.

services/canopy-reporting/src/api/runs.rs:39

authorize_runs_read: if claims.is_service() { Ok } else require_supervisor_or_above()

no-actor-passes

GET /v1/reporting/runs + GET /v1/reporting/runs/{id}

D7 explicit OR: ANY service principal with no actor passes (test matrix at 187-208 pins it, incl. service:canopy-web+caseworker). Runs are deliberately ORG-VISIBLE (module doc 14-17) — no requester scoping.

services/canopy-reporting/src/api/runs.rs:79

authorize_runs_read(&claims)

no-actor-passes

GET /v1/reporting/runs/{id}

services/canopy-reporting/src/api/runs.rs:114

authorize_runs_read(&claims)

no-actor-passes

GET /v1/reporting/runs

services/canopy-reporting/src/api/runs_enqueue.rs:42

enqueue_run(requested_by: &str) → report_runs.requested_by

attribution-resolution

the five generate POSTs

String-param seam; handlers always pass claims.sub, and the generate POSTs are supervisor-gated, so requested_by is always a human sub today.

services/canopy-reporting/src/guard.rs:108

require_least_privilege_role(pool, allow_broad) — DB session-role boot guard

other

process boot (main.rs:87), not a route

AUTHZ OUTSIDE HTTP CLAIMS (#1456/ADR-004 A8b): probes pg_roles for privileged bits + pg_has_role(current_user,'canopy_reporting_app','member') (87-97); evaluate() at 61-75: env==development auto-allows, compliant app role passes, allow_broad_db_role=true proceeds with loud WARN (OverrideAllowed), else refuses to boot. Override knob config.rs:75 (default false, config.rs:141). Grant matrix: migrations/20261111000000_least_privilege_roles.sql (REVOKE PUBLIC + enumerated per-table grants to canopy_reporting_app); janitor reap is SECURITY DEFINER granted only to canopy_reporting_app (migrations/20261111000001_janitor_security_definer.sql:99-103).

services/canopy-reporting/src/store/restricted.rs:143

load_dek_only (readers never mint) + AAD natural-key binding tmsis_row_ctx (271-286)

other

T-MSIS sealed reads/writes (store level)

Crypto-enforced integrity guard, not caller authz: binding person/enrollment/month into AAD means a DB actor with UPDATE cannot re-attribute a sealed payload by rewriting plaintext key columns (comment 264-270). Complements the restricted DB role.

canopy-security (27 branches)

Site Construct Classification Route Notes

services/canopy-security/src/api/export.rs:94

require_admin_or_quality_control

pure-role-gate

GET /v1/export/audit-events

export_audit_events — human-only (admin or quality_control worker roles; service tokens lack these). Bulk FOIA/QC export, capped at 50k rows.

services/canopy-security/src/api/export.rs:123

audit payload "actor": claims.sub (self-audit publish, 122-152)

audit-only

GET /v1/export/audit-events

Every export publishes audit.export.requested attributed to claims.sub (guaranteed human by :94). Publish failure is WARN-and-continue — documented best-effort, not a gate.

services/canopy-security/src/api/mod.rs:175

if !claims.is_service() { claims.require_admin()? } (gate at 175-176)

no-actor-passes

GET /v1/security/events

list_events dual gate: a no-actor service bearer bypasses the admin check entirely. canopy-web reads the audit log on behalf of workers with its service token — per-worker identity is never enforced or recorded at this boundary. This exact dual-gate shape repeats 14x in this file.

services/canopy-security/src/api/mod.rs:224

is_service() bypass / require_admin fallback (224-225)

no-actor-passes

GET /v1/security/persons/{person_id}/fact-history/{resource}

fact_change_history — per-person PII change history readable by any service bearer, no actor.

services/canopy-security/src/api/mod.rs:255

is_service() bypass / require_admin fallback (255-256)

no-actor-passes

GET /v1/security/events/{id}

get_event.

services/canopy-security/src/api/mod.rs:301

require_service_caller

no-actor-passes

POST /v1/security/audit/ingest

ingest_audit_event — PORTAL TARGET (portal lookup.rs:282, bare service bearer, fire-and-forget). Attribution (user_id, user_role, ip_address, household_id) is CALLER-SUPPLIED BODY DATA lifted into the hash chain (lines 338-341), never derived from claims/actor. A no-actor bearer both passes AND names arbitrary users in the tamper-evident audit chain.

services/canopy-security/src/api/mod.rs:382

require_service_caller

pure-role-gate

POST /v1/security/signing-keys

register_signing_key — boot-time machine registration; first gate of a three-stage check.

services/canopy-security/src/api/mod.rs:383

claims.service_id().ok_or(ApiError::Forbidden)

other

POST /v1/security/signing-keys

Fail-closed service-identity resolution: a service bearer with no resolvable service:<id> role (azp fallback also empty) is refused. The resolved id feeds BOTH the program allowlist and row attribution (#1261 — same identity for gate and record). Note service_id() falls back to azp (claims.rs:246), a string-typed identity.

services/canopy-security/src/api/mod.rs:427

require_service_caller

pure-role-gate

GET /v1/security/signing-keys/{program}/jwks

signing_key_jwks — public-key-only read; doc notes public exposure for external verifiers is a follow-up.

services/canopy-security/src/api/mod.rs:468

is_service() bypass / require_admin fallback (468-469)

no-actor-passes

GET /v1/security/alerts

list_alerts.

services/canopy-security/src/api/mod.rs:496

is_service() bypass / require_admin fallback (496-497)

no-actor-passes

GET /v1/security/alerts/{id}

get_alert.

services/canopy-security/src/api/mod.rs:524

is_service() bypass / require_admin fallback (524-525)

no-actor-passes

PATCH /v1/security/alerts/{id}

update_alert — a WRITE (breach-alert triage state) reachable by any no-actor service bearer.

services/canopy-security/src/api/mod.rs:531

req.resolved_by passed to update_alert_status

audit-only

PATCH /v1/security/alerts/{id}

Alert-resolution attribution is CALLER-SUPPLIED body text, not claims-derived — a migration slice should move this to actor()/sub-derived attribution.

services/canopy-security/src/api/mod.rs:552

is_service() bypass / require_admin fallback (552-553)

no-actor-passes

GET /v1/security/nist-controls

list_nist_controls — static reference data, low sensitivity, same dual gate.

services/canopy-security/src/api/mod.rs:585

is_service() bypass / require_admin fallback (585-586)

no-actor-passes

GET /v1/security/summary

get_summary.

services/canopy-security/src/api/mod.rs:684

is_service() bypass / require_admin fallback (684-685)

no-actor-passes

GET /v1/security/chain/status

chain_status.

services/canopy-security/src/api/mod.rs:795

is_service() bypass / require_admin fallback (795-796)

no-actor-passes

POST /v1/security/chain/verify

chain_verify_enqueue — a no-actor service bearer can enqueue verification work (bounded by queue cap + in-flight dedupe).

services/canopy-security/src/api/mod.rs:848

requested_by = if claims.is_service() { service_id() } else { format!("admin:{}", claims.sub) } (848-855)

attribution-resolution

POST /v1/security/chain/verify

Decides WHO to record, never allow/deny. Comment documents why bare service_id() would misfile admins (worker tokens carry the BFF client id in azp). String-typed attribution ("admin:{sub}" / service id) in requested_by.

services/canopy-security/src/api/mod.rs:931

is_service() bypass / require_admin fallback (931-932)

no-actor-passes

GET /v1/security/chain/verify-jobs/{id}

chain_verify_job entry gate.

services/canopy-security/src/api/mod.rs:944

if claims.is_service() && job.requested_by != claims.service_id().unwrap_or("") → 404

ownership-gate

GET /v1/security/chain/verify-jobs/{id}

Requester scoping: service callers see only their own jobs (indistinguishable 404 for foreign); admin sees all; a service token with no resolvable id matches nothing (requested_by non-empty by CHECK) — fail closed. String comparison against the string requested_by column.

services/canopy-security/src/api/mod.rs:991

is_service() bypass / require_admin fallback (991-992)

no-actor-passes

GET /v1/security/chain/attest

chain_attest — citation-PDF attestation input; canopy-web calls with service token.

services/canopy-security/src/api/mod.rs:1177

is_service() bypass / require_admin fallback (1177-1178)

no-actor-passes

GET /v1/security/archive

list_archived.

services/canopy-security/src/api/mod.rs:1231

require_admin (no is_service() arm — deliberate)

pure-role-gate

POST /v1/security/archive

run_archive is ADMIN-ONLY by #1208 decision 10: service tokens are REJECTED because requested_by must name a person; doc comment (1219-1223) explicitly warns not to add the is_service() arm. The one route in security where a service bearer is refused a decision the admin can take.

services/canopy-security/src/api/mod.rs:1238

requested_by = format!("admin:{}", claims.sub)

attribution-resolution

POST /v1/security/archive

Accountability attribution from the bearer’s sub (guaranteed a human by the :1231 gate). String-typed.

services/canopy-security/src/api/mod.rs:1290

is_service() bypass / require_admin fallback (1290-1291)

no-actor-passes

GET /v1/security/archive-runs/{id}

get_archive_run — reads stay dual (decision 10 restricts only the POST). No requester scoping here, unlike verify-jobs :944.

services/canopy-security/src/signing_authz.rs:75

service_may_sign_for(service_id, program) → Forbidden (allowlist at :35-44)

ownership-gate

POST /v1/security/signing-keys

Program-binding allowlist (#1259): a service may register keys ONLY for its own program(s); closes the cross-program determination-forgery path. Pure function; kid derived server-side (:87-92), caller-supplied kid validated-not-trusted.

services/canopy-security/src/store/signing_keys.rs:35

bind(registrant_service_id) into INSERT …​ ON CONFLICT DO NOTHING

audit-only

POST /v1/security/signing-keys

Store-level attribution: registrant_service_id is the authenticated caller and the conflict-free insert means a later caller can never overwrite it (doc lines 16-18). Attribution is the authenticated service id — never a human actor.

canopy-snap (38 branches)

Site Construct Classification Route Notes

services/canopy-snap/src/api/abawd_handler.rs:56

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/abawd/activity

Write path (records activity, updates tracking status, stages abawd.warning/time_limit_reached events) — no attribution captured anywhere in the row or events.

services/canopy-snap/src/api/abawd_handler.rs:160

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/abawd/tracking

Interactive per-person/household read; caseworker-reachable by design (#1249 contrast with batchGet).

services/canopy-snap/src/api/abawd_handler.rs:212

require_service_caller

pure-role-gate

POST /v1/abawd/tracking:batchGet

§B4 bulk read is service-tier only (#1203/#1249 least-privilege): caseworkers deliberately excluded from the batch surface.

services/canopy-snap/src/api/abawd_handler.rs:242

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/abawd/tracking/{id}/activities

Dual read.

services/canopy-snap/src/api/categorical_handler.rs:37

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/categorical-eligibility/participations

Write, no attribution captured.

services/canopy-snap/src/api/categorical_handler.rs:73

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/categorical-eligibility/participations

Dual read.

services/canopy-snap/src/api/categorical_handler.rs:96

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/student-status

Write, no attribution captured.

services/canopy-snap/src/api/categorical_handler.rs:126

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/student-status

Dual read.

services/canopy-snap/src/api/determine_handler.rs:77

require_service_caller

pure-role-gate

POST /v1/determine

ADR-019 hard cutover (#439): service-only; orchestrator (canopy-eligibility) is the production caller. No actor consideration; bare service bearer passes by design.

services/canopy-snap/src/api/determine_handler.rs:199

require_service_caller

pure-role-gate

POST /v1/determine/dry-run

Service-only like /determine; write-free but replays sealed-determination policy so stays service-gated.

services/canopy-snap/src/api/determine_handler.rs:244

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}

is_service() arm short-circuits: bare service bearer (no actor) reads a worker-facing surface; no audit of absence.

services/canopy-snap/src/api/determine_handler.rs:272

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations

Same dual pattern; list read.

services/canopy-snap/src/api/determine_handler.rs:309

require_service_caller().or_else(require_admin_or_quality_control) (chained, line 309-310)

pure-role-gate

GET /v1/determinations/{id}/snapshot

Least-privilege composite (ADR-028 §57): services OR admin/QC humans; explicitly NOT general caseworkers. FTI-adjacent frozen snapshot passes to any bare service bearer via the first arm.

services/canopy-snap/src/api/determine_handler.rs:378

require_data_steward (dedicated role)

pure-role-gate

POST /v1/determinations/{id}/redact

Irreversible DEK shred (ADR-036 Decision M); admins do NOT auto-hold data_steward (mirrors fti_auditor separation). Effectively user-only — service tokens don’t carry the role.

services/canopy-snap/src/api/determine_handler.rs:413

audit-attribution (claims.sub into determination.redacted event)

audit-only

POST /v1/determinations/{id}/redact

Records the bearer’s sub directly (not actor-aware); safe today because the gate forces the bearer to BE the data_steward human. Event staged atomically with the shred (ADR-018).

services/canopy-snap/src/api/determine_policy.rs:82

claims.service_id() != Some("canopy-eligibility") → 403 on non-fallback as_of

other

POST /v1/determine

Service-identity pin (#1467 C12): time-travel as_of is orchestrator-only; any other service identity (or worker token, service_id falls back to azp) is refused. Allow/deny keyed on service identity, not roles or actor.

services/canopy-snap/src/api/determine_policy.rs:90

claims.service_id() != Some("canopy-eligibility") → 403 on trigger assertion

other

POST /v1/determine

Same identity pin for cause-class (trigger) assertions (#1213 D-6). Both branches warn-log the rejected caller’s service_id.

services/canopy-snap/src/api/export.rs:93

require_admin_or_quality_control

pure-role-gate

GET /v1/export/determinations

Human-role gate (admin/QC — FNS-QC sampling); is_service() is NOT consulted, so effectively user-only. Bulk-extract sensitive operation.

services/canopy-snap/src/api/export.rs:117

audit-attribution: "actor": claims.sub in snap.export.requested payload

audit-only

GET /v1/export/determinations

Direct sub capture (fine — gate forces human bearer). BUT the audit staging (lines 131-145) is best-effort: publish/commit failure only warn-logs and the export still ships — the export-audit chain can silently lose entries.

services/canopy-snap/src/api/hearing_view_handler.rs:73

require_service_caller

no-actor-passes

GET /v1/determinations/{id}/hearing-view

Appeals reads 'on a worker’s behalf' (module doc) but no actor is required or recorded. Deliberately FTI-safe projection (ADR-028 §70/Amendment 4) — identities only, never sealed leaf values.

services/canopy-snap/src/api/overpayments_handler.rs:89

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments

Keyset page consumed by canopy-reporting’s roll-up (service arm is the production path).

services/canopy-snap/src/api/overpayments_handler.rs:127

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments

Files a money claim on a bare service bearer; the only attribution is body-supplied CreateClaimRequest.discovered_by (nullable).

services/canopy-snap/src/api/overpayments_handler.rs:152

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}

Dual read.

services/canopy-snap/src/api/overpayments_handler.rs:181

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/repayment-plans

Money-adjacent write, no attribution column on repayment_plans.

services/canopy-snap/src/api/overpayments_handler.rs:209

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/recoupments

Ledger append on bare service bearer; recoupment_ledger actor not set from claims.

services/canopy-snap/src/api/overpayments_handler.rs:234

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}/ledger

Dual read.

services/canopy-snap/src/api/params_handler.rs:47

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/params

THE portal-called route: canopy-portal’s /apply/snap-params proxy hits it with the portal’s own service token, no actor — passes via the is_service() arm. Non-PII policy parameters only.

services/canopy-snap/src/api/params_handler.rs:101

require_service_caller

pure-role-gate

GET /v1/params/provenance

Dispatch-side service-to-service discovery read (#1467 C10).

services/canopy-snap/src/api/recompute_handler.rs:156

require_service_caller

no-actor-passes

POST /v1/determinations/{id}/overpayment-recompute

Comment says 'Worker-actioned, mediated by the BFF/CLI as a service caller' — a money decision (files #382 claims) passes on a bare service bearer; worker identity is never verifiable here because snap has no ActorVerifier.

services/canopy-snap/src/api/recompute_handler.rs:236

audit-attribution: caller_uuid — claims.actor().map_or(claims.sub, |a| a.sub) (actor() at line 238)

attribution-resolution

POST /v1/determinations/{id}/overpayment-recompute

Decides only WHO to record as requested_by. Since actor() is always None in snap (no verifier), it ALWAYS records the service subject; a non-UUID sub warn-logs and records Uuid::nil(). Doc-comment 'Audit-only — require_service_caller is the gate' is accurate.

services/canopy-snap/src/api/tsnap_handler.rs:32

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/tsnap/{id}

Dual read.

services/canopy-snap/src/api/tsnap_handler.rs:58

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/tsnap

Dual read by household_id.

services/canopy-snap/src/api/verification_handler.rs:61

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/verification/discrepancies

Dual read; exactly-one-of application_id/household_id filter (422 otherwise).

services/canopy-snap/src/api/verification_handler.rs:91

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/verification/ievs-matches

Dual read.

services/canopy-snap/src/api/verification_handler.rs:124

require_service_or_caseworker_or_above

no-actor-passes

PUT /v1/verification/discrepancies/{id}/resolve

Write passes on bare service bearer; attribution (resolved_by_sub) is taken from the REQUEST BODY (lines 149-150), not from verified claims — see flags.

services/canopy-snap/src/api/verification_handler.rs:149

audit-attribution: body-supplied req.resolved_by / req.resolved_by_sub / resolved_fact_id threaded to store (store/verification.rs:238-239) and the ievs.discrepancy_resolved event

attribution-resolution

PUT /v1/verification/discrepancies/{id}/resolve

Attribution decided by the caller’s body, unverifiable server-side. contracts doc calls resolved_by_sub 'the real attribution' (T1-9 #677). Any passing bearer can write arbitrary worker attribution.

services/canopy-snap/src/recompute_persist.rs:145

audit-attribution: discovered_by: Some(base.requested_by) on the #382 claim (requested_by also persisted on every recompute audit row, lines 65/170; INSERT store/recomputes.rs:169-189)

audit-only

POST /v1/determinations/{id}/overpayment-recompute

Carries caller_uuid’s resolution into durable rows. Today always the service subject or Uuid::nil() — never a worker — because actor() cannot resolve in snap.

services/canopy-snap/src/store/overpayments.rs:552

string-param actor: void_claim(actor: Option<&str>) bound into the compensating void adjustment (append_adjustment binds body req.actor at line 490)

audit-only

MQ subscriber appeal.overpayment_assessment_voided (no HTTP route in snap)

Production caller main.rs:295-299 passes the literal string "appeal.overpayment_assessment_voided" as the actor. Free-string attribution, not a verified principal. append_adjustment has no HTTP caller in snap (tests only).

canopy-tanf (38 branches)

Site Construct Classification Route Notes

services/canopy-tanf/src/api/discrepancy_handlers.rs:83

require_service_caller

no-actor-passes-with-audit

POST /v1/verification/discrepancies/{id}/resolve

#448: called by canopy-web’s #392 BFF action, which mints X-Canopy-Actor (#961). A no-actor service bearer still passes this user-facing resolve action; the absence is recorded only as resolved_by='system' on the tanf_discrepancies domain row (line 84), not a dedicated audit table.

services/canopy-tanf/src/api/discrepancy_handlers.rs:84

claims.actor().map(|a| a.sub.as_str()).unwrap_or("system")

attribution-resolution

POST /v1/verification/discrepancies/{id}/resolve

Decides WHO is written to tanf_discrepancies.resolved_by (TEXT). No-actor → literal 'system' sentinel persisted. The plan-named attribution site.

services/canopy-tanf/src/api/fti_audit_handlers.rs:57

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log

Dedicated fti_auditor role (Pub 1075 §9, #383); admin does NOT hold it. Actor never consulted.

services/canopy-tanf/src/api/fti_audit_handlers.rs:94

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log/{id}

Same dedicated-role gate.

services/canopy-tanf/src/api/fti_audit_handlers.rs:121

require_fti_auditor

pure-role-gate

GET /v1/fti-audit-log/summary

Same dedicated-role gate.

services/canopy-tanf/src/api/grg_handlers.rs:43

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/grg/payments

Dual gate on a payment-recording write; no actor attribution captured on the payment row.

services/canopy-tanf/src/api/grg_handlers.rs:89

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/grg/payments/{person_id}

Dual gate; unscoped person_id read.

services/canopy-tanf/src/api/handlers.rs:60

require_service_caller

no-actor-passes-with-audit

POST /v1/determine

ADR-019 hard cutover (#439): service-only. A service bearer with no actor passes; the same-tx FTI audit-chain entry (determine.rs:901-923) records accessed_by via the line-75 fallback, so the access is always audited (attributed to the service’s own sub when no actor).

services/canopy-tanf/src/api/handlers.rs:75

claims.actor().map_or(claims.sub.as_str(), |a| a.sub.as_str())

attribution-resolution

POST /v1/determine

Lines 75-78. ADR-028 §52 / ADR-014: decides WHO the FTI audit-chain entry records — prefers the on-behalf-of worker actor, falls back to the service caller’s own sub. Threaded as string param accessed_by into determine::determine (determine.rs:313).

services/canopy-tanf/src/api/handlers.rs:227

require_service_caller (month-param arm)

no-actor-passes

GET /v1/determinations

#1249 least privilege: the month federal ACF-199 scope arm is service-caller-only. No actor consulted, no per-request audit row. The route’s tier changes by query param — see the line-229 sibling arm.

services/canopy-tanf/src/api/handlers.rs:229

require_service_or_caseworker_or_above (unscoped arm)

no-actor-passes

GET /v1/determinations

Dual gate: is_service() short-circuits before the role check (claims.rs:266-274), so a no-actor service bearer passes this caseworker-tier read unaudited.

services/canopy-tanf/src/api/handlers.rs:295

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}

Dual gate; no ownership scoping on the determination id.

services/canopy-tanf/src/api/handlers.rs:335

require_data_steward

pure-role-gate

POST /v1/determinations/{id}/redact

Dedicated data_steward role (ADR-036 Decision M); admins do NOT auto-hold it (claims.rs:214-216). Actor never consulted — the bearer itself must carry the role, so this is a human-bearer surface in practice.

services/canopy-tanf/src/api/handlers.rs:370

audit-attribution (Some(claims.sub.as_str()) into determination.redacted event)

attribution-resolution

POST /v1/determinations/{id}/redact

Records claims.sub directly into the tamper-evident redaction event — never consults actor() (consistent, since data_steward is on the bearer). String-typed Option<&str> param on events::publish_determination_redacted.

services/canopy-tanf/src/api/overpayments_handler.rs:89

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments

Dual gate; canopy-reporting drains this page-looped (service caller).

services/canopy-tanf/src/api/overpayments_handler.rs:127

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments

Dual gate on claim creation; CreateClaimRequest.discovered_by is a body field, not a verified claim.

services/canopy-tanf/src/api/overpayments_handler.rs:152

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}

Dual gate.

services/canopy-tanf/src/api/overpayments_handler.rs:181

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/repayment-plans

Dual gate on a write; no attribution.

services/canopy-tanf/src/api/overpayments_handler.rs:209

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/overpayments/{id}/recoupments

Dual gate on a money-ledger write; no attribution.

services/canopy-tanf/src/api/overpayments_handler.rs:234

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/overpayments/{id}/ledger

Dual gate.

services/canopy-tanf/src/api/personal_responsibility_handlers.rs:44

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/personal-responsibilities/{application_id}

Dual gate.

services/canopy-tanf/src/api/personal_responsibility_handlers.rs:72

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/personal-responsibilities/{application_id}

Dual gate on a write; no attribution.

services/canopy-tanf/src/api/personal_responsibility_handlers.rs:119

require_service_or_caseworker_or_above

no-actor-passes

PUT /v1/personal-responsibilities/status/{id}

Dual gate on a compliance-status write (sanction-relevant); no attribution of who set the status.

services/canopy-tanf/src/api/work_requirement_handlers.rs:54

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/work-requirements/{person_id}

Dual gate. NOTE: this GET is get-or-CREATE (writes a row) — a no-actor service bearer can create tanf_work_requirements rows unattributed.

services/canopy-tanf/src/api/work_requirement_handlers.rs:108

require_service_caller

no-actor-passes

POST /v1/work-requirements:batchGet

§B4 bulk read, service-tier only (#1203/#1249). Read-only; no audit row.

services/canopy-tanf/src/api/work_requirement_handlers.rs:142

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/work-requirements/{person_id}/activities

Dual gate on a write (log_activity). The staged work_requirement_updated event carries no actor attribution.

services/canopy-tanf/src/api/work_requirement_handlers.rs:227

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/work-requirements/evaluate

Dual gate; thin forwarder to canopy-rules using tanf’s OWN service token outbound (ADR-019), never the inbound bearer.

services/canopy-tanf/src/api/work_requirement_handlers.rs:290

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/work-requirements/{person_id}/activities

Dual gate; unscoped person_id read.

services/canopy-tanf/src/api/work_requirement_handlers.rs:397

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/work-requirements/{person_id}/activities/summary

Dual gate; ACF-199 WPR input read.

services/canopy-tanf/src/api/work_requirement_handlers.rs:485

require_service_caller

no-actor-passes

POST /v1/work-requirements/activities/summary:batchGet

§B4 bulk read, service-tier only (#1252). Read-only.

services/canopy-tanf/src/api/work_requirement_handlers.rs:967

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/time-limits/{person_id}

Dual gate. Also get-or-CREATE (writes a tanf_time_limits row on first read).

services/canopy-tanf/src/api/work_requirement_handlers.rs:1011

require_service_caller

no-actor-passes

POST /v1/time-limits:batchGet

§B4 bulk read, service-tier only (#1203). Read-only.

services/canopy-tanf/src/api/work_requirement_handlers.rs:1050

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}/explanation

Dual gate.

services/canopy-tanf/src/api/work_requirement_handlers.rs:1117

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/tanf/sanctions/rollup

Dual gate; jurisdiction-wide aggregate (supervisor dashboard #496) yet reachable at plain caseworker tier.

services/canopy-tanf/src/determine.rs:903

audit-attribution (FtiAuditEntry.accessed_by = accessed_by param)

audit-only

POST /v1/determine

Sink of the handlers.rs:75 resolution — the FTI audit-chain row committed in the SAME tx as the determination + snapshot (ADR-028 §52/ADR-014; lines 891-926). accessed_by arrives as &str (determine.rs:313) — string-param actor.

services/canopy-tanf/src/main.rs:194

with_actor_verifier wiring (#961; key sourcing lines 175-186)

other

all /v1/* on canopy-tanf

Boot-time fail-loud: CANOPY_TANF__ACTOR_VERIFYING_KEY env or .keys/web-actor-public.pem is REQUIRED (expect at 177-180) so the discrepancy resolver identity comes from a verified claim, never a body field. kid derived from raw PEM — env PEM must be byte-identical to keygen export.

services/canopy-tanf/src/store/fti.rs:26

fti_audited(accessed_by, …​) wrapper on fti_tax_data read

audit-only

Mandatory Pub 1075 audit wrapper; accessed_by is a &str param (string-param actor). #[expect(dead_code)] — dormant until FTI income-verification wiring (#810), but the plumbing is live and a migration slice must convert the param type.

services/canopy-tanf/src/store/fti.rs:76

fti_audited(accessed_by, …​) wrapper on fti_tax_data write

audit-only

Same as line 26; dead_code (#810), string-param actor.

canopy-verification (14 branches)

Site Construct Classification Route Notes

services/canopy-verification/src/api/ievs.rs:67

internal-api-key (X-Service-Api-Key; validate_api_key fn at ievs.rs:50)

other

POST /internal/v1/ievs/match

API-KEY SURFACE. Mounted OUTSIDE the /v1 JWT middleware (main.rs:169-173 merges onto the root router), so no Claims exist at all — no actor concept possible. Shared secret CANOPY_INTERNAL_API_KEY; comparison is plain key == expected (non-constant-time). 401 on mismatch/absence.

services/canopy-verification/src/api/ievs.rs:181

audit-attribution (persist_hits → ievs_hits rows)

audit-only

POST /internal/v1/ievs/match

Best-effort ievs_hits persistence per populated record for the worker panel; records application/household/person/member_name but NO caller identity (no claims exist on this surface). DB failure logs warn and does not fail the match.

services/canopy-verification/src/api/ievs_discrepancies.rs:46

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/verifications/ievs/discrepancies

Worker-dashboard IEVS alerts panel read (#522); service bearer with no actor passes.

services/canopy-verification/src/api/save.rs:54

internal-api-key (X-Service-Api-Key; validate_api_key fn at save.rs:37)

other

POST /internal/v1/save/verify

API-KEY SURFACE, same pattern as ievs.rs: outside JWT middleware, shared key, non-constant-time compare, no Claims/actor.

services/canopy-verification/src/api/save.rs:83

internal-api-key (X-Service-Api-Key)

other

POST /internal/v1/save/additional-verification

Second SAVE handler on the same api-key surface (handle_additional).

services/canopy-verification/src/api/ssa.rs:53

internal-api-key (X-Service-Api-Key; validate_api_key fn at ssa.rs:36)

other

POST /internal/v1/ssa/solq

API-KEY SURFACE (#384, Medicaid SOLQ under the SSA CMA). Same third copy of validate_api_key — three duplicated implementations across ievs.rs/save.rs/ssa.rs.

services/canopy-verification/src/api/verifications.rs:76

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/verifications

Worker-dashboard pending panel + portal inbox feed. is_service() short-circuits before any role check; a bare service bearer (portal’s, canopy-web’s) passes with no actor and no audit of the absence.

services/canopy-verification/src/api/verifications.rs:115

require_service_caller

pure-role-gate

POST /v1/verifications

Producer create; canopy-eligibility orchestrator is the only production caller (service-to-service, not user-facing). ADR-025 household existence check follows at 121-123 (referential integrity, not authz).

services/canopy-verification/src/api/verifications.rs:152

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/verifications/{id}/resolve

Worker resolves a verification. Service bearer with no actor passes; who resolved is recorded from caller-supplied body field completed_by (UUID, contracts verifications.rs:93) — never from claims/actor.

services/canopy-verification/src/api/verifications.rs:187

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/verifications/{id}/respond

The applicant-portal write path: canopy-portal calls with a bare service token (portal verifications.rs:204 bearer_auth, no X-Canopy-Actor anywhere in portal src). Applicant identity arrives as body fields person_id/application_id derived from the portal session.

services/canopy-verification/src/api/verifications.rs:208

ownership gate (verification.application_id == req.application_id)

ownership-gate

POST /v1/verifications/{id}/respond

Cross-household boundary: stored application_id is authoritative; mismatch=403, unscoped=422. But the compared req.application_id is caller-supplied under a shared service bearer — the gate is only as strong as service-tier trust, since any service-token holder can read the verification and echo its application_id.

services/canopy-verification/src/api/verifications.rs:254

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/verifications/{id}/responses

Worker case-detail read; service bearer with no actor passes.

services/canopy-verification/src/guard.rs:60

require_real_adapters (boot-time gate)

other

n/a (boot)

NOT request authz: fail-closed boot refusal of fabricated IEVS/SAVE/SSA adapters outside development (#1265). Listed to account for the raw require_* grep noise; excluded from the authz count.

services/canopy-verification/src/main.rs:82

api-key provisioning (canopy_secrets::require_with_dev_fallback)

other

all 4 /internal/v1/* routes

The single CANOPY_INTERNAL_API_KEY feeds all three internal states (main.rs:120,142,149); dev fallback literal 'canopy-internal-dev-key'. Required outside development (expect at :87).

canopy-wic (11 branches)

Site Construct Classification Route Notes

services/canopy-wic/src/api/appointment_handlers.rs:93

require_service_caller

no-actor-passes

POST /v1/wic/households/{household_id}/appointments

USER-FACING mutation via canopy-web actions_wic.rs:166 (#448 BFF action) under bare service bearer. Attribution is body field scheduled_by: String (contracts appointments.rs:33) persisted verbatim into wic_appointments — a free-text, caller-supplied actor string.

services/canopy-wic/src/api/appointment_handlers.rs:136

require_service_caller

no-actor-passes

GET /v1/wic/appointments/upcoming

Worker-dashboard panel feed (#521, canopy-web upcoming_appointments.rs:53) via service bearer, no actor. Tenancy-unscoped: returns ALL households' scheduled appointments (LIMIT 50).

services/canopy-wic/src/api/handlers.rs:57

require_service_caller

pure-role-gate

POST /v1/determine

ADR-019 hard cutover: orchestrator-only, service-to-service.

services/canopy-wic/src/api/handlers.rs:175

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations/{id}

Dual read; service bearer passes without actor.

services/canopy-wic/src/api/handlers.rs:213

require_data_steward

pure-role-gate

POST /v1/determinations/{id}/redact

Identical twin of caps handlers.rs:203 (shared T2-6 #687 pattern). The other site missing from the plan-time count.

services/canopy-wic/src/api/handlers.rs:248

audit-attribution (Some(claims.sub) into determination.redacted event)

attribution-resolution

POST /v1/determinations/{id}/redact

claims.sub recorded directly, actor() never consulted; events.rs:81-92 takes actor_sub: Option<&str> string param. Twin of caps handlers.rs:238.

services/canopy-wic/src/api/handlers.rs:279

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/participants/{id}

Dual read; service bearer passes without actor.

services/canopy-wic/src/api/handlers.rs:302

require_service_or_caseworker_or_above

no-actor-passes

POST /v1/nutritional-risk-assessments

WRITE accepting the full store model as request body: assessor_worker_id (models.rs:113) is caller-supplied, never checked against claims — a service bearer can write an assessment attributed to any worker UUID.

services/canopy-wic/src/api/handlers.rs:355

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/determinations?household_id=X

Household filter is a query param, not an ownership gate.

services/canopy-wic/src/api/handlers.rs:381

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/nutritional-risk-assessments?person_id=X

Person filter is a query param, not an ownership gate.

services/canopy-wic/src/api/handlers.rs:403

require_service_or_caseworker_or_above

no-actor-passes

GET /v1/nutritional-risk-assessments/{id}

Dual read; service bearer passes without actor.

Route classification

canopy-web (BFF mutation authorization, #1516 / ADR-044)

All 66 mutating registrations carry a machine-enforced PROGRAM-scope classification on top of the #1004 role-tier extractor — the census lives in SCOPE_POLICY (xtask/src/cmd/route_authz.rs) and is a cargo xtask validate gate (an unlisted mutation fails the build), so this page records the model, not the rows:

  • RequireExtractor — the program is a compile-time fact; the handler takes ProgramScope<Snap|Tanf|Medicaid|Caps|Wic> (sealed tags; a shadow type cannot match), which 403s before the form body is read. Covers the ~30 per-program case actions and both IEVS handlers (SNAP authoritatively — they mutate the snap service regardless of the posted label).

  • RequireAuthorizedWrite — the program set is a property of the resource: the household’s participating programs (any-of, shared facts); the application row’s programs_requested (all-of — approve, deny, request-verification, the document actions); the path program on the per-program routes (run-determination and the section/data-collection proxies, unknown slugs refused); all selected programs on file-application; any-of SNAP/TANF on ELE consent (its trigger pair). Empty, unrecognized or non-string sets fail closed (422), never a permitted write.

  • NotProgramScoped(reason) — the 9 composition overrides + 2 studio wizard routes, each with its recorded reason.

Enforcement is structural, not remembered: the internal clients' write verbs are module-private, reachable only through ScopedClients in exchange for an AuthorizedResource proof (or the enumerated NeutralWrite — one variant: the audit-citation render RPC), so deleting a handler’s check stops compiling; the audit adds classification completeness, extractor presence, authorization reach, and class-borrowing symmetry on top. Routed tampering matrix: services/canopy-web/src/api/scope_authz_route_tests.rs.

canopy-web (BFF read authorization, #1518 / ADR-044)

All 30 protected GET registrations carry a READ_SCOPE_POLICY classification (xtask/src/cmd/route_authz.rs) under the same gate: unlisted, stale, and count-mismatched entries all fail cargo xtask validate, and a ScopedRead entry must REACH a scope carrier (the query-filter/participation/proof mechanisms) via the #1516 fixpoint engine — a name-level tripwire, deliberately over-approximate: removing a handler’s LAST carrier un-reaches it and fails the audit, while the classification table itself stays the human-audited truth. The model:

  • Participation gate (case detail, its tab/fact-history fragments, the cross-program summary): the household must participate in an in-scope program — the union of programs_requested across its applications, the same authority as the #1516 fact-write gate (any-of). On the FULL PAGE an empty union is an honest 404 — no household-existence oracle — and an unparseable one 422s, both rendering the shared error page; the tab and fact-history FRAGMENTS keep their banner denial shapes until #1526; a lookup failure refuses the view everywhere.

  • Row gate (case search, command palette): each candidate household passes the participation check before its row is BUILT; unknown participation drops fail-closed. Case search additionally degrades the fragment so an outage cannot read as "not found"; the per-keystroke palette omits silently (no degraded-state UI by design). A person with no household participates in nothing and never renders.

  • Query-time filters (application/notices/appeals indexes, team queue, renewals legs, the dashboard hero and panels): every list fetch carries the worker’s programs= storage slugs (or fans per-program routes over canonical slugs) — the degenerate unfiltered fetch has no call site.

  • Artifact authorization (notice PDF, document bytes): the artifact is authorized by its OWNING case (the notice’s program or its household’s participation; the document’s application programs_requested) before a byte streams — possession of the id is not access.

  • Panels are linkme plugins dispatched by slug, invisible to the route walker: the per-panel matrix is the PANEL_SCOPE_POLICY exhaustiveness test in services/canopy-web/src/dashboard/panels/mod.rs (22 panels; per-program panels render an explicit scoped-out card, never a fetch).

  • Audit surfaces (#1519): audit rows carry the authoritative programs set upstream (canopy-security: publisher envelope assertion → routing-key derivation → curated neutral families → NULL); the audit page, CSV export, the dashboard panel, the case-detail Audit section and the Activity tab all send the worker’s programs= query-time, and citation-by-id authorizes against the ROW’s set (neutral admits; overlap required; a no-assertion row is a 404 before attestation is even consulted). The BFF’s event_program classifier is retired as an authorization input. Pre-#1519 rows have no assertion and drop from every scoped worker’s view — the recorded forward-only posture (see the 20261128000000 migration header and security-operations.adoc).

  • /sse (#1520): the stream emits minimal invalidation messages (routing key + household id — never the envelope, which pre-#1520 carried every program’s determination and notice payloads to every authenticated worker); each connection filters fail-closed on the event’s program metadata (publisher assertion, else routing-key derivation, else NOT delivered — the #1519 precedence), and a mid-connection scope change terminates the stream so the browser reconnects through the full auth path. Read-denial shape unification: #1526.

Live disclosure regression: tests/e2e/specs/program-scope-reads.spec.ts (the tanf-only worker, whose legitimate universe is the seed’s one snap+tanf household).

canopy-appeals

Plan-time index: appeals 27 — read-verified require_* sites: 27. Exact match, all read-verified: 17 require_service_caller in services/canopy-appeals/src/api/mod.rs (lines 331, 586, 619, 672, 700, 742, 798, 991, 1371, 1560, 1743, 1837, 1902, 2002, 2208, 2272, 2304) + 10 in services/canopy-appeals/src/ipv/api.rs (lines 80, 152, 178, 216, 264, 359, 464, 506, 625, 664). Every one is require_service_caller — no other require_* variant, no role checks, no ownership gates, and zero claims.actor()/is_service()/service_id() call sites anywhere in the service source. Uniform: every registered route is service-only behind the shared bearer-auth middleware, one gate per handler as the first statement.

Method Path Handler Class Portal target

POST

/v1/appeals

file_appeal

service-only

GET

/v1/appeals

list_appeals

service-only

GET

/v1/appeals/queue

appeals_queue

service-only

GET

/v1/appeals/hearings/upcoming

upcoming_hearings

service-only

GET

/v1/appeals/{id}

get_appeal

service-only

GET

/v1/appeals/{id}/hearing-view

get_appeal_hearing_view

service-only

PUT

/v1/appeals/{id}/schedule

schedule_hearing

service-only

PUT

/v1/appeals/{id}/decision

record_decision

service-only

PUT

/v1/appeals/{id}/final-appeal

record_final_appeal

service-only

PUT

/v1/appeals/{id}/final-decision

record_final_appeal_decision

service-only

PUT

/v1/appeals/{id}/withdraw

withdraw_appeal

service-only

PUT

/v1/appeals/{id}/withdraw/confirm

confirm_withdrawal

service-only

PUT

/v1/appeals/{id}/withdraw/reinstate

reinstate_appeal

service-only

PUT

/v1/appeals/{id}/withdraw/finalize

finalize_withdrawal

service-only

POST

/v1/appeals/{id}/postponements

record_postponement

service-only

POST

/v1/internal/appeals/clock-check

trigger_clock_check

service-only

POST

/v1/internal/appeals/reconcile

trigger_reconcile

service-only

POST

/v1/ipv/cases

ipv::api::create_referral

service-only

GET

/v1/ipv/cases

ipv::api::list_cases

service-only

GET

/v1/ipv/cases/{id}

ipv::api::get_case

service-only

PUT

/v1/ipv/cases/{id}/schedule-adh

ipv::api::schedule_adh

service-only

PUT

/v1/ipv/cases/{id}/send-notice

ipv::api::send_notice

service-only

PUT

/v1/ipv/cases/{id}/record-decision

ipv::api::record_decision

service-only

PUT

/v1/ipv/cases/{id}/waiver

ipv::api::record_waiver

service-only

PUT

/v1/ipv/cases/{id}/impose-disqualification

ipv::api::impose_disqualification

service-only

PUT

/v1/ipv/cases/{id}/withdraw

ipv::api::withdraw_case

service-only

GET

/v1/ipv/disqualifications/active

ipv::api::check_active_disqualification

service-only

GET

/livez

canopy_api::livez_check (framework, mounted by ApiServer::router)

public

GET

/readyz

canopy_api::readyz_check (framework)

public

GET

/swagger-ui + /api-doc/openapi.json

SwaggerUi (framework)

public

POST

/test-clock (devstack only)

test_clock_router (compile-stripped from release builds; canopy-api/src/lib.rs:224)

public

Flags for the migration slices
  • STRING-PARAM ACTOR (the surface the prompt asked for): 8 request bodies in crates/canopy-contracts-appeals/src/appeals.rs carry pub actor: String ('The recording worker’s identity (audited on the row)') — RecordDecisionRequest (:329), RecordFinalAppealRequest (:384), RecordFinalAppealDecisionRequest (:405), WithdrawAppealRequest (:440), ConfirmWithdrawalRequest (:453), ReinstateAppealRequest (:463), FinalizeWithdrawalRequest (:472), RecordPostponementRequest (:482). Appeals persists and cross-service-forwards this unverified string. canopy-web derives it from the authenticated session (services/canopy-web/src/api/actions_snap_appeal_decision.rs:157 'actor is the AUTHENTICATED worker identity — never a form field'), but on appeals' wire ANY service bearer can assert any actor value. Migration: replace body actor with the verified X-Canopy-Actor claims chain; ScheduleHearingRequest (:263) has no actor at all and needs one added.

  • STALE COMMENT services/canopy-appeals/src/clients.rs:190-191 (verified false): SnapHearingClient doc says 'Unlike [EnrollmentClient] (which forwards the worker’s bearer), this carries appeals' own ADR-019 service token'. EnrollmentClient stopped bearer-forwarding at the #1105 cutover — it now holds its own ServiceTokenSource (clients.rs:68) and mints a service identity per call (send_get, :89-102); its own doc-comment (:58-63) explicitly says 'the pre-#1105 bearer-forwarding mode died with the inline writer'. Fix the contrast clause in the appeals slice.

  • STALE COMMENT services/canopy-appeals/src/clients.rs:284 (verified false, same defect): AdverseActionsClient doc says '(like [SnapHearingClient], unlike the bearer-forwarding [EnrollmentClient])'. All three clients now carry appeals' own service identity; no bearer forwarding exists anywhere in the service.

  • NO ACTORVERIFIER WIRED — MIGRATION ORDER CONSTRAINT: canopy_api::bootstrap builds AuthLayer::new(jwks) with no with_actor_verifier (crates/canopy-api/src/bootstrap.rs:180), and appeals never adds one. The shared middleware 401-rejects any request carrying X-Canopy-Actor when no verifier is configured (crates/canopy-auth/src/middleware.rs:138-144, 'actor verifier not configured'). If the BFF starts sending actor headers to appeals before the appeals slice wires a verifier, EVERY appeals call breaks. Verifier wiring must land first or together.

  • claims.actor() is called ZERO times in canopy-appeals src — there are no attribution-resolution branches and no no-actor-passes-with-audit branches (nothing observes or records the ABSENCE of an actor; the body-string audit records whatever the caller asserted). The whole service is uniformly pattern 1.

  • 6 IPV mutations have ZERO attribution of any kind (no body actor, no claims actor): schedule_adh, send_notice, record-decision, waiver, impose-disqualification, withdraw (ipv/api.rs:216, 264, 359, 464, 506, 625) — impose_disqualification is a permanent-consequence action with an anonymous audit trail. schedule_hearing (api/mod.rs:798) likewise. The slice should route these straight to verified claims actor, not replicate the body-string pattern.

  • No household/person ownership gates anywhere: any service bearer reads/mutates any appeal or IPV case (get_appeal :700, list_cases :152, etc.). Tenancy is delegated entirely to callers — consistent today (everything is worker-side via the BFF) but load-bearing if any route is ever exposed toward applicants.

  • Applicant portal does NOT call appeals: grep of services/canopy-portal/src has zero appeals paths, and appeals is not among the 8 portal-target services. All portal_target=false.

  • Background workers use appeals' own ADR-019 service identity exclusively (mandatory at boot for the assessment pipeline, main.rs:35-41) and self-label cross-service commands with STAY_ACTOR="canopy-appeals" (cb_stay.rs:35); enrollment records the authenticated service JWT sub alongside (#1093: caller-supplied identity is labeling, not authentication). This labeling convention is the model the API-layer string actor should converge to (label + verified claim, never label alone).

  • Devstack-only unauthenticated test-clock route is mounted at root by the framework when the test-clock feature is on (crates/canopy-api/src/lib.rs:219-225); compile-stripped from release builds — not an appeals-specific surface but present in dev inventories.

  • OpenAPI doc test pins 25 paths (api/mod.rs:2330-2335) — a migration slice that adds/splits routes must update that assertion.

Post-slice state — S-appeals (#1439, ADR-043 §C receiver contract)

Fifteenth and FINAL receiver of the epic &52 chain, a TERMINAL exchange target with ZERO user-only routes (the enforce flag is inert, set for fleet consistency). Deltas against the survey above:

  • THE TWO WEB-DRIVEN WORKER WRITES widened require_service_callerrequire_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES): the filing (POST /v1/appeals, api/mod.rs:334) and the decision (PUT /v1/appeals/{id}/decision, :1022). Their two BFF senders flipped onto exchanged bearers through the shared #1560 dispatch (appeals_write_client in web actions.rs — shared by both modules, the renewals precedent); the decision action’s ownership pre-check read and the filing’s SSR pre-resolution stay service-class (FU-A).

  • EVERYTHING ELSE stays service-only per FU-B / ADR-023 D4: the hearing lifecycle (schedule, final-appeal, final-appeal-decision — distinct from the WIDENED hearing decision), the four-route withdraw lifecycle, postponements, both /v1/internal triggers, and ALL TEN IPV routes. The survey’s attribution flags STAND unchanged — the 8 body-string actor fields, the zero-attribution IPV mutations (including impose_disqualification), and ScheduleHearingRequest’s missing actor are follow-on work, not this slice (the exchanged bearer now carries the verified worker identity in-token on the two widened routes for that follow-on to consume).

  • No portal callers exist; no service calls appeals inbound over HTTP (its cross-service coupling is outbound + MQ) — so the widening can strand nothing.

  • Azp allowlist: canopy-web-exchanger ONLY.

  • Conformance: the F4 matrix activates canopy-appeals (6 service-only probe-safe GET rows; appeals has no audit-on-read middleware). The two widened writes are pinned by receiver_contract_test.rs: direct 403 on both; the exchanged and service positives die at neutral pre-write failures (the filing at the 422 adverse-action binding check against the placeholder — the binding validates against enrollment BEFORE persist; the decision at the placeholder 404); rogue azp 403 on the decision path where the legit exchanger provably reaches 404.

canopy-applications

Plan-time index: 36+3+1 — read-verified require_* sites: 39. Read-verified claims-gate call sites: 35 require_service_caller + 3 require_service_or_caseworker_or_above (sections.rs:112/187/222) + 1 require_service_or_applicant_or_caseworker_or_above (api/mod.rs:397) = 39. The plan-time 36 for require_service_caller is a grep-hit count: grep matches 36 lines in src/api but one is the module doc-comment at api/assignments.rs:5 ('all gated claims.require_service_caller()'), not a call — only 35 are executable sites (assignments 4, authorized_reps 5, documents 5, documents_scan 2, recovery 3, mod.rs 16). Alternative reading: 36 = 35 + the main.rs:118 require_scanned_uploads call, which is a boot config guard, not request authz. Either way no gate exists that the plan count implies and this read missed; additionally this service has 2 local actor-gate helpers (require_supervisor_actor, verified_reviewer/verified_supervisor) and 3 attribution sites the require_* count does not cover.

Method Path Handler Class Portal target

POST

/v1/applications

create_application

service-only

GET

/v1/applications

list_applications

service-only

POST

/v1/applications:batchGet

batch_get_applications

service-only

GET

/v1/applications/caseload-trend

get_caseload_trend

service-only

GET

/v1/applications/{id}

get_application

service-only

yes

PUT

/v1/applications/{id}

update_application

service-only

DELETE

/v1/applications/{id}

withdraw_application

service-only

POST

/v1/applications/{id}/interview/waive

waive_interview

service-only

POST

/v1/applications/{id}/interview/complete

complete_interview

service-only

POST

/v1/applications/{id}/programs/{program}/determination

record_determination

service-only

POST

/v1/households/{household_id}/authorized-representatives

authorized_reps::create_rep

service-only

GET

/v1/households/{household_id}/authorized-representatives

authorized_reps::list_reps_by_household

service-only

GET

/v1/authorized-representatives/{id}

authorized_reps::get_rep

service-only

PUT

/v1/authorized-representatives/{id}

authorized_reps::update_rep

service-only

DELETE

/v1/authorized-representatives/{id}

authorized_reps::delete_rep

service-only

POST

/v1/workers/{worker_id}/assignments

assignments::create_assignment (service + supervisor/admin-actor-or-NO-actor)

service-only

GET

/v1/workers/{worker_id}/assignments

assignments::list_assignments_by_worker

service-only

DELETE

/v1/assignments/{id}

assignments::delete_assignment (service + supervisor/admin-actor-or-NO-actor)

service-only

GET

/v1/households/{household_id}/assignments

assignments::list_assignments_by_household

service-only

PUT

/v1/applications/{id}/sections/{program}/{section}

sections::upsert_section (service OR caseworker-or-above)

dual

GET

/v1/applications/{id}/sections

sections::list_sections (service OR caseworker-or-above)

dual

POST

/v1/applications/{id}/programs/{program}/complete-data-collection

sections::complete_data_collection (service OR caseworker-or-above)

dual

POST

/v1/applications/{id}/ele-consent

record_ele_consent (service OR applicant OR caseworker-or-above)

dual

POST

/v1/applicants/verify-credential

verify_credential

service-only

yes

POST

/v1/applicants/drafts

create_draft

service-only

yes

PATCH

/v1/applicants/drafts/{id}

patch_draft

service-only

yes

GET

/v1/applicants/drafts/{id}

get_draft

service-only

yes

POST

/v1/applicants/drafts/{id}/finalize

finalize_draft

service-only

yes

POST

/v1/applicants/drafts/reap

reap_drafts

service-only

POST

/v1/applicants/recover/initiate

recovery::recover_initiate

service-only

yes

POST

/v1/applicants/recover/kill/{token}

recovery::recover_kill

service-only

yes

GET

/v1/applicants/recover/{recovery_id}

recovery::recover_get

service-only

POST

/v1/applications/{id}/documents

documents::upload_document

service-only

yes

GET

/v1/applications/{id}/documents

documents::list_documents

service-only

yes

GET

/v1/applications/{id}/documents/{document_id}/content

documents::get_document_content

service-only

POST

/v1/applications/{id}/documents/{document_id}/accept

documents::accept_document (service + verified worker actor REQUIRED)

service-only

POST

/v1/applications/{id}/documents/{document_id}/reject

documents::reject_document (service + verified worker actor REQUIRED)

service-only

POST

/v1/applications/{id}/documents/{document_id}/scan-override

documents_scan::scan_override_document (service + verified supervisor/admin actor REQUIRED)

service-only

POST

/v1/applications/{id}/documents/{document_id}/rescan

documents_scan::rescan_document

service-only

GET

/livez

canopy-api livez_check (crates/canopy-api/src/lib.rs:215)

public

GET

/readyz

canopy-api readyz_check (crates/canopy-api/src/lib.rs:216)

public

GET

/api-doc/openapi.json (+ /swagger-ui)

canopy-api SwaggerUi merge (crates/canopy-api/src/lib.rs:233)

public

Flags for the migration slices
  • no-actor-passes on MUTATING routes: services/canopy-applications/src/api/assignments.rs:36 lets a bare service token create/delete household assignments with no actor — documented as intentional for 'pure system traffic — seeding, scheduled assignment workflows' (lines 16-18). A slice requiring actors everywhere must preserve or explicitly kill this system path.

  • Inconsistent attribution patterns across the service: sections.rs editor_uuid prefers actor() and 422s on non-UUID; ele-consent (api/mod.rs:427-428) NEVER consults actor() and silently falls back to app.submitted_by on non-UUID sub — a worker attesting via BFF service token is recorded as the APPLICANT, not the worker; finalize hardcodes submitted_by_role='applicant' (finalize_saga.rs:677); create_application takes submitted_by_role as spoofable body data (api/mod.rs:956-969).

  • String-param actor subjects: actor.sub and claims.sub are Strings parsed to Uuid at 4 sites (sections.rs:36, documents_scan.rs:30, documents_scan.rs:52, api/mod.rs:428) with divergent failure semantics (422 vs silent fallback).

  • Applicant flows carry NO actor at all: ADR-026 made the portal Postgres-free with opaque (non-JWT) sessions, so no applicant actor JWT exists to verify (documents.rs module doc lines 6-16 records this as deferred defence-in-depth needing an applicant-token signer). IDOR boundary for all /v1/applicants/* and portal document routes is the portal BFF session, not this origin.

  • No household-ownership gates anywhere at this origin: any service-class bearer can read/mutate ANY application, document, draft, or rep. Tenancy = ADR-019 service-class trust + BFF scoping; store-level scoping is application_id-join only (documents/sections queries prevent cross-application ID reach, not cross-caller reach).

  • Actor verifier registry holds ONLY canopy-web’s web-actor public key (main.rs:62-78); env-supplied PEM must be byte-identical to the keygen export or every actor JWT fails closed as UnknownKid — a second actor-minting service (e.g. a future applicant-token signer) needs registry wiring here.

  • rescan_document (documents_scan.rs:162-216) revokes document acceptance service-only with no actor identity — audit trail is only the scan_requeued event’s 'manual' trigger string; contrast scan-override which demands a verified supervisor.

  • Non-claims allow/deny surfaces a migration must keep in scope: applicant credential verify (store/credentials.rs:26, timing-equalized), recovery DOB second factor + confidential/kill-lock gate (api/recovery.rs:70, store/recovery.rs:71-72), 256-bit kill-switch token possession (recover_kill), document quarantine viewability gate (documents.rs:376, data-state not principal).

  • require_service_or_caseworker_or_above is documented as TRANSITIONAL ADR-019-cutover sugar (crates/canopy-auth/src/claims.rs:261-265) — the 3 dual sections routes + dual ele-consent are the cutover-era surfaces to resolve.

  • is_service()/service_id() mechanics: role-prefix scan for 'service:' in Keycloak realm_access.roles with azp fallback (claims.rs:231-247); require_service_caller never inspects actor(), so every service-only route is implicitly no-actor-passes at the gate level.

  • No api-key surfaces in this service — all bearer JWT via canopy-api auth_middleware on /v1; public: /livez, /readyz, /swagger-ui, /api-doc/openapi.json; devstack test-clock router is compile-stripped from release builds.

  • guard.rs:62 require_scanned_uploads (called main.rs:118) matches require_* greps but is a boot-time fail-closed scanner-config guard (ADR-041 accountable-override pattern), not request authorization.

  • Portal (canopy-portal/src) calls exactly: POST verify-credential (apply.rs:327, lookup.rs:130), POST drafts (apply.rs:68), PATCH drafts/{id} (apply.rs:145), GET drafts/{id} (apply.rs:361), POST drafts/{id}/finalize (apply.rs:214), POST recover/initiate (recover.rs:96), POST recover/kill/{token} (recover.rs:158), GET applications/{id} (persona.rs, home.rs, notices.rs, documents.rs:321, verifications.rs:385), GET+POST applications/{id}/documents (documents.rs:101/258, verifications.rs:371). Portal does NOT call document content, ele-consent (ele_consent rides the finalize payload), sections, or any worker/ops route.

Post-slice state — S-applications (#1429, ADR-043 §C receiver contract)

Fifth receiver — the fleet’s FIRST zero-swap adoption: applications has no pure human-role gates at all, so NO route Class changes and the survey table above stays exact. What the slice adds:

  • The exchanged_gate on the whole API router (threaded through app::build_router so the shared prod/test assembly carries it), plus Extension(ReceiverContract). ACCEPT_OWN_AUDIENCE=true makes exchanged aud=canopy-applications bearers validate; the gate vets exact-audience/azp; the 35 service-only guards then 403 them, and the 4 dual routes' existing role bars admit well-formed exchanged workers with no handler changes. ENFORCE_USER_ONLY_ROUTES is inert for guards here (no require_user_only sites) — set for fleet consistency.

  • The azp allowlist is canopy-web-exchanger ONLY (the persons least-privilege precedent — applications is not an EXCHANGE_TARGETS program).

  • The wired ActorVerifier is untouched: service+actor bearers are Service-shaped and pass the gate; an exchanged bearer carrying an actor header 401s in the middleware before the gate. The three actor-gated document routes (accept/reject/scan-override) stay on the verified-actor path — their exchange migration is C1’s actor-retirement scope, where the verified_reviewer/verified_supervisor attribution must resolve across BOTH the ViaActor and Direct-exchanged arms.

  • Incidental attribution improvement (no code change): sections editor_uuid already prefers actor-else-sub — once the BFF sends an exchanged Direct bearer, it records the real worker instead of today’s service-sub misattribution (the flag above stands until then).

  • Conformance: the F4 matrix activates canopy-applications (3 service-only + 2 dual rows; the actor-gated document routes excluded — actor-required and mutating; ele-consent also excluded because its guard vocabulary INCLUDES the applicant role, so the harness’s wrong-role principal legitimately passes auth there — live-verified, its applicant-arm coverage belongs to P2/P3); a new receiver_contract_test.rs pins the dual exchanged-arm positive, both gate kills (rogue azp, multi-audience), and the service-only 403-for-exchanged.

canopy-persons

Plan-time index: persons 28 — read-verified require_* sites: 51. Read-verified require_* call sites in services/canopy-persons/src: 26 require_service_caller (api/mod.rs 255,483,531,572,613,642,710,834,903,986,1106,1177,1273,1334,1376,1404,1430,1624,1690,1755,1820,1873,2079,2114,2153,2328) + 11 require_finalize_caller (258,645,1109,1276,1627,1693,1758 conditional; 2080,2115,2154,2329 unconditional) + 3 require_data_steward (1928,1987,2445) + 1 require_admin_or_quality_control (export.rs:170) + 7 require_fact_ownership (1286,1337,1642,1707,1772,1824,1937) + 2 require_member_ownership (1122,1182) + 1 require_active_generation (mod.rs:314; defined store/finalize.rs:46) = 51. If the plan counted only bearer-claims-tier gates (claims.require_*: 26+3+1) that is 30 today; 30 − households:batchGet’s require_service_caller (mod.rs:986, landed with #1203/#1249) − compensate_finalize_orphan’s require_data_steward (mod.rs:2445, ADR-038 MR9) = 28, exactly the plan-time figure — the plan index almost certainly predates those two merges and excluded the helper-level ownership/finalize/generation gates. main.rs:21 require_kek is boot key-loading, not authz, and is excluded.

Method Path Handler Class Portal target

POST

/v1/persons

create_person

service-only

GET

/v1/persons

list_persons

service-only

GET

/v1/persons/{id}

get_person

service-only

yes

PUT

/v1/persons/{id}

update_person

service-only

DELETE

/v1/persons/{id}

delete_person

service-only

POST

/v1/households

create_household

service-only

GET

/v1/households/{id}

get_household

service-only

GET

/v1/households/{id}/full

get_household_full

service-only

POST

/v1/persons:batchGet

batch_get_persons

service-only

POST

/v1/households:batchGet

batch_get_households

service-only

POST

/v1/households/{id}/members/claims

claim_household_member

service-only

DELETE

/v1/households/{id}/members/claims/{fact_id}

close_household_member_claim

service-only

GET

/v1/persons/{id}/income

list_income

service-only

POST

/v1/persons/{id}/income/claims

claim_income

service-only

DELETE

/v1/persons/{id}/income/claims/{fact_id}

close_income_claim

service-only

GET

/v1/persons/{id}/assets

list_assets

service-only

POST

/v1/persons/{id}/assets/claims

claim_asset

service-only

GET

/v1/persons/{id}/expenses

list_expenses

service-only

POST

/v1/persons/{id}/expenses/claims

claim_expense

service-only

GET

/v1/persons/{id}/addresses

list_addresses

service-only

POST

/v1/persons/{id}/addresses/claims

claim_address

service-only

DELETE

/v1/persons/{id}/addresses/claims/{fact_id}

close_address_claim

service-only

GET

/v1/export/persons

export::export_persons

user-only (mechanical, #1428)

POST

/v1/persons/{id}/facts/{kind}/{fact_id}/redact

post_redact_fact

user-only (mechanical, #1428)

POST

/v1/persons/{id}/redact-ssn

post_redact_ssn

user-only (mechanical, #1428)

POST

/v1/internal/finalize-operations/{op}/{gen}/register

register_finalize_operation

service-only

POST

/v1/internal/finalize-operations/{op}/{gen}/release

release_finalize_operation

service-only

POST

/v1/internal/finalize-operations/{op}/{gen}/cancel

cancel_finalize_operation

service-only

GET

/v1/internal/finalize-operations/{op}

get_finalize_operation

service-only

POST

/v1/households/{household_id}/compensate-finalize-orphan

compensate_finalize_orphan

user-only (mechanical, #1428)

GET

/livez

canopy_api::livez_check (mounted by ApiServer::router, not api::routes())

public

GET

/readyz

canopy_api::readyz_check (mounted by ApiServer::router, not api::routes())

public

Flags for the migration slices
  • ZERO actor() call sites in canopy-persons: no attribution-resolution branches exist anywhere. Every Pub-1075 ssn.accessed audit attributes claims.sub raw (api/mod.rs:265,500,539,584,869,954; api/export.rs:217) and both redaction events use claims.sub (mod.rs:1946-1955, 2010-2016). A verified X-Canopy-Actor is silently ignored — migration must introduce actor().map_or(claims.sub, \|a\| a.sub) shapes at 9+ attribution sites.

  • Portal reaches persons with a bare service bearer and NO X-Canopy-Actor: canopy-portal fetch_json (services/canopy-portal/src/persona.rs:117-127) sends bearer_auth only; call sites persona.rs:110 and home.rs:209 hit GET /v1/persons/{id}. The resulting ssn.accessed audit row names the portal service sub, not the applicant — the response includes ssn_last_four so the audit DOES fire.

  • Fact-claim attribution is caller-supplied wire data: req.author (Author::Worker/Member) rides the request body and is only checked as not-System (reject_system_author, api/mod.rs:1443); it is never cross-checked against the bearer Claims or actor. A migration slice must decide whether body-author must match the verified actor.

  • All close/delete surfaces are attribution-free by design: publish_member_closed/publish_income_closed/publish_address_closed hardcode author=None citing 'the ADR-019 on-behalf-of limitation' (services/canopy-persons/src/events.rs:214, 276-278, 339) — these comments are the explicit TODO markers for the actor migration; delete_person (mod.rs:608-620) emits no event at all.

  • require_finalize_caller depends on service_id()'s role-suffix-before-azp ordering (crates/canopy-auth/src/claims.rs:241-247): standing alone it would accept ANY token whose azp is canopy-applications (e.g. a user token minted through that client). Safe today only because require_service_caller precedes every one of its 11 call sites; the coupling is documented (finalize.rs:86-88) but not enforced in the helper — keep the pairing when regenerating handlers.

  • Inconsistent audit posture: ssn.accessed is fail-closed (request fails if the audit can’t stage, mod.rs:419 doc), but persons.export.requested is warn-and-continue (export.rs:264-271) — a FOIA/portability export can succeed with no audit event.

  • The three user-only routes (export, redact-fact/ssn, compensate-finalize-orphan) gate on bearer realm roles only, with no is_service() exclusion — a service token granted admin/quality_control/data_steward realm roles would pass. User-only is intent, not mechanism.

  • Stale OpenAPI 403 descriptions: most service-only routes still say 'Caller lacks admin role' (e.g. api/mod.rs:243,472,520,560,604,631,701,822,892) though the actual gate is require_service_caller — misleading for anyone deriving the authz model from persons.json.

  • No caller-scoped tenancy anywhere: any service-class bearer can read/write ANY person/household (ownership gates require_fact_ownership/require_member_ownership are data-integrity within the path subject, not caller scoping). Household-ownership-by-actor gating (the enrollment gate_household_actor_access pattern) does not exist in persons.

  • No api-key surfaces, no string-typed actor params in handlers (attribution flows as &str claims.sub into events); auth middleware is the shared canopy-api /v1 nest (JWT for every registered route), /livez + /readyz public via canopy-api.

Post-slice state — S-persons (#1428, ADR-043 §C receiver contract)

Fourth receiver — user-only-enforcement ONLY on a hard service-only data service (no hop-2; the 22 data-plane guards and the applications-scoped finalize surface stay untouched; persons mounts no admin router, so the exchanged_gate wraps the whole API router). Deltas against the survey above (retained as the migration baseline):

  • The four pure human-role gates became MECHANICAL user-only routes: require_user_only(["data_steward"]) on redact-fact (api/mod.rs:1931), redact-ssn (:1994), and compensate-finalize-orphan (:2458), and require_user_only(["admin", "quality_control"]) on the FOIA/ portability bulk export (export.rs:175). This RETIRES the survey flag "user-only is intent, not mechanism": service class is now 403 service_class_on_user_only unconditionally, and under CANOPY_PERSONS__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a legacy broad-audience worker bearer is 403 aud_not_exact.

  • Attribution via EffectiveUser at the four user-only sites (both redact event actors, the export SSN-access audit + payload actor). The service-only audit_ssn_access sites deliberately stay claims.sub — those callers are System-shaped, so the projection is byte-identical and the churn buys nothing; the survey’s "9+ site" actor migration remains open for whichever unit wires real actor propagation.

  • ALLOWLIST DIVERGENCE (least privilege): persons allowlists ONLY canopy-web-exchanger — persons is not in EXCHANGE_TARGETS, so the eligibility exchanger never legitimately mints persons-audience user tokens; allowlisting it would be surplus authority. (The realm still grants the scope to both exchangers; the per-service azp allowlist is the enforcement point.)

  • Operator tooling: cargo xtask sweep-finalize-orphans --apply and the canopy-cli redact commands exchange natively since #1501 (RFC 8693 via the canopy-web-exchanger pair by default, overridable per deployment) — a raw worker token never reaches the user-only routes; the finalize-orphan-sweep runbook is back to a single mint.

  • Conformance: the F4 matrix activates canopy-persons — the three steward mutations probe 404-before-write; the bulk export is excluded write-unsafe (real read + outbox publish) and pinned by the new dedicated export_test.rs (its four arms closed a zero-coverage gap).

canopy-renewals

Plan-time index: 25 — read-verified require_* sites: 27. 27 require_service_caller call sites read-verified, all in services/canopy-renewals/src/api/mod.rs (one per handler; 27 handlers across 26 OpenAPI paths — SNAP_CERTIFICATIONS carries POST+GET). The +2 over the plan-time 25 are the two universe-snapshot gates at api/mod.rs:548 and api/mod.rs:583, added by #1470 (commit 1a9927bc, 'immutable SNAP universe snapshot generations') after the plan count was taken. No other require_* variants exist in the service (no require_caseworker_or_above, no require_service_or_* transitional gates).

Method Path Handler Class Portal target

POST

/v1/renewals/snap/certifications

create_certification

service-only

GET

/v1/renewals/snap/certifications

get_active_certification

service-only

GET

/v1/renewals/snap/certifications/{id}

get_certification

service-only

GET

/v1/renewals/snap/due

list_due

service-only

POST

/v1/renewals/snap/universe-snapshots

create_universe_snapshot

service-only

GET

/v1/renewals/snap/universe-snapshots/{id}/rows

list_universe_snapshot_rows

service-only

GET

/v1/renewals/{program}/due

list_program_due

service-only

GET

/v1/renewals/overdue

list_overdue

service-only

GET

/v1/renewals/caseload-trend

get_caseload_trend

service-only

GET

/v1/renewals/snap/interim-contacts/due

list_interim_contacts_due

service-only

POST

/v1/renewals/snap/certifications/{id}/interim-contact

record_interim_contact

service-only

POST

/v1/renewals/snap/certifications/{id}/change-report

create_change_report

service-only

GET

/v1/renewals/snap/nudges

list_recert_nudges

service-only

POST

/v1/renewals/snap/nudges/{id}/action

action_recert_nudge

service-only

POST

/v1/renewals/{program}/certifications/{id}/interim-contact

record_program_interim_contact

service-only

POST

/v1/renewals/{program}/certifications/{id}/change-report

create_program_change_report

service-only

GET

/v1/renewals/snap/periodic-reports

list_periodic_reports

service-only

GET

/v1/renewals/snap/periodic-reports/{id}

get_periodic_report

service-only

POST

/v1/renewals/snap/periodic-reports/{id}/form

record_periodic_report_form

service-only

POST

/v1/renewals/snap/periodic-reports/{id}/vcl

send_periodic_report_vcl

service-only

POST

/v1/renewals/snap/periodic-reports/{id}/verified

record_periodic_report_verified

service-only

POST

/v1/renewals/snap/periodic-reports/{id}/complete

complete_periodic_report

service-only

POST

/v1/renewals/snap/periodic-reports/{id}/reopen

reopen_periodic_report

service-only

GET

/v1/renewals/snap/redeterminations

list_redeterminations

service-only

POST

/v1/renewals/snap/redeterminations/{id}/action

action_redetermination

service-only

POST

/v1/renewals/scheduler/run

run_scheduler_pass

service-only

POST

/v1/renewals/caseload-rollup/refresh

run_caseload_rollup_refresh

service-only

Flags for the migration slices
  • Zero actor consumption: no claims.actor(), is_service()-beyond-the-guard, or service_id() call anywhere in services/canopy-renewals/src — every one of the 27 gates is the bare require_service_caller (is_service()-only, canopy-auth claims.rs:253-259). A no-actor service bearer passes every user-facing decision in the service.

  • String/UUID-param actors on all worker-decision writes: req.action_by (body UUID) at api/mod.rs:1207 and api/mod.rs:1852; req.actor (free string) at api/mod.rs:1610 forwarded into canopy-enrollment’s audit record. A migration slice must replace these with verified X-Canopy-Actor claims and update canopy-web to send the header.

  • canopy-web BFF calls every renewals write with plain .post (no post_with_actor) — actions.rs:87/155/261, actions_snap.rs:320, actions_tanf/medicaid/caps/wic — so no X-Canopy-Actor even arrives at renewals today; flipping renewals to no-actor-rejects requires the canopy-web sender change in the same slice.

  • Several worker writes record NO worker identity at all (not even a body field): record_interim_contact, create_change_report, program variants, all five periodic-report transition commands (form/vcl/verified/complete) — the change-report and cycle rows have no actor column populated from the request.

  • No household-ownership gates anywhere: household_id is a trusted caller-supplied query/body param on every household-scoped read/write (HouseholdQuery api/mod.rs:66-69, NudgeListQuery api/mod.rs:1139-1144, program change-report bodies). Safe only while callers are service-class; any future user-bearer opening must add ownership checks.

  • Store layer has zero authz predicates; the WHERE action_taken IS NULL guards (store.rs:1017-1036, pr_pipeline.rs:785-795) are idempotency/first-decision-wins, not authorization.

  • Machine attribution is the constant PIPELINE_ACTOR = 'canopy-renewals periodic-report pipeline' (pr_pipeline.rs:63), stamped at pr_pipeline.rs:271/284/402/413 on scheduler-driven adverse actions — enrollment-side audit cannot distinguish this constant from a caller-spoofed string.

  • Pattern is uniformly clean: one construct (require_service_caller) across all 27 handlers, no #429-era per-endpoint role enumeration and no transitional require_service_or_caseworker_or_above — a single mechanical migration surface.

  • reopen_periodic_report has a deployment-identity gate (api/mod.rs:1580-1588): Extension<Option<EnrollmentClient>> is None without ADR-019 OIDC creds and the endpoint 500s rather than half-reopening — not caller authz, but a slice touching service identity wiring must preserve it.

  • OpenAPI regression test pins 26 paths (api/mod.rs:2003-2006) — any route added/removed in a migration slice must update it.

  • canopy-renewals is NOT an applicant-portal target (not in the 8-service list) and canopy-portal/src contains zero renewals calls (only a comment at services/canopy-portal/src/home.rs:284), so portal_target=false on all routes.

  • Stale-comment risk for slices: utoipa 403 descriptions on all 27 handlers say 'caller not authorized for this operation' generically; if gates gain actor requirements the OpenAPI responses and the '#1470: service-class only' / 'Service-caller only' doc-comments (api/mod.rs:547, 789) need matching updates.

Post-slice state — S-renewals (#1436, ADR-043 §C receiver contract)

Twelfth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target with ZERO user-only routes (the verification/ enrollment precedent — the enforce flag is inert, set for fleet consistency). Deltas against the survey above:

  • THE SIX WEB-DRIVEN WORKER WRITES widened require_service_callerrequire_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES): certification create (mod.rs:319), snap interim-contact (:923), snap change-report (:998), program interim-contact (:1069), program change-report (:1122), nudge action (:1219). Their eleven BFF senders flipped onto exchanged bearers through the shared #1560 dispatch (renewals_write_client in web actions.rs — ONE shared helper, not per-file, because every renewals sender targets the same audience and error surface; fail-on-denied). SSR pre-checks inside the create-certification action (cert lookup, determination resolve) stay on the service identity (FU-A).

  • Everything else stays service-only per FU-B / ADR-023 D4: the machine surfaces (universe snapshots, scheduler run, rollup refresh, the five periodic-report pipeline commands, redetermination action — NONE of the machine surfaces has a web sender; test-lib journeys drive the worker-shaped ones service-class), and every SSR read.

  • The survey’s attribution flags STAND: req.action_by / req.actor body attribution is unchanged this slice (the exchanged bearer now carries the worker’s verified identity in-token on the widened routes, so a follow-on can derive attribution from EffectiveUser — not done here).

  • Azp allowlist: canopy-web-exchanger ONLY.

  • Conformance: the F4 matrix activates canopy-renewals (6 service-only probe-safe GET rows). The six widened writes are mutating surfaces, pinned instead by receiver_contract_test.rs: direct 403 on ALL SIX; exchanged 404 on the three placeholder-safe writes; the create’s pre-insert date-order 422 on exchanged AND service arms (the two program-parameterized writes insert directly, so their exchanged arm has no safe probe); rogue azp 403 pinned on a widened write where the legit exchanger provably reaches 404.

canopy-snap

Plan-time index: snap 7+21 — read-verified require_* sites: 31. The plan-time 7+21 matches exactly what reading found for those two constructs: 7 require_service_caller sites (determine_handler.rs:77,199,309; recompute_handler.rs:156; hearing_view_handler.rs:73; abawd_handler.rs:212; params_handler.rs:101) and 21 require_service_or_caseworker_or_above sites (verification_handler.rs:61,91,124; tsnap_handler.rs:32,58; categorical_handler.rs:37,73,96,126; overpayments_handler.rs:89,127,152,181,209,234; abawd_handler.rs:56,160,242; determine_handler.rs:244,272; params_handler.rs:47). The delta of +3 is other require_* variants the plan count did not enumerate: require_admin_or_quality_control x2 (export.rs:93 and the or_else fallback at determine_handler.rs:310) and require_data_steward x1 (determine_handler.rs:378). Not counted in the 31 (outside services/canopy-snap/src): one more require_service_caller in the shared canopy-api admin replay handler (crates/canopy-api/src/admin.rs:129) that snap mounts into its router, plus non-authorization require_* (main.rs:316 require_kek, main.rs:362 require_with_dev_fallback — secrets loading, not gates).

Method Path Handler Class Portal target

POST

/v1/determine

determine_handler::post_determine

service-only

POST

/v1/determine/dry-run

determine_handler::post_determine_dry_run

service-only

GET

/v1/determinations/{id}

determine_handler::get_determination

dual

GET

/v1/determinations

determine_handler::list_determinations

dual

GET

/v1/determinations/{id}/snapshot

determine_handler::get_determination_snapshot

dual

GET

/v1/determinations/{id}/hearing-view

hearing_view_handler::get_determination_hearing_view

service-only

POST

/v1/determinations/{id}/overpayment-recompute

recompute_handler::post_overpayment_recompute

service-only

POST

/v1/determinations/{id}/redact

determine_handler::post_redact_determination

user-only

POST

/v1/categorical-eligibility/participations

categorical_handler::post_participation

dual

GET

/v1/categorical-eligibility/participations

categorical_handler::list_participations

dual

POST

/v1/student-status

categorical_handler::post_student_status

dual

GET

/v1/student-status

categorical_handler::list_student_statuses

dual

GET

/v1/verification/discrepancies

verification_handler::list_discrepancies

dual

PUT

/v1/verification/discrepancies/{id}/resolve

verification_handler::resolve_discrepancy

dual

GET

/v1/verification/ievs-matches

verification_handler::list_ievs_matches

dual

GET

/v1/params

params_handler::get_params

dual

yes

GET

/v1/params/provenance

params_handler::get_params_provenance

service-only

GET

/v1/tsnap/{id}

tsnap_handler::get_tsnap

dual

GET

/v1/tsnap

tsnap_handler::list_tsnap

dual

POST

/v1/abawd/activity

abawd_handler::record_activity

dual

GET

/v1/abawd/tracking

abawd_handler::list_tracking

dual

POST

/v1/abawd/tracking:batchGet

abawd_handler::batch_get_tracking

service-only

GET

/v1/abawd/tracking/{id}/activities

abawd_handler::list_activities

dual

GET

/v1/export/determinations

export::export_determinations

user-only

POST

/v1/overpayments

overpayments_handler::post_claim

dual

GET

/v1/overpayments

overpayments_handler::list_claims

dual

GET

/v1/overpayments/{id}

overpayments_handler::get_claim

dual

POST

/v1/overpayments/{id}/repayment-plans

overpayments_handler::post_repayment_plan

dual

POST

/v1/overpayments/{id}/recoupments

overpayments_handler::post_recoupment

dual

GET

/v1/overpayments/{id}/ledger

overpayments_handler::get_ledger

dual

POST

/v1/admin/events/replay

canopy_api::admin::admin_replay_handler (shared crate, mounted main.rs:405-411)

service-only

GET

/livez

canopy_api::livez_check (ApiServer::router, outside the /v1 auth nest)

public

GET

/readyz

canopy_api::readyz_check (ApiServer::router, outside the /v1 auth nest)

public

Flags for the migration slices
  • NO ACTOR VERIFIER IN SNAP (governs everything): canopy_api::bootstrap builds AuthLayer::new(jwks) without with_actor_verifier (crates/canopy-api/src/bootstrap.rs:180); canopy-auth middleware then 401-rejects ANY request carrying X-Canopy-Actor ('actor verifier not configured', middleware.rs:138-147). claims.actor() is unconditionally None on every snap route. Only canopy-applications and canopy-tanf wire verifiers. Any slice introducing actor-required or actor-audited gates to snap must wire the verifier first or every BFF-forwarded actor header will hard-fail the request.

  • DEAD ADMIN SURFACE: POST /v1/admin/events/replay (shared canopy-api admin.rs:129-133, mounted by snap main.rs:405-411) requires service caller + verified actor with admin role — unsatisfiable in snap today (header→401 at middleware; no header→actor None→403). Operator event replay on canopy-snap cannot authorize until the verifier lands.

  • STALE/ASPIRATIONAL COMMENTS: recompute_handler.rs:155 ('Worker-actioned, mediated by the BFF/CLI as a service caller') and caller_uuid’s doc ('The authenticated worker behind the service call (the actor)') describe actor plumbing snap’s middleware cannot deliver; requested_by/discovered_by on overpayment_recomputes and #382 claims (recompute_persist.rs:65,145,170) always record the SERVICE subject, or Uuid::nil() + warn when the service sub is not a UUID (recompute_handler.rs:240-243).

  • BODY-SUPPLIED ATTRIBUTION (string-param actors): PUT /v1/verification/discrepancies/{id}/resolve trusts resolved_by_sub/resolved_by/resolved_fact_id from the request body (canopy-contracts-snap/src/verification.rs:116-133 — doc calls resolved_by_sub 'the real attribution'); POST /v1/overpayments trusts CreateClaimRequest.discovered_by from the body. Any bearer passing the dual gate can forge worker attribution. Migration should re-source these from verified actor claims.

  • FREE-STRING ACTOR AT STORE LAYER: store/overpayments.rs void_claim/append_adjustment take actor as Option<&str>; the appeal.overpayment_assessment_voided subscriber stamps the literal event name as the actor (main.rs:299 → overpayments.rs:552). claim_adjustments.actor / recoupment_ledger.actor are untyped strings.

  • API-KEY SURFACE (outbound): canopy-snap→canopy-verification uses CANOPY_INTERNAL_API_KEY with dev fallback 'canopy-internal-dev-key' (main.rs:362-367, verification_client) — a pre-OIDC internal-key hop; inbound snap routes are all OIDC-bearer. A later slice should move this to ADR-019 service tokens like the persons/enrollment/rules clients already are.

  • BEST-EFFORT AUDIT ON BULK EXPORT: export.rs:131-145 — if staging/committing snap.export.requested fails it only warn-logs and the bulk export still returns 200; the export audit chain can silently lose entries.

  • SERVICE-IDENTITY PINS EXIST (a fifth construct beyond role gates): determine_policy.rs:82,90 pin as_of/trigger to service_id()=='canopy-eligibility'. Note service_id() falls back to azp for non-service tokens (canopy-auth claims.rs:241-247), so the comparison is against whatever azp Keycloak sets on worker tokens — currently never 'canopy-eligibility', but the fallback makes the pin azp-sensitive.

  • PORTAL SURFACE IS EXACTLY ONE ROUTE: canopy-portal calls only GET /v1/params (snap_params.rs:69-73, via its /apply/snap-params proxy) using the portal’s own service token, no actor — it passes through the is_service() arm of the dual gate. All other snap_url references in portal src are test fixtures.

  • BROAD DUAL GATES ON MONEY WRITES: POST /v1/overpayments, /repayment-plans, /recoupments, /abawd/activity, /categorical-eligibility/participations, /student-status all pass ANY service token in the mesh (require_service_or_caseworker_or_above is_service() arm) with zero attribution captured — the widest no-actor-passes exposure in this service.

  • MQ SUBSCRIBERS WRITE WITH NO PRINCIPAL: ipv.not_established (ipv_claim.rs, discovered_by: None), appeal.overpayment_assessed (main.rs:173-239, discovered_by: None), appeal.overpayment_assessment_voided (main.rs:275-300, literal-string actor) create/void claims outside any Claims context — expected for event-driven paths but relevant if slices add row-level attribution requirements.

  • GET /v1/determinations/{id}/snapshot serves the FTI-adjacent frozen snapshot (proven facts, income, household composition, DOB/disability) to any bare service bearer via the require_service_caller arm — the in-code least-privilege note (ADR-028 §57) excludes caseworkers but not unattributed services.

  • Devstack-only test-clock routes are mounted unauthenticated at root when the test-clock feature is compiled (canopy-api lib.rs:218-225); compile-stripped from release builds — not a production surface but worth knowing when auditing devstack.

  • Store layer has NO household-ownership/tenancy gates anywhere in services/canopy-snap/src/store — all row scoping is by caller-supplied IDs after the role gate; there is no applicant-facing ownership concept in this service.

Post-slice state — S-snap (#1431, ADR-043 §C receiver contract)

Seventh receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target (single-exact audience — no hop-2 pair; that shape is eligibility-only). Deltas against the survey above (retained as the migration baseline; where they conflict, this block is current):

  • POST /v1/determine (determine_handler.rs:63) moved require_service_callerrequire_service_or_exchanged — the orchestrator’s service token, or the hop-2 exchanged bearer it re-exchanges from the S-eligibility pair (EXCHANGE_TARGETS now includes canopy-snap in the devstack). A DIRECT worker bearer stays 403 (the #439 posture, pinned by receiver_contract_test.rs). /v1/determine/dry-run deliberately stays require_service_caller — it is a #1213 service self-call surface with no human caller.

  • The 2 effectively-user-only gates became MECHANICAL user-only routes: require_user_only(["data_steward"]) (redact, determine_handler.rs:377) and require_user_only(["admin", "quality_control"]) (export, export.rs:96). Service class is 403 service_class_on_user_only unconditionally; with CANOPY_SNAP__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a broad-audience worker bearer is 403 aud_not_exact.

  • THE SERVICE-IDENTITY PINS flag above is UPDATED: the as_of/trigger pins (determine_policy.rs) now accept BOTH orchestrator shapes via orchestrator_caller — the exact canopy-eligibility service identity OR azp == canopy-eligibility-exchanger (the hop-2 bearer only eligibility’s exchanger can mint; canopy-web’s exchanger mints a different azp, so a worker cannot fabricate the shape). Without the second arm the UTC-stamped as_of crossing the legal-timezone day boundary would 403 every exchanged interactive dispatch each ET evening — the underlying date seam is #1561 (this slice fixes the identity predicate, not the date convention).

  • Attribution converged on EffectiveUser at three sites: the redaction event actor, the export audit actor, and recompute_handler’s caller_uuid (the service’s ONE genuine actor().map_or shape — value-identical, since snap has no actor verifier and every admitted user-only bearer is non-service). The STALE/ASPIRATIONAL COMMENTS flag above is resolved for caller_uuid; the BODY-SUPPLIED ATTRIBUTION flag (resolved_by_sub, discovered_by) is UNCHANGED — server-side re-derivation is #874’s scope, deliberately not this slice’s.

  • POST /v1/determinations/{id}/overpayment-recompute (recompute_handler.rs) moved require_service_callerrequire_service_or_exchanged: the BFF now sends the worker’s own exchanged bearer, so caller_uuid finally records the WORKER instead of the BFF’s service subject — resolving the survey’s "worker identity is never verifiable here" flag (the sender flip without this widening would 403; caught in the slice’s J-review). Service class (CLI/ops) keeps working; a direct worker bearer stays 403.

  • Every route sits behind the exchanged_gate (main.rs, layered on api::routes() before the admin merge); the rest of the dual mass is unchanged. Azp allowlist: canopy-web-exchanger,canopy-eligibility-exchanger (snap is an EXCHANGE_TARGETS program — the tanf/medicaid posture).

  • Senders switched for THIS target: the five canopy-web worker writes (overpayment-recompute, abawd-activity, snap discrepancy-resolve, IEVS accept/reject flips) ride the shared #1560 dispatch with exchanged aud=canopy-snap bearers (fail-on-denied, no downgrade); the IEVS accept’s persons fact write stays service-path (that target’s slice owns its sender). The orchestrator fan-out to snap exchanges when a subject exists (interactive); bulk/order dispatches stay service-class by design.

  • Conformance: the F4 matrix activates canopy-snap (9 rows: 4 probe-safe dual incl. the PORTAL-TARGET /v1/params, the redact user-only row — guard → reason check → 404 on the placeholder, before any shred — 3 service-only, and the overpayment-recompute ServiceOrExchanged row pinning the widened money route; the F4 SEED determine row also reclassifies ServiceOnly → ServiceOrExchanged, probing empty-body 400-after-auth). Floor 430 → 510. Excluded from the NEW tranche: a determine/dry-run row (a valid probe body runs a real determination) and export (stages an audit event; pinned with the cross-service pair-replay 401 by receiver_contract_test.rs).

canopy-tanf

Plan-time index: tanf 6+21 — read-verified require_* sites: 31. Plan-time 6+21=27 counts exactly the two migration-relevant families, and both reconcile perfectly: 6 require_service_caller (handlers.rs:60,227; work_requirement_handlers.rs:108,485,1011; discrepancy_handlers.rs:83) + 21 require_service_or_caseworker_or_above (handlers.rs:229,295; work_requirement_handlers.rs:54,142,227,290,397,967,1050,1117; grg_handlers.rs:43,89; personal_responsibility_handlers.rs:44,72,119; overpayments_handler.rs:89,127,152,181,209,234). The read-verified total in services/canopy-tanf/src is 31 because 4 additional require_* gates exist that the plan family-count excluded: 3 require_fti_auditor (fti_audit_handlers.rs:57,94,121) + 1 require_data_steward (handlers.rs:335). A 32nd require_service_caller sits at crates/canopy-api/src/admin.rs:129 on the shared POST /v1/admin/events/replay route that tanf’s router merges (main.rs:198-204) — outside the service tree but on this service’s live surface. main.rs:98 require_kek is key-material loading, not authorization, and is excluded.

Method Path Handler Class Portal target

POST

/v1/determine

handlers::post_determine

service-or-exchanged (#1425)

GET

/v1/determinations

handlers::list_determinations

dual

GET

/v1/determinations/{id}

handlers::get_determination

dual

POST

/v1/determinations/{id}/redact

handlers::post_redact_determination

user-only (mechanical, #1425)

GET

/v1/determinations/{id}/explanation

work_requirement_handlers::get_determination_explanation

dual

POST

/v1/work-requirements/evaluate

work_requirement_handlers::evaluate_work_requirements

dual

GET

/v1/work-requirements/{person_id}

work_requirement_handlers::get_work_requirements

dual

POST

/v1/work-requirements:batchGet

work_requirement_handlers::batch_get_work_requirements

service-only

POST

/v1/work-requirements/{person_id}/activities

work_requirement_handlers::log_activity

dual

GET

/v1/work-requirements/{person_id}/activities

work_requirement_handlers::list_activities

dual

GET

/v1/work-requirements/{person_id}/activities/summary

work_requirement_handlers::activities_summary

dual

POST

/v1/work-requirements/activities/summary:batchGet

work_requirement_handlers::batch_activities_summary

service-only

GET

/v1/time-limits/{person_id}

work_requirement_handlers::get_time_limits

dual

POST

/v1/time-limits:batchGet

work_requirement_handlers::batch_get_time_limits

service-only

POST

/v1/grg/payments

grg_handlers::create_grg_payment

dual

GET

/v1/grg/payments/{person_id}

grg_handlers::list_grg_payments

dual

GET

/v1/personal-responsibilities/{application_id}

personal_responsibility_handlers::list_personal_responsibilities

dual

POST

/v1/personal-responsibilities/{application_id}

personal_responsibility_handlers::create_personal_responsibility

dual

PUT

/v1/personal-responsibilities/status/{id}

personal_responsibility_handlers::update_personal_responsibility

dual

GET

/v1/fti-audit-log

fti_audit_handlers::list_fti_audit

user-only (mechanical, #1425)

GET

/v1/fti-audit-log/summary

fti_audit_handlers::fti_audit_summary

user-only (mechanical, #1425)

GET

/v1/fti-audit-log/{id}

fti_audit_handlers::get_fti_audit_entry

user-only (mechanical, #1425)

POST

/v1/overpayments

overpayments_handler::post_claim

dual

GET

/v1/overpayments

overpayments_handler::list_claims

dual

GET

/v1/overpayments/{id}

overpayments_handler::get_claim

dual

POST

/v1/overpayments/{id}/repayment-plans

overpayments_handler::post_repayment_plan

dual

POST

/v1/overpayments/{id}/recoupments

overpayments_handler::post_recoupment

dual

GET

/v1/overpayments/{id}/ledger

overpayments_handler::get_ledger

dual

POST

/v1/verification/discrepancies/{id}/resolve

discrepancy_handlers::resolve_discrepancy

service-or-exchanged (#1425)

GET

/v1/tanf/sanctions/rollup

work_requirement_handlers::get_sanctions_rollup

dual

POST

/v1/admin/events/replay

canopy_api::admin::admin_replay_handler (shared crate, merged at main.rs:198-204)

service-only

GET

/livez

canopy_api::livez_check (shared ApiServer::router)

public

GET

/readyz

canopy_api::readyz_check (shared ApiServer::router)

public

GET

/swagger-ui + /api-doc/openapi.json

SwaggerUi (canopy-api lib.rs:233)

public

Flags for the migration slices
  • The ONLY no-actor-rejects on the tanf surface is in the SHARED crate (crates/canopy-api/src/admin.rs:130, claims.actor().ok_or(Forbidden) on POST /v1/admin/events/replay) — a slice that edits only services/canopy-tanf/src will miss it; conversely every other service mounting AdminRoutes shares this exact branch, so changing it is a fleet-wide change.

  • String-param actors throughout the attribution plumbing: determine::determine takes accessed_by: &str (determine.rs:313 → FtiAuditEntry.accessed_by at :903); store/fti.rs read_fti_tax_data/store_fti_tax_data take accessed_by: &str (dead_code until #810 FTI income wiring goes live — the migration must retype BEFORE #810 activates them); events::publish_determination_redacted takes Option<&str> (fed claims.sub at handlers.rs:370).

  • 'system' sentinel: discrepancy_handlers.rs:84 persists the literal string 'system' into tanf_discrepancies.resolved_by (TEXT) when a service bearer has no actor — any later attribution migration must preserve/translate this sentinel in existing rows and decide whether a no-actor resolve should remain legal at all (canopy-web always mints an actor per #961).

  • NO ownership gates anywhere in canopy-tanf: neither handlers nor the store layer scope person_id/application_id/household_id path params to the caller — any caseworker-or-above bearer (or any no-actor service bearer via the is_service() short-circuit) can read or write ANY person’s TANF data. The store layer (store/mod.rs, store/overpayments.rs, store/batch.rs) contains zero Claims/role/ownership checks — authorization is 100% handler-level.

  • require_service_or_caseworker_or_above is documented in canopy-auth (claims.rs:261-265) as a TRANSITIONAL ADR-019-cutover guard; 21 of tanf’s 31 gates still sit on it — the F1a migration must assign each a final posture (several guard writes with no attribution: log_activity, GRG payment creation, personal-responsibility status, overpayment recoupments).

  • Read-that-writes under a dual gate: GET /v1/work-requirements/{person_id} (work_requirement_handlers.rs:56) and GET /v1/time-limits/{person_id} (:969) are get-or-CREATE — row creation reachable by an unattributed no-actor service bearer; the :batchGet siblings were added specifically as read-only (#1203) and are service-only.

  • GET /v1/determinations changes privilege tier BY QUERY PARAM (handlers.rs:226-230): month set → service-only (#1249), unscoped → dual. Route-level classifiers/middleware that assume one tier per path will misclassify this route.

  • Stale doc comment: services/canopy-tanf/src/api/overpayments_handler.rs:3 says 'SNAP overpayment claim' — this is the TANF copy (copy-paste residue).

  • Middleware posture (crates/canopy-auth/src/middleware.rs:109-147, wired fail-loud in tanf main.rs:175-194 per #961): X-Canopy-Actor on a non-service bearer → 401; invalid actor JWT → 401; header without verifier → 401; but an ABSENT actor always passes middleware — actor presence is never enforced centrally, only per-handler (and in tanf src, no handler enforces it; only shared admin.rs:130 does).

  • fti_auditor and data_steward are dedicated roles that admin does NOT auto-hold (claims.rs:198-216, separation of duties) — the 4 gates using them are pure human-role gates; by convention no service token carries them.

  • Inconsistent route registration: FTI-audit-log and overpayments routes register with literal path strings (api/mod.rs:183-214) while everything else uses canopy-contracts-tanf path constants — a path-constant-driven migration sweep will miss those 8 method-routes.

  • OpenAPI surface pin: api/mod.rs:244-252 asserts exactly 27 paths — any slice adding/removing tanf routes must bump the pin deliberately.

  • Devstack-only unauthenticated test-clock routes mount at root when the test-clock feature is on (canopy-api lib.rs:218-225); compile-stripped from release builds — not a production surface.

  • No api-key surfaces in canopy-tanf (bearer JWT only). Event-subscriber paths (main.rs overpayments inbox) run with no Claims at all — trust derives from broker topology, and provenance is stamped from event payload fields, not verified claims.

  • canopy-tanf is NOT one of the 8 applicant-portal targets; grep-confirmed canopy-portal/src never calls tanf HTTP routes (its 'tanf' hits at home.rs:399,720,758 are program-name display strings and test fixtures).

Post-slice state — S-tanf (#1425, ADR-043 §C receiver contract)

canopy-tanf is the first receiver on canopy_auth::ReceiverContract. Deltas against the plan-time survey above (the survey text is retained as the migration baseline; where they conflict, this block is current):

  • POST /v1/determine (handlers.rs:64) and the discrepancy resolve (discrepancy_handlers.rs:87) moved require_service_callerrequire_service_or_exchanged — an exchanged user-context token (exact aud=canopy-tanf, allowlisted azp, caseworker-or-above role) now passes alongside service callers.

  • The 4 pure-role gates became MECHANICAL user-only routes: require_user_only(["fti_auditor"]) (fti_audit_handlers.rs:62/104/136) and require_user_only(["data_steward"]) (handlers.rs:347). Service-class bearers are 403 service_class_on_user_only unconditionally; with CANOPY_TANF__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a legacy broad-audience worker bearer is 403 aud_not_exact — the by-convention "no service token carries these roles" flag above is now enforced in code.

  • Every other route in the service’s API router sits behind the exchanged_gate middleware (main.rs:220 — layered on api::routes() BEFORE the admin merge, so the shared /v1/admin/events/replay route and the public livez/readyz are outside it; outcome-equivalent for admin, whose require_service_caller rejects exchanged bearers anyway): an exchanged-shaped bearer failing exact-audience/azp checks is 403 uniformly, so the 21 transitional require_service_or_caseworker_or_above dual gates accept well-formed exchanged tokens with no handler changes (their final-posture assignment remains open as flagged above).

  • Attribution: determine’s FTI accessed_by (handlers.rs:84), redaction’s event actor (handlers.rs:385), and the discrepancy resolved_by (discrepancy_handlers.rs:93) now resolve via EffectiveUser — an exchanged bearer attributes its own sub; the 'system' sentinel for unattributed service resolves is preserved. The claims.actor() line-cites in the flags above predate this.

  • Senders: canopy-web’s two tanf BFF write actions and the orchestrator’s tanf dispatch exchange-when-configured, fail-not-fallback (ADR-043 no-silent-downgrade). Conformance: the F4 matrix activates canopy-tanf (manifest rows cite the guard lines; exchange rows live).

canopy-enrollment

Plan-time index: enrollment 18 — read-verified require_* sites: 17. 17 require_service_caller call sites read-verified in services/canopy-enrollment/src (api/mod.rs:338,396,420,449,681,715,801,851,913; api/adverse_actions.rs:81,283,335,373,423,479; api/enact_sweep.rs:32; api/reopen.rs:75). No other require_* auth guards exist in src/ (require_role/require_caseworker/etc. are unused here). The 18th plan-time count is most plausibly the shared admin replay handler’s require_service_caller (crates/canopy-api/src/admin.rs:129), which IS mounted into this service’s router via AdminRoutes at main.rs:304-310 — counting it gives exactly 18 require_service_caller sites reachable through canopy-enrollment’s router. (main.rs:85 'require_issuance_days' matches a require_* grep but is a jurisdiction-config loader closure, not an auth gate.)

Method Path Handler Class Portal target

POST

/v1/enrollments

api::create_enrollment

service-only

GET

/v1/enrollments

api::list_enrollments

service-only

GET

/v1/enrollments/{id}

api::get_enrollment

service-only

POST

/v1/enrollments/{id}/issue

api::issue_benefits

service-only

GET

/v1/enrollments/{id}/issuances

api::list_issuances

service-only

GET

/v1/households/{household_id}/issuances

api::list_issuances_for_household

service-only

POST

/v1/households/issuances:batchGet

api::batch_get_household_issuances

service-only

GET

/v1/households/{household_id}/annual-summary

api::get_household_annual_summary

service-only

yes

POST

/v1/enrollments/{id}/terminate

api::terminate_enrollment (410 Gone tombstone)

service-only

POST

/v1/adverse-actions

api::adverse_actions::schedule_adverse_action

service-only

GET

/v1/adverse-actions

api::adverse_actions::list_adverse_actions

service-only

GET

/v1/adverse-actions/{id}

api::adverse_actions::get_adverse_action

service-only

POST

/v1/adverse-actions/{id}/cancel

api::adverse_actions::cancel_adverse_action

service-only

PUT

/v1/adverse-actions/{id}/stays/{appeal_id}

api::adverse_actions::stay_adverse_action

service-only

GET

/v1/adverse-actions/{id}/stays/{appeal_id}

api::adverse_actions::get_appeal_stay

service-only

POST

/v1/adverse-actions/enact-sweep

api::enact_sweep::trigger_enact_sweep

service-only

POST

/v1/adverse-actions/{id}/reopen

api::reopen::reopen_adverse_action

service-only

POST

/v1/admin/events/replay

canopy_api::admin::admin_replay_handler (shared, merged at main.rs:304-310)

service-only

GET

/livez

canopy_api::livez_check (shared ApiServer::router)

public

GET

/readyz

canopy_api::readyz_check (shared ApiServer::router)

public

Flags for the migration slices
  • TAXONOMY DRIFT on the exemplar: api/mod.rs:123 (gate_household_actor_access) is cited in the plan as the no-actor-passes-with-audit exemplar, but the no-actor arm returns Ok(()) with NO audit recording the absence — audit_household_read (mod.rs:176-204) fires only when an actor IS present, and its doc-comment (mod.rs:172-175) states actor-less reads 'are not audited here'. If the program intends pattern 2 (audit the absence), that audit must be ADDED in the migration slice, not assumed present.

  • STRING-PARAM ACTORS on the wire: schedule/cancel/stay/reopen request bodies all carry a free-text 'actor' field. schedule_adverse_action (api/adverse_actions.rs:151, plus exemption.authority/actor at 142-145) persists it VERBATIM with no authenticated-principal binding; cancel (374), stay (433), and reopen (reopen.rs:78) prepend claims.sub. Inconsistent attribution pattern within one file — a migration slice replacing string actors with X-Canopy-Actor must touch the schedule path’s row-persisted actor, not just the sub-prefixed commands.

  • Attribution records the SERVICE principal, not the human: the sub+actor format strings use claims.sub of the service bearer and never consult claims.actor() — after OIDC actor propagation these sites should move to the actor().map_or(claims.sub, \|a\| a.sub) shape or they will permanently attribute BFF-originated commands to the BFF service account.

  • Whole service is service-class-only (post-#439; doc comment api/adverse_actions.rs:7-8: 'workers arrive via the canopy-web BFF') — there are no user-only or dual routes; every worker/human authorization decision in enrollment rides the X-Canopy-Actor header behind a service bearer.

  • batch_get_household_issuances (api/mod.rs:801) is DELIBERATELY outside the #408 gate/audit with a test pinning zero #408 events (doc mod.rs:769-779) — a migration adding actor requirements to household reads must preserve this actor-less reporting-pipeline path or split it.

  • The only no-actor-rejects surface reachable through this router is SHARED-CRATE code: canopy-api admin.rs:130 (POST /v1/admin/events/replay, service bearer + admin actor). Migrating its pattern changes every service that mounts AdminRoutes, not just enrollment.

  • Actor sub must parse as a UUID: gate_household_actor_access (api/mod.rs:129-135) 403s fail-closed on a non-UUID actor.sub, and audit_household_read (mod.rs:183) silently skips audit for the same shape — Keycloak-style UUID subs assumed; any future non-UUID subject format breaks worker access to household reads.

  • All #408 audit emissions are best-effort warn-and-continue (mod.rs:151-168 deny path, 185-202 allow path) — audit failure never blocks or fails the request; a slice that upgrades audit to mandatory changes error semantics.

  • No store-level or domain-level authz exists: store.rs and adverse_actions.rs (domain) contain zero claims/role checks — every guard lives in the API layer; the actor strings persisted by the domain are attribution data passed down from handlers. DB constraints (one-live-per-household, one-open-action, lifecycle_revision fences) are integrity, not authorization.

  • No service-local middleware: authn is entirely the shared canopy_api::ApiServer::router auth layer (canopy_auth::middleware::auth_middleware) nesting all routes under /v1; /livez and /readyz are the only public routes. Event-driven writes (auto-enroll, notice evidence, appeal resolutions, periodic-report tombstones — main.rs:374-480) bypass HTTP authz entirely and trust the broker.

  • terminate_enrollment (api/mod.rs:913) is a 410 Gone tombstone that still carries require_service_caller — keep the gate ordering (403 before 410) when migrating.

  • Portal usage confirmed by grep+read of services/canopy-portal/src: exactly ONE enrollment route is called — GET /v1/households/{household_id}/annual-summary (home.rs:266-268, actor-less service-token fetch for the 'Your year' recap). No other enrollment path appears in portal source.

Post-slice state — S-enrollment (#1435, ADR-043 §C receiver contract)

Eleventh receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target with ZERO user-only routes (the enforce flag is inert, set for fleet consistency). Deltas against the survey above:

  • THE #408 HOUSEHOLD GATE MIGRATED TO EffectiveUser: the survey’s actor arms were UNREACHABLE (enrollment has no actor verifier, so claims.actor() was always None and every read passed the no-actor arm; the middleware 401s any presented actor header). An EXCHANGED worker bearer now carries the human identity in the token itself, so the assignment check + both #408 audit arms are live for worker traffic for the first time — live-verified: an exchanged caseworker without an assignment is denied (the deny path also publishes the #408 audit event), a supervisor bypasses by design, and service/system traffic (the portal BFF) keeps the pass-through arm. audit_household_read likewise fires on any human-carrying bearer.

  • THE TWO #408-GATED HOUSEHOLD READS (issuances mod.rs:729, annual summary :868) widened require_service_callerrequire_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES) — the widening is what makes the gate reachable for worker bearers at all. The batchGet aggregate stays service-only (documented SSR fan-out).

  • THE TWO WEB-DRIVEN ADVERSE-ACTION WRITES (schedule adverse_actions.rs:86, cancel :381) moved require_service_callerrequire_service_or_exchanged with their BFF senders flipped onto exchanged bearers (#1560 dispatch; fail-on-denied). The sub=; actor= attribution string on cancel now carries the WORKER’s own sub under an exchanged bearer; schedule’s verbatim req.actor inconsistency stays flagged. Stay/reopen (appeals nested hops), enact-sweep, and the enrollments CRUD stay service-only (FU-B / ADR-023 D4).

  • Azp allowlist: canopy-web-exchanger ONLY.

  • Conformance: the F4 matrix activates canopy-enrollment (6 rows: 4 service-only + the 2 widened household reads, whose exchanged probe rides jane.doe’s supervisor bypass). The mutating adverse-action writes and the gate semantics are pinned by receiver_contract_test.rs (direct worker 403 on both writes / exchanged 404-past-auth on both / the #408 assignment-deny + supervisor-bypass + service-pass ladder).

canopy-medicaid

Plan-time index: 4+14 — read-verified require_* sites: 24. Found 24 require_* call sites in services/canopy-medicaid/src (read-verified). The plan’s 4 matches exactly the 4 require_service_caller sites (handlers.rs:69, handlers.rs:292, cmd_handlers.rs:81, cmd_handlers.rs:118). The plan’s 14 matches the non-service-caller gates in handlers.rs (11: 8x require_service_or_caseworker_or_above at 161/294/364/400/433/468/496/661, require_data_steward at 201, 2x require_admin_or_quality_control at 545/720) plus fti_audit_handlers.rs (3x require_fti_auditor at 57/94/121). The +6 delta is overpayments_handler.rs (6x require_service_or_caseworker_or_above at 89/127/152/181/209/234), which the plan-time index evidently missed. One additional require_service_caller guards a medicaid-registered route from the shared crate (crates/canopy-api/src/admin.rs:129, /v1/admin/events/replay) — outside the service-src count. main.rs:1021 canopy_crypto_shred::require_kek is a boot-time key loader, not a Claims guard; excluded.

Method Path Handler Class Portal target

POST

/v1/determine

handlers::post_determine

service-or-exchanged (#1426)

GET

/v1/determinations

handlers::list_determinations

dual

GET

/v1/determinations/{id}

handlers::get_determination

dual

POST

/v1/determinations/{id}/redact

handlers::post_redact_determination

user-only (mechanical, #1426)

GET

/v1/applications/{id}/categories

handlers::get_eligible_categories

dual

GET

/v1/determinations/{id}/explanation

handlers::get_explanation

dual

GET

/v1/tma

handlers::list_tma_coverage

dual

GET

/v1/ele/chain-status

handlers::ele_chain_status

dual

GET

/v1/ele/{person_id}

handlers::get_ele_status

dual

POST

/v1/ele/{person_id}/revoke

handlers::revoke_ele

user-only (mechanical, #1426)

GET

/v1/ele/household/{household_id}

handlers::get_ele_household_summary

dual

POST

/v1/ele/renewals/run

handlers::run_ele_renewals

user-only (mechanical, #1426)

GET

/v1/fti-audit-log

fti_audit_handlers::list_fti_audit

user-only (mechanical, #1426)

GET

/v1/fti-audit-log/summary

fti_audit_handlers::fti_audit_summary

user-only (mechanical, #1426)

GET

/v1/fti-audit-log/{id}

fti_audit_handlers::get_fti_audit_entry

user-only (mechanical, #1426)

POST

/v1/overpayments

overpayments_handler::post_claim

dual

GET

/v1/overpayments

overpayments_handler::list_claims

dual

GET

/v1/overpayments/{id}

overpayments_handler::get_claim

dual

POST

/v1/overpayments/{id}/repayment-plans

overpayments_handler::post_repayment_plan

dual

POST

/v1/overpayments/{id}/recoupments

overpayments_handler::post_recoupment

dual

GET

/v1/overpayments/{id}/ledger

overpayments_handler::get_ledger

dual

POST

/v1/cmd/ingest

cmd_handlers::ingest_cmd_update

service-only

POST

/v1/determinations/{id}/requeue

cmd_handlers::requeue_determination

service-only

POST

/v1/admin/events/replay

canopy_api::admin::admin_replay_handler (shared, merged at main.rs:1090)

service-only

GET

/livez

canopy_api livez_check (ApiServer::router)

public

GET

/readyz

canopy_api readyz_check (ApiServer::router)

public

GET

/swagger-ui + /api-doc/openapi.json

SwaggerUi (ApiServer::router)

public

Flags for the migration slices
  • NO ACTOR VERIFIER WIRED: canopy-medicaid uses the bootstrap default AuthLayer::new(jwks) (crates/canopy-api/src/bootstrap.rs:180) and never calls with_actor_verifier (only canopy-applications and canopy-tanf do, per repo-wide grep). Consequences: (a) Claims::actor() is ALWAYS None in this service, so the handlers.rs:84-87 FTI accessed_by attribution always records the calling service’s sub, never the on-behalf-of worker; (b) any request carrying X-Canopy-Actor is 401-rejected fail-closed (crates/canopy-auth/src/middleware.rs:139-145); (c) POST /v1/admin/events/replay (merged at main.rs:1084-1090) requires a verified admin actor (admin.rs:130-131) and is therefore permanently 403/unusable in medicaid. Any migration slice moving medicaid to actor-attributed calls MUST wire an ActorVerifier first or every actor-carrying caller breaks with 401.

  • STRING-PARAM ACTORS: cmd_handlers.rs:94 persists caller-supplied req.submitted_by into medicaid_cmd_events.submitted_by, and cmd_handlers.rs:149 logs req.resolved_by, both from the request body with no cross-check against Claims — attribution rides on caller honesty on two service-only routes fronted by canopy-web.

  • INCONSISTENT ATTRIBUTION PATTERNS across the service: post_determine uses actor-else-sub (handlers.rs:84-87), redact uses bare claims.sub (handlers.rs:236), ELE revoke parses claims.sub as UUID and silently (warn-logged) drops attribution for non-UUID subs (handlers.rs:573-582), cmd routes use body strings. A migration slice should converge these on one resolver.

  • SYSTEM EVENTS HAVE NO ACTOR SENTINEL: ELE chain events from schedulers/subscribers write actor_id=None (scheduler.rs:305, scheduler.rs:425, main.rs:182, main.rs:795); a manually triggered sweep (POST /v1/ele/renewals/run, admin/QC-gated) also produces actor_id=None rows, losing the triggering admin’s identity.

  • DORMANT FTI READ SURFACE: store/fti.rs read_fti_tax_data (line 34) and read_fti_for_magi (line 78) are #[expect(dead_code)] pending #785; when wired, their accessed_by parameter must be threaded from a real resolved identity — flag for whichever slice lands #785.

  • STALE COMMENT: overpayments_handler.rs:3-4 doc header says 'SNAP overpayment claim' endpoints but this is the medicaid service (copy-paste from the shared canopy-overpayments extraction).

  • SPLIT GATE ON A QUERY PARAM: GET /v1/determinations is dual-classed but its month param arm is service-only (#1249, handlers.rs:291-295) — route-level classification alone under-describes it; keep the arm split in any migration.

  • NO OWNERSHIP GATES ANYWHERE: no household/person-ownership checks exist in this service (worker/service surface only; medicaid is not a portal target and canopy-portal/src contains no medicaid API calls — only display labels in home.rs). Every id/param is trusted after the role gate.

  • LITERAL ROUTE STRINGS: /fti-audit-log*, /overpayments*, and /ele/renewals/run are registered as literal strings in api/mod.rs:129-160 (not contracts-crate constants) — path-constant-driven migration tooling will miss them.

  • IDEMPOTENCY PRINCIPAL COLLAPSE: the shared idempotency middleware keys by actor-else-sub (crates/canopy-api/src/idempotency.rs:678); with actor always None in medicaid, all workers behind one BFF service token share one idempotency principal for guarded POSTs.

Post-slice state — S-medicaid (#1426, ADR-043 §C receiver contract)

Second receiver on canopy_auth::ReceiverContract, following the tanf template. Deltas against the plan-time survey above (retained as the migration baseline; where they conflict, this block is current):

  • POST /v1/determine (handlers.rs:72) moved require_service_callerrequire_service_or_exchanged — an exchanged user-context token (exact aud=canopy-medicaid, allowlisted azp, caseworker-or-above role) now passes alongside service callers (the hop-2 prepare; the orchestrator’s EXCHANGE_TARGETS includes canopy-medicaid in the devstack).

  • The 6 pure-role gates became MECHANICAL user-only routes: require_user_only(["fti_auditor"]) (fti_audit_handlers.rs:62/101/130), require_user_only(["data_steward"]) (handlers.rs:211), and require_user_only(["admin", "quality_control"]) on the two ELE ops routes (handlers.rs:564 revoke, :747 renewals/run — pure human-role gates with no service or BFF caller). Service class is 403 service_class_on_user_only unconditionally; with CANOPY_MEDICAID__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a legacy broad-audience worker bearer is 403 aud_not_exact. No quality_control user exists in the devstack realm — the positive arm is exercised via admin (jane.admin).

  • Every other route in the API router sits behind the exchanged_gate middleware (main.rs — layered on api::routes() BEFORE the admin merge); dual routes accept well-formed exchanged tokens with no handler changes.

  • Attribution converged on EffectiveUser at the three inconsistent handler sites flagged above: determine’s FTI accessed_by, the redaction event actor, and the ELE revoke actor_id (an exchanged bearer attributes its own preserved sub). This CLOSES the "accessed_by always records the service sub" consequence of the no-actor-verifier flag for exchanged traffic — a per-target user-context bearer needs no actor header at all (one is 401-rejected on non-service bearers). The cmd body-string sites and the renewals-sweep actor_id=None event rows are unchanged.

  • CHIP: the orchestrator’s hop-2 audience derivation would mint aud=canopy-chip, which this service’s single-audience gate 403s — CHIP dispatch deliberately stays on the ADR-019 service token (EXCHANGE_TARGETS lists only canopy-medicaid).

  • Conformance: the F4 matrix activates canopy-medicaid (11 manifest rows citing the guard lines; POST /v1/determine and POST /v1/ele/renewals/run excluded as unsafe-to-probe, covered by medicaid_test / ele_lapse_e2e_test).

canopy-eligibility+canopy-reporting

Plan-time index: eligibility 1+8+1 · reporting 0+1 — read-verified require_* sites: 43. Read-verified handler-level authorization call sites: eligibility 19 (8x require_service_or_caseworker_or_above in handlers.rs:120,195,234,258,281,385,443,499; 1x require_supervisor_or_above probe inside enforce_worker_id_identity at handlers.rs:318; 4x require_admin at bulk_runs.rs:65,172,364,415; 2x require_supervisor_or_above at bulk_runs.rs:271,318; 4x require_reader at bulk_runs_read.rs:42,80,122,163) + reporting 24 (21x require_supervisor_or_above in api/mod.rs:207,369,396,420,453,567,593,665,697,721,734,768,813,870,937,971,995,1023,1076,1134,1205; 1x require_service_or_caseworker_or_above at api/mod.rs:1273; 2x authorize_runs_read at api/runs.rs:79,114). Two helper-internal delegations (bulk_runs_support.rs:78, runs.rs:43) are not double-counted. Delta vs plan: the plan-time figures index only the ADR-019 dual-class constructs — eligibility '1+8+1' maps cleanly to 1 service_id() exact-identity gate (handlers.rs:140) + 8 require_service_or_caseworker_or_above + 1 is_service-OR helper (require_reader); reporting '0+1' to 0 require_service_caller + 1 require_service_or_caseworker_or_above. The plan count excludes the pure human-role gates (require_admin / require_supervisor_or_above / require_reader call sites / authorize_runs_read), which dominate numerically (6 eligibility bulk mutations + 22 reporting sites + 4 bulk-read sites + 2 runs-read sites + the enforce_worker_id_identity probe). Every plan-time-indexed site was found; the delta is purely additive — no plan-indexed site is missing. Non-authz require_* helpers (require_enabled H18 feature gate at bulk_runs_support.rs:39, require_published_inputs data-guard at reporting api/mod.rs:309) were excluded as not authorization.

Method Path Handler Class Portal target

POST

/v1/eligibility/determine

api::handlers::post_determine

dual

POST

/v1/eligibility/determine/dry-run

api::handlers::post_determine_dry_run

dual

GET

/v1/eligibility/requests/{id}

api::handlers::get_request

dual

GET

/v1/eligibility/requests/{id}/determinations

api::handlers::get_request_determinations

dual

GET

/v1/eligibility/results/{application_id}

api::handlers::get_result

dual

GET

/v1/eligibility/workers/{worker_id}/cross-program-alerts

api::handlers::scoped_cross_program_alerts

dual

assignment-scoped (#596)

GET

/v1/eligibility/cross-program-alerts/all

api::handlers::list_cross_program_alerts_all

dual

supervisor-or-canopy-web only (#596)

GET

/v1/eligibility/case-status

api::handlers::get_case_status

dual

GET

/v1/eligibility/determinations

api::handlers::list_determinations_by_household

dual

yes

POST

/v1/eligibility/bulk-runs

api::bulk_runs::create_bulk_run

user-only

GET

/v1/eligibility/bulk-runs

api::bulk_runs_read::list_bulk_runs

dual

GET

/v1/eligibility/bulk-runs/{id}

api::bulk_runs_read::get_bulk_run

dual

GET

/v1/eligibility/bulk-runs/{id}/failures

api::bulk_runs_read::list_bulk_run_failures

dual

GET

/v1/eligibility/bulk-runs/{id}/actions

api::bulk_runs_read::list_bulk_run_actions

dual

POST

/v1/eligibility/bulk-runs/{id}/enact

api::bulk_runs::enact_bulk_run

user-only

POST

/v1/eligibility/bulk-runs/{id}/pause

api::bulk_runs::pause_bulk_run

user-only

POST

/v1/eligibility/bulk-runs/{id}/resume

api::bulk_runs::resume_bulk_run

user-only

POST

/v1/eligibility/bulk-runs/{id}/cancel

api::bulk_runs::cancel_bulk_run

user-only

POST

/v1/eligibility/bulk-runs/{id}/retry-failures

api::bulk_runs::retry_bulk_run_failures

user-only

POST

/v1/reporting/snap/fns-388

api::generate_fns_388

user-only

GET

/v1/reporting/snap/fns-388

api::list_reports

user-only

GET

/v1/reporting/snap/fns-388/{month}

api::get_report

user-only

POST

/v1/reporting/snap/qc-universe

api::generate_qc_snapshot

user-only

GET

/v1/reporting/snap/qc-universe/{date}

api::get_qc_universe

user-only

GET

/v1/reporting/snap/qc-universe/{date}/csv

api::export_qc_csv

user-only

POST

/v1/reporting/tanf/acf-199

api::generate_tanf_acf199

user-only

GET

/v1/reporting/tanf/acf-199

api::list_tanf_acf199

user-only

GET

/v1/reporting/tanf/acf-199/csv

api::export_tanf_acf199_csv

user-only

POST

/v1/reporting/tanf/acf-196

api::generate_tanf_acf196

user-only

GET

/v1/reporting/tanf/acf-196

api::list_tanf_acf196

user-only

POST

/v1/reporting/tanf/wpr

api::generate_tanf_wpr

user-only

GET

/v1/reporting/tanf/wpr

api::list_tanf_wpr

user-only

POST

/v1/reporting/medicaid/tmsis

api::generate_medicaid_tmsis

user-only

GET

/v1/reporting/medicaid/tmsis

api::list_medicaid_tmsis

user-only

GET

/v1/reporting/medicaid/tmsis/csv

api::export_medicaid_tmsis_csv

user-only

POST

/v1/reporting/medicaid/cms-64

api::generate_medicaid_cms64

user-only

GET

/v1/reporting/medicaid/cms-64

api::list_medicaid_cms64

user-only

POST

/v1/reporting/medicaid/cms-416

api::generate_medicaid_cms416

user-only

GET

/v1/reporting/medicaid/cms-416

api::list_medicaid_cms416

user-only

GET

/v1/reporting/overpayments

api::export_overpayments_csv

user-only

GET

/v1/reporting/overpayments/summary

api::get_overpayments_summary

dual

GET

/v1/reporting/runs

api::runs::list_runs

dual

GET

/v1/reporting/runs/{id}

api::runs::get_run_status

dual

Flags for the migration slices
  • NO ACTOR VERIFIER IN EITHER SERVICE: both use the default AuthLayer::new(jwks) from canopy_api::bootstrap (crates/canopy-api/src/bootstrap.rs:180); only canopy-applications and canopy-tanf call with_actor_verifier. So Claims::actor is ALWAYS None here, every is_service() pass is a true no-actor pass, and — per crates/canopy-auth/src/middleware.rs:129-144 — a service bearer that PRESENTS X-Canopy-Actor gets 401 'actor verifier not configured'. Any migration slice that starts sending actor headers into eligibility/reporting must wire verifiers in the same slice or every call 401s.

  • STRING-PARAM ACTOR (eligibility): DetermineRequest.requested_by is a caller-supplied request-body string ('for audit attribution', crates/canopy-contracts-eligibility/src/determine.rs:34) persisted verbatim to eligibility_requests.requested_by (services/canopy-eligibility/src/store/mod.rs:80 via orchestrator.rs:1296). Attribution is self-declared by the caller — a migration must derive it server-side from actor()/sub or validate it against claims.

  • PORTAL COUPLING: canopy-portal’s only eligibility call is GET /v1/eligibility/determinations?household_id= (services/canopy-portal/src/home.rs:247) with a bare bearer token (home.rs:561 bearer_auth, no actor header). It passes on the no-actor service arm of require_service_or_caseworker_or_above with zero server-side household-ownership check — moving that route to actor-required or ownership-gated breaks the portal home page unless the portal slice lands first. canopy-reporting is NOT a portal target (no reporting calls in services/canopy-portal/src).

  • SERVICE-OPEN UNSCOPED READS (eligibility): requests/{id}, results/{application_id}, case-status, determinations, are readable by ANY service bearer with no actor and no tenancy scoping. The cross-program-alerts feeds are the exception since #596: the worker path is assignment-scoped with a canopy-web-only service allowlist, and /all is supervisor-or-canopy-web only.

  • API-KEY SURFACE (outbound, eligibility): CANOPY_INTERNAL_API_KEY loaded at services/canopy-eligibility/src/main.rs:84-86 and sent as x-service-api-key to canopy-verification’s internal SOLQ endpoint (#384, handlers.rs:26-29). A non-OIDC bearer path a later slice must retire; the inbound gate lives in canopy-verification.

  • AZP-FALLBACK SUBTLETY: the D-5 exact-identity gate (handlers.rs:140) relies on Claims::service_id(), which falls back to the azp claim when no service: role exists (crates/canopy-auth/src/claims.rs:241-247). The gate sits behind require_service_or_caseworker_or_above, so a HUMAN caseworker token whose azp is 'canopy-eligibility' would satisfy service_id()==Some("canopy-eligibility") without being a service — pattern to normalize in migration.

  • BULK SELF-CALLS carry with_service_identity(jwt) only, never an actor header (services/canopy-eligibility/src/bulk/consumer.rs:174-182 preview dry-run, 291-297 enact) — the no-actor service caller the D-5 gate exists to admit; keep exempt from any actor-required policy.

  • AUDIT LEDGER IS BEST-EFFORT: bulk-run H22 action rows append AFTER the committed transition; a failed append is only tracing::error (bulk_runs_support.rs:444-472) — attribution rows can be silently missing from /actions.

  • basis_of() (bulk_runs_support.rs:433-438) knows only role:admin\|role:supervisor — if a slice ever opens bulk mutations to service callers the ledger mislabels them role:supervisor.

  • REPORTING DB-ROLE SURFACE (authz outside HTTP claims): boot-time least-privilege guard services/canopy-reporting/src/guard.rs:108 (called main.rs:87) — env==development auto-allows (guard.rs:62-64), allow_broad_db_role=true (config.rs:75, default false) proceeds with a loud WARN naming the cutover runbook, otherwise refuses boot. Grant matrix in migrations/20261111000000_least_privilege_roles.sql (REVOKE ALL FROM PUBLIC + enumerated grants to NOLOGIN canopy_reporting_app; owner role split); janitor reap is SECURITY DEFINER, EXECUTE granted only to canopy_reporting_app (20261111000001_janitor_security_definer.sql:99-103). Plus crypto-side: T-MSIS sealed payloads AAD-bind natural keys so a DB actor with UPDATE cannot re-attribute rows (store/restricted.rs:264-286).

  • REPORTING RUNS ARE ORG-VISIBLE BY DESIGN (api/runs.rs:14-17): deliberate deviation from #1205 requester-scoping so the 409 conflict handle stays pollable — do not 'fix' with requester scoping in a migration slice.

  • ASYMMETRY TO PRESERVE OR RESOLVE: reporting report READS (incl. decrypted T-MSIS PHI) are supervisor-only and service-CLOSED, while run-status reads and the overpayments summary are service-open; eligibility bulk-run reads (incl. failures pages with household/application ids) are open to ANY service identity via require_reader — the migration should decide per-surface which service identities belong.

  • Route paths: eligibility api::routes() registers 18 paths/19 method-handlers (count refreshed at #1430; the survey’s 17/18 predated the #596 feed split), reporting 17 paths/24 method-handlers, all nested under /v1 behind auth_middleware by ApiServer::router (crates/canopy-api/src/lib.rs:190-214); the only unauthenticated routes are canopy-api’s own /livez and /readyz — neither service registers any public or portal-only route itself.

  • Stale-comment risk: orchestrator.rs:1513-1517 asserts program services' /v1/determine checks require_service_caller 'post-MR-3 cutover' — verify against the program services when their slice runs; eligibility itself contains zero require_service_caller sites. RESOLVED in #1430: the comment now names the require_service_or_exchanged posture on flipped slices.

Post-slice state — S-eligibility (#1430, ADR-043 §C receiver contract)

Sixth receiver on canopy_auth::ReceiverContract, and the fleet’s ONE hop-2 receiver. Deltas against the survey above (retained as the migration baseline; where they conflict, this block is current):

  • The contract is built with_hop2_exchanger("canopy-eligibility-exchanger"): the user-context arm accepts exact aud=canopy-eligibility OR exactly the {canopy-eligibility, canopy-eligibility-exchanger} pair — the delegable hop-1 shape canopy-web mints for the two determine senders (approve / run-determination), which the orchestrator re-exchanges for its EXCHANGE_TARGETS fan-out (hop-2, live end-to-end as of this slice). Any other multi-audience shape stays 403 aud_not_exact; a pair token replayed at any single-exact service is 403 there.

  • The 6 bulk-run mutations became MECHANICAL user-only routes: require_user_only(["admin"]) (create bulk_runs.rs:70, enact :183, cancel :384, retry-failures :441) and require_user_only(["supervisor", "admin"]) (pause :286, resume :335) — same role bars as the require_admin/require_supervisor_or_above they replace. Service class is 403 service_class_on_user_only unconditionally; with CANOPY_ELIGIBILITY__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a legacy broad-audience worker bearer is 403 aud_not_exact — bulk-run operators mint through exchange (the persons-slice runbook pattern, #1501).

  • Every route sits behind the exchanged_gate middleware (main.rs, layered on api::routes()); the 13 dual routes accept well-formed exchanged workers with no handler changes. The azp allowlist is web-exchanger-ONLY (the eligibility exchanger mints tokens FOR the fan-out targets, never for eligibility itself).

  • Attribution converged on EffectiveUser at the four user-only ledger sites (created_by, enacted_by, canceled_by, the H22 append_action actor) — value-identical to the raw claims.sub they replace for every bearer those routes admit (both admitted shapes are non-service ⇒ Direct). The STRING-PARAM ACTOR flag above (DetermineRequest. requested_by) is UNCHANGED — its server-side derivation is #985-class follow-on work, not this slice.

  • The #596 gates are semantically unchanged: an exchanged worker now reaches resolve_effective_worker with verified identity (the caseworker path-binding finally binds to a cryptographically-carried sub), while the canopy-web service arms remain for the BFF’s service-class SSR reads (dashboard panels, /all feed) — the FU-A residual C1 retires.

  • Senders switched for THIS target only (the §B rule): canopy-web’s two determine writes mint the hop-2 pair via exchange_for_target(…, Some("canopy-eligibility-exchanger")); configured-but-failed exchange fails the action (no silent downgrade, ADR-043). SSR reads stay service-class. The G5 outcome write-back to canopy-applications stays on the service path (that target’s posture is its own slice).

  • Conformance: the F4 matrix activates canopy-eligibility (4 probe-safe dual rows; the 6 bulk-run mutations are write-arm surfaces excluded per the S-security precedent, enforcement pinned by receiver_contract_test.rs, which also pins the pair-positive, the rogue-azp and non-pair multi-audience kills, and both user-only kills).

Post-slice state — S-reporting (#1438, ADR-043 §C receiver contract)

Fourteenth receiver on canopy_auth::ReceiverContract — and the FIRST whose dominant class is USER-ONLY. Deltas against the survey above (canopy-eligibility landed separately as S-eligibility):

  • ALL 21 SUPERVISOR REPORT SURFACES flipped claims.require_supervisor_or_above()contract.require_user_only(&claims, SUPERVISOR_OR_ABOVE) (the same supervisor/admin bar, now behind the §C user-context arm). Devstack ENFORCES: a broad-audience direct worker bearer is 403 aud_not_exact; service bearers are 403 service_class_on_user_only; only the exchanged user-context arm (exact aud=canopy-reporting, allowlisted azp, supervisor role) reaches the report readers/generators — live-verified ladder on a 404-deterministic read.

  • THE THREE DUAL SURFACES ARE UNCHANGED by design: the overpayments summary (the BFF panel’s service-class sender — the survey’s "deliberately service-open" note stands) and the two org-visible runs reads (authorize_runs_read, runs.rs:39 — do NOT requester- scope). The runs reads' residual arms are live-pinned under enforcement (the list by test, both by their F4 Dual rows); the summary’s service arm rides its daily BFF sender.

  • NO web sender changes: canopy-web’s only reporting call is the dual summary (service-class SSR panel, FU-A). The 21 user-only surfaces have no BFF sender — the devstack test suites are their worker callers, and reporting_test.rs + snap_reader_generations_test.rs migrated to jane.doe’s EXCHANGED bearer (the steward-client precedent).

  • Attribution: generate_fns_388 persists claims.sub as requested_by — value-identical under the exchanged bearer (the EffectiveUser invariant).

  • Azp allowlist: canopy-web-exchanger ONLY.

  • Conformance: the F4 matrix activates canopy-reporting (3 UserOnly rows on 404-deterministic 1970 periods — a 200 read stages a #1404 audit outbox event, so 200-empty list/export surfaces stay out per the S-security/S-persons precedent — plus the 2 Dual runs rows). receiver_contract_test.rs pins the full user-only ladder, the FNS-388 generate deny arms (no safe exchanged probe — a passing POST enqueues a real run; the other four generates share the identical guard shape, unprobed), the runs-list residual arms, and the rogue-azp kill.

canopy-notices + canopy-security

Plan-time index: notices 8 · security 3 — read-verified require_* sites: 27. The plan-time numbers exactly match a require_service_caller-only grep: notices has 8 (api/mod.rs:124,197,239,286,321,390,416,448) and security has 3 (api/mod.rs:301,382,427). Reading the full handlers surfaces 19 more require_* call sites the plan index missed, all in canopy-security: 14 require_admin fallbacks inside the repeated if !claims.is_service() { claims.require_admin()?; } dual gates (api/mod.rs:176,225,256,469,497,525,553,586,685,796,932,992,1178,1291), the standalone admin-only require_admin on POST /security/archive (api/mod.rs:1231), and require_admin_or_quality_control on the export endpoint (api/export.rs:94). Total in-service require_* = 8 + 19 = 27. One additional shared-crate require_service_caller (crates/canopy-api/src/admin.rs:129, the /v1/admin/events/replay handler) is mounted by BOTH services' routers but lives outside services/*/src, so it is not in the 27.

Method Path Handler Class Portal target

POST

/v1/notices

canopy-notices::api::generate_notice

service-only

GET

/v1/notices

canopy-notices::api::list_notices

service-only

yes

GET

/v1/notices/queue

canopy-notices::api::list_delivery_queue

service-only

GET

/v1/notices/{id}

canopy-notices::api::get_notice

service-only

yes

GET

/v1/notices/{id}/pdf

canopy-notices::api::get_notice_pdf

service-only

yes

POST

/v1/notices/{id}/mark-read

canopy-notices::api::mark_notice_read

service-only

yes

POST

/v1/notices/{id}/resend

canopy-notices::api::resend_notice

service-only

POST

/v1/documents/render

canopy-notices::api::render_document

service-only

POST

/v1/admin/events/replay

canopy-api::admin::admin_replay_handler (notices mount, main.rs:245)

service-only

GET

/livez + /readyz (canopy-notices)

canopy-api::livez_check/readyz_check (lib.rs:215-216)

public

GET

/v1/security/events

canopy-security::api::list_events

dual

GET

/v1/security/events/{id}

canopy-security::api::get_event

dual

GET

/v1/security/persons/{person_id}/fact-history/{resource}

canopy-security::api::fact_change_history

dual

POST

/v1/security/audit/ingest

canopy-security::api::ingest_audit_event

service-only

yes

GET

/v1/security/alerts

canopy-security::api::list_alerts

dual

GET

/v1/security/alerts/{id}

canopy-security::api::get_alert

dual

PATCH

/v1/security/alerts/{id}

canopy-security::api::update_alert

dual

GET

/v1/security/nist-controls

canopy-security::api::list_nist_controls

dual

GET

/v1/security/summary

canopy-security::api::get_summary

dual

GET

/v1/security/chain/status

canopy-security::api::chain_status

dual

POST

/v1/security/chain/verify

canopy-security::api::chain_verify_enqueue

dual

GET

/v1/security/chain/verify-jobs/{id}

canopy-security::api::chain_verify_job (requester-scoped for service callers)

dual

GET

/v1/security/chain/attest

canopy-security::api::chain_attest

dual

GET

/v1/security/archive

canopy-security::api::list_archived

dual

POST

/v1/security/archive

canopy-security::api::run_archive

user-only (mechanical, #1427)

GET

/v1/security/archive-runs/{id}

canopy-security::api::get_archive_run

dual

GET

/v1/export/audit-events

canopy-security::api::export::export_audit_events

user-only (mechanical, #1427)

POST

/v1/security/signing-keys

canopy-security::api::register_signing_key (program-allowlisted)

service-only

GET

/v1/security/signing-keys/{program}/jwks

canopy-security::api::signing_key_jwks

service-only

POST

/v1/admin/events/replay

canopy-api::admin::admin_replay_handler (security mount, main.rs:149)

service-only

GET

/livez + /readyz (canopy-security)

canopy-api::livez_check/readyz_check (lib.rs:215-216)

public

Flags for the migration slices
  • NO ActorVerifier is wired in either service: canopy_api bootstrap.rs:180 builds AuthLayer::new(jwks) plain, and repo-wide only canopy-applications (main.rs:78) and canopy-tanf (main.rs:194) call with_actor_verifier. In canopy-notices and canopy-security claims.actor() is therefore ALWAYS None, and any request carrying X-Canopy-Actor is rejected 401 by canopy-auth middleware.rs:139-145. Every migration slice that wants actor-aware gates in these services must first wire a verifier.

  • POST /v1/admin/events/replay is fail-closed but INOPERABLE in both services: admin.rs:130 demands claims.actor() with the admin role, but with no ActorVerifier the actor can never be populated (and sending the header 401s at middleware). Stale doc risk: the handler doc says 'Gated by service-class JWT + actor with the admin role' as if usable.

  • String-param actor identities throughout canopy-security: requested_by is a bare String holding either a service id or 'admin:{sub}' (api/mod.rs:848-855, :1238), compared as a string for requester scoping (:944); Claims::service_id() falls back to the azp string (claims.rs:246) — the api/mod.rs:846 comment documents the misattribution hazard that fallback already caused once (worker tokens attributing to 'canopy-api').

  • Caller-supplied attribution surfaces (body-trusted, not claims-derived): POST /v1/security/audit/ingest takes user_id/user_role/ip_address/household_id from the request body into the tamper-evident chain (api/mod.rs:338-341) — the portal posts these with a bare service token (portal lookup.rs:290-297); PATCH /v1/security/alerts/{id} takes resolved_by from the body (api/mod.rs:531).

  • The if !claims.is_service() { claims.require_admin()?; } dual-gate is copy-pasted 14x in canopy-security/src/api/mod.rs (176,225,256,469,497,525,553,586,685,796,932,992,1178,1291) — a migration slice must sweep all 14 atomically or the surface becomes inconsistent; two deliberate exceptions (POST /security/archive admin-only per #1208 decision 10 with a do-NOT-add-is_service comment at 1221-1223, and the export endpoint’s admin-or-QC gate) must survive the sweep.

  • canopy-notices is 100% require_service_caller with the IDOR/tenancy boundary explicitly delegated to BFFs (api/mod.rs:384 comment; portal does an owner pre-check GET before pdf/mark-read, portal notices.rs:173-180) — GET /v1/notices?household_id= is a filter, not an enforced gate; any service bearer can enumerate any household’s notices and read any notice PDF by id.

  • Portal (an applicant-facing caller) mints NO actor tokens at all — zero ACTOR_HEADER/with_actor hits in services/canopy-portal/src — so applicant actions arrive at notices (list/get/pdf/mark-read) and security (audit ingest) as pure no-actor service calls; applicant attribution today exists only in portal-composed audit event bodies.

  • Outbound attribution loss: canopy-notices calls applications/persons with bare service identity (applications_client.rs:66,:105 with_service_identity) — no actor context is forwarded on internal hops.

  • POST /v1/notices/{id}/resend has no in-tree caller (canopy-web/canopy-portal grep clean) and hardcodes delivery channel 'test' (api/mod.rs:426) — decide keep+gate or retire during migration.

  • Non-HTTP authz surfaces in canopy-security (out of F1a claims scope, catalogued for completeness): the audit/FTI chain-verify subsystem runs on a dedicated Postgres verify role — preimage views are verify-role-only, C8 (chain_verify/status.rs:107, :697); drainer park columns are excluded from the app role’s UPDATE grant (chain_staging/drainer.rs:166); the MQ wildcard subscriber ingests audit events broker-side with payload-carried attribution (main.rs consume_audit_event) — none of these consult HTTP Claims.

  • GET /v1/security/archive-runs/{id} has NO requester scoping (any service bearer or admin reads any run), unlike the sibling verify-jobs poll (api/mod.rs:944) which is requester-scoped — pattern inconsistency to reconcile.

  • GET /v1/security/signing-keys/{program}/jwks doc (api/mod.rs:408-409) records that public (unauthenticated) exposure for external verifiers is an intended follow-up — a later slice may need to reclassify this route from service-only to public.

  • Shared public surfaces on both services: /livez, /readyz (canopy-api lib.rs:215-216, unauthenticated by design), /swagger-ui + /api-doc/openapi.json, and the devstack test-clock router (compile-stripped from release builds, lib.rs:218-225).

Post-slice state — S-security (#1427, ADR-043 §C receiver contract)

Third receiver on canopy_auth::ReceiverContract — user-only-enforcement ONLY (canopy-security is not a program service; there is no hop-2 route, and audit ingest + the signing-key registry stay genuine service-to-service require_service_caller). Deltas against the survey above (retained as the migration baseline):

  • The two pure human-role gates became MECHANICAL user-only routes: require_user_only(["admin"]) on POST /v1/security/archive (api/mod.rs — the decision-10 "service tokens are rejected" posture is now the class kill) and require_user_only(["admin", "quality_control"]) on GET /v1/export/audit-events. With CANOPY_SECURITY__ENFORCE_USER_ONLY_ROUTES=true (devstack: on) a legacy broad-audience worker bearer is 403 aud_not_exact; service class is 403 service_class_on_user_only unconditionally.

  • The 14 copy-pasted is_service() || require_admin dual sites stay untouched behind the exchanged_gate middleware (layered on api::routes() before the admin merge) — a well-formed exchanged admin token passes them with no handler changes.

  • Attribution via EffectiveUser: the archive requested_by (admin:{sub}) and the export self-audit actor — an exchanged bearer attributes its own preserved sub. Security still wires no ActorVerifier (the flag above stands); user-only routes only ever see the Direct arm, so none is needed.

  • Conformance: the F4 matrix activates canopy-security with READ rows only — BOTH user-only routes are excluded as write-unsafe to probe (a passing probe enqueues a real archive run / publishes an audit.export.requested event); their enforcement arms are pinned by security_test (exchanged-admin positive, exchanged-wrong-role, and both service-class kills).

  • The dual READ rows carry the Admin conformance subject — security’s human arm is admin-gated, unlike the caseworker-or-above program services.

Post-slice state — S-notices (#1437, ADR-043 §C receiver contract)

Thirteenth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target with ZERO user-only routes (the enforce flag is inert, set for fleet consistency). Deltas against the notices survey above (canopy-security is untouched by this slice):

  • ONE ROUTE WIDENED: the citation render RPC (POST /v1/documents/render, api/mod.rs:140) moved require_service_callerrequire_service_or_exchanged(CITATION_RENDER_ROLES) — the exchanged arm mirrors the web-side citation-download bar (admin / studio_admin / auditor as realm roles), NOT the caseworker set, because auditors are the primary citation consumers and sit outside CASEWORKER_OR_ABOVE_ROLES. Its one sender (canopy-web audit_log.rs) flips onto the worker’s exchanged bearer through the #1560 dispatch via the NEW InternalClient::into_neutral accessor — the NeutralWrite counterpart of into_authorized; the #1004 route audit recognizes into_neutral exactly like neutral_writer (fail-on-denied; no service-identity downgrade).

  • Everything else stays service-only per FU-B / ADR-023 D4: the machine surfaces (generate, delivery queue, resend), the portal-driven applicant reads + mark-read (the portal BFF holds a bare service bearer — the survey’s IDOR-delegation flags stand), and the web SSR reads. The recipient.rs:60 household-membership gate on the generation worker path is untouched.

  • The resend route’s no-in-tree-caller + channel-literal flag stands (keep-or-retire stays a follow-on decision, not this slice).

  • Azp allowlist: canopy-web-exchanger ONLY.

  • Conformance: the F4 matrix activates canopy-notices (4 service-only probe-safe GET rows + the render row as ServiceOrExchanged riding WorkerSubject::Admin — jane.doe’s caseworker set is OUTSIDE the mirrored bar). Pinned by receiver_contract_test.rs: direct admin 403 / exchanged admin 400-past-auth (idempotent unknown-template probe, nothing persisted) / exchanged caseworker 403 / service 400-past-auth / rogue azp 403 on the same path.

canopy-verification + canopy-caps + canopy-wic

Plan-time index: verification 1+5 (plus the api-key surface) · caps 9+4 · wic 3+6 = 28 JWT require_* sites — read-verified require_* sites: 30. Read-verified: verification 6 (1 require_service_caller + 5 require_service_or_caseworker_or_above — exact match); caps 14 (9 require_service_caller + 4 require_service_or_caseworker_or_above + 1 require_data_steward); wic 10 (3 require_service_caller + 6 require_service_or_caseworker_or_above + 1 require_data_steward). Delta of +2 vs plan = the two require_data_steward redact gates (caps handlers.rs:203, wic handlers.rs:213) that the plan-time count omitted. A raw grep of 'require_' returns 35 lines; the 5 excluded are boot-time, not request authz: require_kek (caps main.rs:80, wic main.rs:78), require_real_adapters (verification guard.rs:60 definition + main.rs:94 call), require_with_dev_fallback (verification main.rs:82). The verification api-key surface is 4 validate_api_key call sites (ievs.rs:67, save.rs:54, save.rs:83, ssa.rs:53) across 3 duplicated validate_api_key implementations, catalogued as the separate internal-api-key construct per the plan.

Method Path Handler Class Portal target

GET

/v1/verifications

verifications::list_verifications

dual

yes

POST

/v1/verifications

verifications::create_verification

service-only

POST

/v1/verifications/{id}/resolve

verifications::resolve_verification

dual

POST

/v1/verifications/{id}/respond

verifications::respond_verification

dual

yes

GET

/v1/verifications/{id}/responses

verifications::list_verification_responses

dual

GET

/v1/verifications/ievs/discrepancies

ievs_discrepancies::list_ievs_discrepancies

dual

POST

/internal/v1/ievs/match

ievs::handle_ievs_match (X-Service-Api-Key, outside JWT middleware)

service-only

POST

/internal/v1/save/verify

save::handle_verify (X-Service-Api-Key)

service-only

POST

/internal/v1/save/additional-verification

save::handle_additional (X-Service-Api-Key)

service-only

POST

/internal/v1/ssa/solq

ssa::handle_query (X-Service-Api-Key)

service-only

GET

/livez + /readyz (canopy-verification)

canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json)

public

POST

/v1/determine (caps)

caps handlers::post_determine

service-only

GET

/v1/determinations (caps)

caps handlers::list_determinations_by_household

dual

GET

/v1/determinations/{id} (caps)

caps handlers::get_determination

dual

POST

/v1/determinations/{id}/redact (caps)

caps handlers::post_redact_determination (data_steward role; a service token granted that role would also pass)

user-only

GET

/v1/determinations/{id}/authorizations (caps)

caps handlers::list_authorizations_for_determination

dual

POST

/v1/authorizations/active:batchGet (caps)

caps handlers::batch_get_active_authorizations

service-only

GET

/v1/authorizations/{id} (caps)

caps handlers::get_authorization

dual

PUT

/v1/authorizations/{id} (caps)

caps handlers::update_authorization (worker-driven via canopy-web service identity, no actor)

service-only

PUT

/v1/authorizations/{id}/provider (caps)

caps handlers::switch_provider (worker-driven via BFF, no actor)

service-only

POST

/v1/providers (caps)

caps providers::create_provider

service-only

GET

/v1/providers (caps)

caps providers::list_providers

service-only

GET

/v1/providers/{id} (caps)

caps providers::get_provider (read by canopy-web determination_view.rs:874)

service-only

PUT

/v1/providers/{id} (caps)

caps providers::update_provider

service-only

DELETE

/v1/providers/{id} (caps)

caps providers::delete_provider (soft-delete)

service-only

GET

/livez + /readyz (canopy-caps)

canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json)

public

POST

/v1/determine (wic)

wic handlers::post_determine

service-only

GET

/v1/determinations (wic)

wic handlers::list_determinations_by_household

dual

GET

/v1/determinations/{id} (wic)

wic handlers::get_determination

dual

POST

/v1/determinations/{id}/redact (wic)

wic handlers::post_redact_determination (data_steward role)

user-only

GET

/v1/participants/{id} (wic)

wic handlers::get_participant

dual

POST

/v1/nutritional-risk-assessments (wic)

wic handlers::create_assessment

dual

GET

/v1/nutritional-risk-assessments (wic)

wic handlers::list_assessments_by_person

dual

GET

/v1/nutritional-risk-assessments/{id} (wic)

wic handlers::get_assessment

dual

POST

/v1/wic/households/{household_id}/appointments (wic)

wic appointment_handlers::schedule_appointment (worker-driven via canopy-web actions_wic.rs:166)

service-only

GET

/v1/wic/appointments/upcoming (wic)

wic appointment_handlers::list_upcoming_appointments (dashboard feed via BFF)

service-only

GET

/livez + /readyz (canopy-wic)

canopy-api livez_check/readyz_check (also /swagger-ui, /api-doc/openapi.json)

public

Flags for the migration slices
  • API-KEY SURFACE (verification): 4 routes under /internal/v1/* (ievs/match, save/verify, save/additional-verification, ssa/solq) are merged onto the ROOT router (main.rs:169-173), fully outside the JWT auth middleware — no Claims, so no actor/role/service_id concept exists there at all. Migration must decide whether these become service-JWT routes or keep the shared key.

  • The api-key is a single shared secret (CANOPY_INTERNAL_API_KEY) for all 4 internal routes, with a dev fallback literal 'canopy-internal-dev-key' (verification main.rs:82-87); the header check is plain string equality key == expected (non-constant-time) duplicated in 3 files (ievs.rs:50, save.rs:37, ssa.rs:36).

  • ZERO actor() consumption: no file in any of the three services calls claims.actor(), is_service() directly, or service_id(); grep for actor() across all three src trees returns nothing. Every guard is require_* only. There are no no-actor-passes-with-audit or no-actor-rejects sites anywhere in these services.

  • String/UUID caller-supplied attribution throughout (never claims-derived): resolve_verification trusts body completed_by UUID; respond_verification trusts body person_id + application_id + responded_by_source enum string; WIC schedule_appointment persists body scheduled_by free-text string; WIC create_assessment persists body assessor_worker_id UUID. A migration to actor-derived attribution touches all four.

  • canopy-caps DISCARDS worker attribution on mutations: UpdateAuthorizationRequest.updated_by and SwitchProviderRequest.switched_by are accepted-but-ignored by design (contracts-caps authorizations.rs:63-65, 85-88 — 'audit lives on canopy-web’s tracing log'). The only durable record of WHO changed a childcare authorization is the BFF’s tracing output. Also effective_date on switch is accepted-but-ignored.

  • Possible path mismatch: canopy-web actions_caps.rs:182/241 PUT to '/v1/caps/authorizations/{id}[/provider]' but canopy-caps serves '/v1/authorizations/{id}[/provider]' (contracts-caps paths.rs:23-27) with base_url pointing straight at the caps service (clients.rs:2201 test pins http://localhost:8016). Verify whether a gateway rewrite exists or these worker actions 404 in production — either way the migration slice for caps mutations must pin the real path.

  • The two require_data_steward redact gates (caps:203, wic:213) are role-only, principal-class-agnostic: a service token GRANTED data_steward would pass and the audit event would then attribute the service client id (claims.sub, handlers caps:238 / wic:248) — the attribution site never consults actor(). The redacted-by parameter is a stringly Option<&str> (events.rs publish_determination_redacted, caps:80-92 / wic:81-92).

  • respond_verification’s ownership gate (verification’s stored application_id vs body application_id) is the ONLY household/ownership gate in all three services; caps/wic household_id/person_id query filters are scoping conveniences, not authz. Under the shared-service-bearer model the gate is bypassable by any service-token holder that first reads the verification.

  • WIC GET /v1/wic/appointments/upcoming is tenancy-unscoped (all households, LIMIT 50) behind bare require_service_caller; caps POST /v1/authorizations/active:batchGet is intentionally service-tier bulk (#1249) — both are cross-household reads a least-privilege slice should keep off any user-reachable path.

  • caps provider-registry mutations (POST/PUT/DELETE /v1/providers*) and GET /v1/providers have NO in-repo caller (only GET /v1/providers/{id} is called, by canopy-web determination_view.rs:874) — confirm the intended caller before choosing their migrated tier.

  • canopy-portal calls exactly two verification routes, both with a bare service token and NO X-Canopy-Actor: GET /v1/verifications?application_id&status=pending (portal verifications.rs:101, home.rs:228) and POST /v1/verifications/{id}/respond (portal verifications.rs:197-204). canopy-web’s post_with_actor (X-Canopy-Actor, clients.rs:981) exists but is used only in actions.rs (3 sites) and actions_tanf.rs (1) — never toward verification/caps/wic.

  • Doc-comment drift risk for the migration: verification verifications.rs:5-8 module doc frames the guards as 'service-class JWT auth; service-or-caseworker-or-above' — accurate today, but any tier change must update it, plus the per-handler #[utoipa::path] 403 descriptions which encode the current tier in the public OpenAPI.

Post-slice state — S-caps (#1432, ADR-043 §C receiver contract)

Eighth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target (single-exact audience). Deltas against the survey above:

  • POST /v1/determine (handlers.rs:63) moved require_service_callerrequire_service_or_exchanged — the orchestrator’s service token or its re-exchanged hop-2 bearer (EXCHANGE_TARGETS gains canopy-caps); a direct worker bearer stays 403. No as_of/trigger provenance pins exist in caps (unlike snap/medicaid) — nothing to widen.

  • POST /v1/determinations/{id}/redact became a MECHANICAL user-only route (require_user_only(["data_steward"]), handlers.rs:206) with EffectiveUser on the redaction event actor (value-identical). The survey’s principal-class-agnostic caveat is closed: a service token granted data_steward no longer passes.

  • THE TWO WORKER-FACING AUTHORIZATION WRITES (PUT /v1/authorizations/{id} :420, PUT /v1/authorizations/{id}/provider :453) moved require_service_callerrequire_service_or_exchanged — the S-snap recompute precedent: the BFF now sends the worker’s exchanged bearer, so the survey’s worker-identity-as-ignored-body-field exposure gains a cryptographically-carried identity. Row-level attribution capture is STILL absent by design (no schema change this slice); the flag stays open for a follow-on.

  • Discovered en route (#1564, fixed in the same MR): both web actions had been PUTting to nonexistent /v1/caps/* paths since #448 — a bare route-miss 404 rendered as the error fragment on every submit; the caps-actions e2e smoke asserts only < 500 (the #872/#1562 blind-assert class) so it never caught it.

  • Every route sits behind the exchanged_gate; azp allowlist = canopy-web-exchanger,canopy-eligibility-exchanger. Providers registry + batchGet stay service-only unchanged.

  • Conformance: the F4 matrix activates canopy-caps (3 dual, the redact user-only row, 2 service-only, and the authorization-update ServiceOrExchanged row — the matrix’s first PUT surface). Excluded: determine (real determinations; caps_test covers) and the provider-switch PUT (same guard shape as the included update). receiver_contract_test.rs pins the enforcement kills, the exchanged steward/worker positives, and the direct-worker 403s.

Post-slice state — S-wic (#1433, ADR-043 §C receiver contract)

Ninth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target. Deltas against the survey above:

  • POST /v1/determine (handlers.rs:57) moved require_service_callerrequire_service_or_exchanged (EXCHANGE_TARGETS gains canopy-wic); a direct worker bearer stays 403. No provenance pins exist in wic.

  • POST /v1/determinations/{id}/redact became a MECHANICAL user-only route (handlers.rs:216, the caps twin) with EffectiveUser on the redaction event actor (value-identical).

  • POST /v1/wic/households/{household_id}/appointments (appointment_handlers.rs:99) moved require_service_callerrequire_service_or_exchanged and its BFF sender flipped — the survey’s USER-FACING-mutation-under-bare-service-bearer flag gains a cryptographically-carried worker identity. scheduled_by stays the caller-supplied string (no schema change; flag stays open). The nutritional-risk write’s sender also flipped (dual route — no guard change); assessor_worker_id remains caller-supplied (flag stays).

  • GET /v1/wic/appointments/upcoming stays service-only (SSR dashboard feed; the tenancy-unscoped flag stays for a least-privilege follow-on).

  • Azp allowlist: canopy-web-exchanger,canopy-eligibility-exchanger.

  • Conformance: the F4 matrix activates canopy-wic (4 dual, the redact user-only row, 1 service-only). Excluded: determine (real determinations), the appointment create (a bare INSERT with no existence check — any authorized probe writes a row; its direct-worker 403 is pinned by receiver_contract_test.rs), the risk-assessment create (write), and the person-scoped assessments list (a fifth dual read identical in shape to the four rowed ones — deliberately unrowed).

Post-slice state — S-verification (#1434, ADR-043 §C receiver contract)

Tenth receiver on canopy_auth::ReceiverContract, a TERMINAL exchange target with ZERO user-only routes (the applications precedent — the enforce flag is inert, set for fleet consistency). Deltas:

  • POST /v1/verifications (verifications.rs:123) moved require_service_callerrequire_service_or_exchanged: the survey’s "orchestrator is the only production caller" note was STALE — the BFF’s request-verification action posts it under service identity, and BOTH web verification writes now send the worker’s exchanged bearer via the shared #1560 dispatch — the create, and the G3 auto-resolve leg inside accept_document (best-effort semantics kept: a denied exchange degrades to manual resolution, never a downgrade). A direct worker bearer stays 403 on the create. The dual mass (list/resolve/respond/responses/discrepancies) is guard-unchanged behind the exchanged_gate; completed_by stays the caller-supplied body field (flag open).

  • The PORTAL surfaces (GET /v1/verifications, POST /v1/verifications/{id}/respond) carry the #1441 portal arm since P2 (require_dual_or_portal on portal:verifications:read / portal:verifications:respond); the respond ownership-gate caveat stands until P3 (#1442).

  • THE API-KEY SURFACE (/internal/v1/{ievs/match, save/*, ssa/solq}, X-Service-Api-Key) is CLASSIFIED, not migrated: it is mounted outside the JWT router entirely (no Claims exist), so no receiver-contract arm applies. Its retirement to ADR-019 service tokens remains the survey-flagged follow-on; N1 fixes the stale security.adoc bullet.

  • Azp allowlist: canopy-web-exchanger ONLY (not an EXCHANGE_TARGETS program).

  • Conformance: the F4 matrix activates canopy-verification — the two SEED rows go live (the create row reclassified ServiceOnly → ServiceOrExchanged, still the ADR-025 422-zero-write probe) plus two new dual rows (ievs/discrepancies, {id}/responses). resolve (mutating dual) and the portal respond (P2 scope) stay unrowed; receiver_contract_test.rs pins the create posture BOTH ways (direct worker 403, exchanged worker 422-past-auth-zero-write).

Post-slice state — P1 portal narrow token sources (#1440, ADR-043 A1)

The portal caller rows above predate #1440 and their mechanics moved: LookupDeps.service_token (the ONE process-wide broad source every row’s token came from) no longer exists — acquisition is per-target (services/canopy-portal/src/tokens.rs, PortalTokenSources: eight scope-aware sources, one per backend target, each self-validating against its own target audience), so cited deps.service_token call sites and their line numbers are stale as locations while the ROUTES each row documents are unchanged. The cross-target token reuse several rows implied (one token fanned across applications + persons
verification + eligibility + enrollment in home; applications
security in lookup) is GONE — each hop now carries a token valid only at that hop’s target (lateral kill live-pinned by services/canopy-portal/tests/narrow_token_test.rs).

Post-slice state — P3 origin-verifiable ownership binding (#1442, ADR-043 A1)

The cross-owner hole inside the classified surface is closed. The portal signs a 120s X-Canopy-Applicant claim per resource-keyed call (application sub + resolved household/person; key ≠ the OAuth2 client secret, so a stolen narrow bearer cannot mint one) and six origins verify + enforce: applications drafts patch/get/finalize, the application read, document list/upload (upload also binds the SUBJECT person before any object write — the old caller-supplied person_id hole); persons GET /v1/persons/{id} (session’s submitting person only); notices list (absent household filter denies) and get/pdf/mark-read (post-load compares, UNIFORM 404 — denial never confirms a foreign notice exists; mark-read pre-reads before stamping); verification list + respond (the citizen session binding runs BEFORE the legacy 403/422 arms, closing their existence oracle; the response’s subject person is bound); eligibility determinations and enrollment’s annual summary (claim-local household compares). Non-citizen principals pass every ownership guard untouched. Exempt by design: create-draft, verify-credential and the recovery flows (they ARE the authentication), snap params (no applicant resource) + security audit-ingest — exempt because nothing is READ back and the server mints the chain identity (a replayed event_id cannot fork the chain), NOT because the body is resource-free: the portal’s session events do carry a body-trusted resource_id, an attribution-integrity residue that predates P3 (a stolen portal bearer could stage misattributed audit rows; tracked as follow-on work). Neither service is claim-wired. F4: CrossOwnerAccess live on the 6 pre-load classified rows; live pins in narrow_token_test (claim-missing / cross-owner / garbage-claim-401, codes pinned), the applications draft suites, and the enrollment receiver tests. This closes #665 (the applicant-token-signer premise is superseded — the signer now exists as the portal-applicant keypair).

Post-slice state — P2 receiver-side portal narrowing (#1441, ADR-043 A1)

The receiver half landed. The portal credential is a compiled CITIZEN CLASS in canopy-auth (CITIZEN_CLASS_SERVICE_IDS = ["canopy-portal"]; recognition checks the service:* role half and the azp half independently): Claims::require_service_caller refuses it — 403 portal_on_non_portal_route — and every service-accepting contract arm delegates there, so EVERY route in the portal’s 8 targets that is not explicitly portal-classified rejects the narrowed token through one check (the negative the P1 block above deferred). The 21 portal-reachable routes re-admit it on azp allowlist + per-route operation scope (12-scope portal:* vocabulary, minted per target by PortalTokenSources and defined as realm optional client scopes with include.in.token.scope=true): the applications applicant flows (verify-credential, drafts ×4, recover initiate/kill) are PORTAL-ONLY on portal:intake — ordinary service bearers are 403 portal_only_route there now; application GET + document list/upload, security audit-ingest (portal:audit:write — service-or-portal, the exchange-audit sinks keep their service arm), persons GET, and the notices reads/ack take require_service_or_portal; verification list/respond, eligibility household determinations, and snap params take require_dual_or_portal; enrollment’s annual summary takes require_service_or_exchanged_or_portal (issuances stays portal-killed). A missing scope is 403 portal_scope_missing. F4: PortalLateralAccess live on all 47 rows of the 8 targets
PortalScopeMissing on the 9 classified rows (floor 945 → 1000); live pins: narrow_token_test (intra-target kill, aud-only scope kill, 8-target positive sweep incl. the ack/respond/documents-list arms)
the enrollment receiver tests. Ownership binding (a stolen portal credential crossing RESOURCE boundaries inside its classified surface) remains P3 (#1442).

Caller manifest

Every user-context edge, nested hop, and background caller — verified at HEAD.

Caller Target Kind Evidence

canopy-web

canopy-applications

user-context

services/canopy-web/src/clients.rs:1538-1541 (roster target); all calls carry canopy-web’s own OIDC service bearer via with_service_identity (clients.rs:1457-1490 → with_token 1413-1415 → bearer_auth in prepare, clients.rs:507-508). Worker identity additionally rides X-Canopy-Actor JWT on exactly 3 applications sites: accept document api/actions.rs:570, reject document api/actions.rs:697, scan override api/actions.rs:772 (minted by mint_actor_jwt actions.rs:526-544, header attached clients.rs:989-990 via canopy_auth::client_ext::ACTOR_HEADER).

canopy-web

canopy-tanf

user-context

clients.rs:1554-1557 (roster); service bearer everywhere + X-Canopy-Actor on the work-requirement action: api/actions_tanf.rs:264 (mint) and :272-278 (post_with_actor). This is the 4th and last actor-attaching site in the whole fleet.

canopy-web

canopy-persons

user-context

clients.rs:1534-1537 (roster); service bearer only (with_service_identity, e.g. api/members.rs:103, api/income.rs:127). No actor header on any persons call — worker attribution rides in request bodies.

canopy-web

canopy-eligibility

user-context

clients.rs:1542-1545; service bearer only (api/actions_ele.rs:114, api/actions_intake.rs:224).

canopy-web

canopy-enrollment

user-context

clients.rs:1546-1549; service bearer only (api/actions_snap_enrollment.rs:287). Issuance reads therefore arrive actor-less at enrollment’s #408 gate.

canopy-web

canopy-snap

user-context

clients.rs:1550-1553; service bearer only (api/actions_snap.rs:98, :310).

canopy-web

canopy-medicaid

user-context

clients.rs:1558-1561; service bearer only (api/actions_medicaid.rs:43-236).

canopy-web

canopy-caps

user-context

clients.rs:1562-1565; service bearer only (api/actions_caps.rs:46-231).

canopy-web

canopy-wic

user-context

clients.rs:1566-1569; service bearer only (api/actions_wic.rs:42-215).

canopy-web

canopy-renewals

user-context

clients.rs:1570-1573; service bearer only (dashboard/case panels, e.g. api/cases.rs:135-141 stamps deadline then with_service_identity).

canopy-web

canopy-notices

user-context

clients.rs:1574-1577; service bearer only (api/notices.rs handlers).

canopy-web

canopy-appeals

user-context

clients.rs:1578-1581; service bearer only (api/appeals.rs:55, api/actions_snap_appeal.rs:252, api/actions_snap_appeal_decision.rs:242).

canopy-web

canopy-security

user-context

clients.rs:1582-1585; service bearer only (audit-log reads / chain status via get_terminal_status, clients.rs:841-880).

canopy-web

canopy-reporting

user-context

clients.rs:1586-1589; service bearer only.

canopy-web

canopy-verification

user-context

clients.rs:1590-1593; service bearer only (worker-portal /v1 surface — distinct from the X-Service-Api-Key /internal/v1 IEVS surface, see clients.rs:1355-1359 comment); e.g. team_queue.rs:72, fact_history.rs:129.

canopy-portal

canopy-applications

user-context

Pure canopy-portal service bearer, never an applicant actor: apply.rs:68-69 (POST /v1/applicants/drafts, bearer_auth(&token) from deps.service_token.current() apply.rs:60), apply.rs:327 (verify-credential), documents.rs:260-265 (multipart upload, bearer), documents.rs:103/138 (document list), lookup.rs:130-134 (verify-credential), recover.rs:96-100 + 160-162 (recover initiate / kill-switch), home.rs:184, persona.rs:103, verifications.rs:116, notices.rs:136. Applicant ownership is enforced only inside the portal BFF session, not on the wire.

canopy-portal

canopy-notices

user-context

notices.rs:231 (list by household), :180-181 (owner check GET /v1/notices/{id}), :325-326 (pdf), :390-391 (mark-read) — all .bearer_auth(service token) from service_token() helper notices.rs:161-163.

canopy-portal

canopy-persons

user-context

persona.rs:110 + :123 (GET /v1/persons/{submitted_by}, bearer_auth(token)); home.rs:209 same shape via fetch_json (home.rs:561 .bearer_auth(token)).

canopy-portal

canopy-verification

user-context

verifications.rs:101 (list for application), :199-204 (POST /v1/verifications/{id}/respond, bearer_auth(&token)); home.rs:228 (pending count).

canopy-portal

canopy-eligibility

user-context

home.rs:246-249 (GET /v1/eligibility/determinations?household_id=…, service bearer via fetch_json home.rs:561).

canopy-portal

canopy-enrollment

user-context

home.rs:265-268 (GET /v1/households/{id}/annual-summary, service bearer via fetch_json).

canopy-portal

canopy-snap

user-context

snap_params.rs:61-73 (GET snap params, .bearer_auth(&token) from deps.service_token.current()).

canopy-portal

canopy-security

background

lookup.rs:275-303: fire-and-forget tokio::spawn POST {security_url}/v1/security/audit/ingest with service bearer (lookup.rs:282, :298) — applicant.session.minted audit; best-effort by design (ADR-026), dropped on error.

canopy-eligibility(orchestrator)

canopy-persons

nested-hop

orchestrator.rs:229-231 GET {persons_base_url}/v1/households/{id}/full with service jwt (service_token.current() orchestrator.rs:215-216); runs inside POST /v1/eligibility/determine (api/handlers.rs:111-170 wires DetermineConfig with its own ServiceTokenSource handlers.rs:65/93/130). Originating worker actor is NOT propagated.

canopy-eligibility(orchestrator)

canopy-snap/tanf/medicaid/caps/wic

nested-hop

orchestrator.rs:1545 POST {base_url}/v1/determine per registry program (+ snap dry-run/baseline legs orchestrator.rs:1099-1196, dispatch 873-925), all with svc_jwt via with_service_identity; no actor forwarded.

canopy-eligibility(orchestrator)

canopy-verification

nested-hop

Two surfaces: POST /v1/verifications (orchestrator.rs:804-808, bearer_auth service token from :787) creating pending verifications; and POST /internal/v1/ssa/solq (orchestrator.rs:617-621) which sends the service jwt PLUS a static x-service-api-key header (legacy internal-API-key auth, orchestrator.rs:620).

canopy-eligibility

canopy-security

nested-hop

key_history.rs:20-21, 52-57: HttpKeyHistoryProvider GET on security_base_url with with_service_identity(&svc_jwt), memoized via MemoizedKeyHistory (main.rs:126-127) — signing-key-history lookups during verification of chained records.

canopy-snap (also tanf/medicaid/caps/wic)

canopy-rules

nested-hop

crates/canopy-rules-client/src/lib.rs:274-287 (POST /v1/evaluate, bearer_auth(t) at :287) + :346 (GET /v1/corpus); used inside snap determine (determine_handler.rs:20/64), tanf work-requirements (work_requirement_handlers.rs:32-33/222), caps determine.rs, wic determine.rs, medicaid rules_client.rs. Service token only; the determine call is itself already actor-less.

canopy-snap

canopy-enrollment

nested-hop

enrollment_client.rs:62-79: GET /v1/households/{id}/issuances with with_service_identity(&token) inside POST /determinations/{id}/overpayment-recompute (recompute_handler.rs:113-121, 150-157; handler is require_service_caller — worker action mediated by the BFF).

canopy-snap

canopy-persons

nested-hop

recompute_handler.rs:119 (PersonsClient dep) inside the same overpayment-recompute request; PersonsClient attaches the service token via with_service_identity (crates/canopy-persons-client/src/lib.rs:104 etc.).

canopy-enrollment

canopy-applications

nested-hop

api/mod.rs:115-144 gate_household_actor_access: when an X-Canopy-Actor worker is present (claims.actor(), api/mod.rs:123), calls clients/mod.rs:66-82 GET /v1/households/{id}/assignments with with_service_identity(service_token) (clients/mod.rs:79); actor absent ⇒ allowed unconditionally with no hop (api/mod.rs:123-125).

canopy-appeals

canopy-enrollment

nested-hop

AdverseActionsClient (clients.rs:286-309, service token; wired main.rs:132 on enrollment_url) — filing-time GET /v1/adverse-actions/{id} (clients.rs:395-401) and fenced stay PUT/GET (clients.rs:415-443) used in the appeal-filing handler (api/mod.rs:328) and the inline continued-benefits step (cb_stay.rs:15-16, execute_pending_stay cb_stay.rs:65). Actor not forwarded.

canopy-appeals

canopy-snap

nested-hop

SnapHearingClient (clients.rs:197-241, appeals' own service token — doc at clients.rs:191-196 notes a forwarded worker bearer would be rejected by the service-gated /hearing-view) GET /v1/determinations/{id}/hearing-view inside get_appeal_hearing_view (api/mod.rs:47/116, wired main.rs:206 on snap_url).

canopy-applications

canopy-persons

nested-hop

finalize saga on applicant submit: finalize_saga.rs:46/66/180 uses canopy_persons_client::PersonsClient (built main.rs:124-137 with service_token_source); the client attaches only the ADR-019 service token per call (crates/canopy-persons-client/src/lib.rs:78, :104, :131, :171, :305, :372, :390 with_service_identity). Applicant identity does not ride the hop.

canopy-eligibility (bulk pipeline)

canopy-renewals + canopy-persons + program services

background

bulk/worker.rs:378-384 GET {renewals_url}/v1/renewals/snap/universe-snapshots/{id}/rows with with_service_identity(&jwt); bulk consumer/worker re-drive the orchestrator DetermineConfig (persons + snap dispatch) under the service token, no actor (bulk/consumer.rs, bulk/worker.rs).

canopy-renewals (scheduler + MQ subscriber)

canopy-eligibility + canopy-enrollment

background

scheduler.rs:261-292 drains drive pr_pipeline; eligibility_client.rs:66-71 POST /v1/eligibility/determine/dry-run and enrollment_client.rs:121-129 POST /v1/adverse-actions (+ :163 reopen), both under renewals' service token; subscriber.rs consumes MQ and uses the same clients.

canopy-notices (render worker + recovery subscriber)

canopy-applications + canopy-persons

background

worker.rs:24/55-56 (PersonsClient + ApplicationsClient, built worker.rs:415-419); applications_client.rs:3-14 documents the service-token-per-call pattern (recovery-detail endpoint is service-caller-gated); recovery.rs drives GET /v1/applicants/recover/{recovery_id}.

canopy-medicaid (ex-parte scheduler + ELE MQ consumer)

canopy-persons + canopy-rules

background

scheduler.rs:57-61, :466-489 GET {persons_url}/v1/households/{id} and /v1/persons/{id}/income with bearer_auth(token); ELE consumer main.rs:554-585 (household fetch bearer_auth(&token) main.rs:580) + rules evaluate via medicaid rules_client.rs. Nacks to DLQ without a service token (main.rs:562-567).

canopy-appeals (assessment worker + reconcile + stay-retry)

canopy-enrollment

background

EnrollmentClient clients.rs:58-99 (doc: only consumer since #1105 is the background assessment worker — 'no inbound caller whose bearer it could forward'); assessment_worker.rs, reconcile.rs, cb_stay.rs spawn_stay_retry_task (cb_stay.rs:16-17, run_pending_stay_pass :286).

canopy-applications (finalize reconciler)

canopy-persons

background

reconciler.rs POST /v1/internal/finalize-operations/{op}/{generation}/cancel|release (paths reconciler.rs:372/398 as exercised by its persons mock); started from main.rs:145-148, :204-212 with the persons client’s service token.

canopy-reporting (run worker)

canopy-renewals/persons/applications/enrollment/snap/tanf/medicaid

background

clients/mod.rs:84-91 (7-service roster), :108-114 with_token(service token source), bearer attached per attempt clients/mod.rs:205-211; run worker spawned detached in main.rs:42 (spawn_run_worker) / :120 — the requesting worker’s identity does not ride the outbound calls.

No-slice confirmations

  • canopy-exchange: CONFIRMED stub — no migration slice needed. services/canopy-exchange/src/api/mod.rs:6-8 is the entire business router: pub fn routes() → Router<AppState> { Router::new() } (empty). main.rs:23-37 mounts only that empty router into ApiServer::router plus mq/outbox health Extensions; no route handlers, no Claims usage, no outbound service calls (adapters.rs/events.rs are MQ/partner scaffolding with no authorization branches).

  • canopy-rules: CONFIRMED all-service — no migration slice needed. Exactly 5 routes registered (services/canopy-rules/src/api/mod.rs:67-74: LIST_RULE_SETS, GET_RULE_SET, EVALUATE, LIST_EVALUATIONS, GET_CORPUS) and every handler’s first authorization act is claims.require_service_caller(): get_corpus api/mod.rs:91 (comment 'ADR-019: service-class callers only, like every rules read'), list_rule_sets :117, get_rule_set :150, evaluate :181, list_evaluations :237 — each read-verified. No actor(), role, or household branch exists anywhere in the file (258 lines); main.rs:56-74 adds no other routes.

Cross-cutting caller flags

  • Actor coverage is 4 sites fleet-wide: only canopy-web ever attaches X-Canopy-Actor outbound (clients.rs:989-990), and only on applications document accept/reject/scan-override (actions.rs:570/697/772) and the tanf work-requirement action (actions_tanf.rs:272-278). Every other worker-actioned write and ALL reads leave canopy-web as bare service-bearer traffic; worker attribution elsewhere rides unverified request-body fields (e.g. requested_by/completed_by shapes).

  • canopy-portal attaches NO applicant identity on any outbound call — pure canopy-portal service bearer everywhere (apply/documents/notices/persona/verifications/home/lookup/recover/snap_params). Downstream services cannot distinguish 'applicant acting on own case' from 'any service traffic'; ownership is enforced only inside the portal BFF (e.g. notices.rs:180-186 owner check happens portal-side before the pdf proxy). Every portal edge lands in the no-actor branch downstream.

  • No nested hop propagates the originating actor: eligibility→persons/verification/programs, program→rules, enrollment→applications, snap→persons/enrollment, appeals→enrollment/snap, applications→persons all re-mint their OWN service identity (with_service_identity / bearer_auth); the fleet grep for ACTOR_HEADER attachment outbound hits only canopy-web. Once a request is one hop past the BFF, user identity is gone — audit attribution downstream falls back to claims.sub = the calling service.

  • enrollment’s #408 household-assignment RBAC gate (api/mod.rs:115-145) is dormant for worker-portal traffic: it only bites when claims.actor() is Some, but canopy-web never attaches an actor on issuance/annual-summary reads, so worker reads take the no-actor 'pure system traffic' allow branch (api/mod.rs:123-125) — a no-actor-passes instance the migration slices must close.

  • eligibility→verification SOLQ uses a legacy static x-service-api-key header in ADDITION to the OIDC service jwt (orchestrator.rs:617-621); the /internal/v1 IEVS/SAVE/SOLQ surface is a parallel non-OIDC auth mechanism (also noted in web clients.rs:1355-1359) and needs explicit handling in the F1a plan.

  • canopy-web fails OPEN at the auth layer on the un-stamped path: with_service_identity returns clients WITHOUT any bearer when token fetch fails (clients.rs:1459-1467, deliberate — graceful degradation, downstream 401s render as error panels); the stamped path fast-fails AuthUnavailable instead (clients.rs:1470-1489). Downstream services are the only enforcement point for these unauthenticated calls.

  • Portal→security audit ingest is fire-and-forget from a detached tokio::spawn (lookup.rs:289-303) with SILENT-OK on failure — applicant session-mint audit events can be silently lost by design (ADR-026); if session-mint auditing becomes an authorization-relevant record under OIDC F1a, this delivery guarantee is insufficient.

  • Positive confirmation for the migration plan: no forwarded end-user bearer exists anywhere in fleet outbound traffic — the pre-#1105 bearer-forwarding mode in appeals is dead (clients.rs:62-63 doc), and web’s with_token JWT-pass-through (clients.rs:1410-1412 doc) has zero non-service-token callers in src. All 14 slices start from a uniform service-bearer(+rare-actor) baseline.

Edit this page · default