Plan: OIDC Validation at Service Boundaries + Citizen-Upload Isolation
On this page
Ratified program plan. The 2026-08-10 maintainer ruling on
#546
(note 3666918785) activated the full program and resolved all five architect-input
flags; this rewrite replaced the original <TBD> stub on 2026-08-11 and the
program was decomposed into 34 child issues (#1418–#1451) under
epic &52.
Dependencies are wired as GitLab blocks/is_blocked_by links — the issue DAG, not
this page, is the authoritative "what can start now" view.
Status
| Step | Description | Status |
|---|---|---|
S0 |
ADR drafted (ADR-023) |
Done (2026-05-23) |
F1a |
Fleet authorization-branch inventory manifest (#1418) |
Done (2026-08-17) — MR !1145, inventory page |
F1b |
|
Done (2026-08-17) — MR !1148, ADR-043 |
F2 |
Validated-bearer extension + exact-aud/azp/role policy primitives (#1420) |
Done (2026-08-17) — |
F3 |
|
Done (2026-08-17) — |
F4 |
Conformance harness skeleton (#1422) |
Done (2026-08-17) — |
R1 |
Exchanger clients + realm wiring + hop-2 devstack proof (#1423) |
Done (2026-08-17) — hop-2 PROVEN (chained exchange works in KC 26.5; off-ramp not triggered, C1 actor retirement unconditional) (#1423) |
A1 |
|
Done (2026-08-17) — chain accept (jti dedup + frozen purpose gate) + |
S-tanf |
canopy-tanf receiver slice (#1425) |
Done (2026-08-17) — first |
S-medicaid |
canopy-medicaid receiver slice (#1426) |
Done (2026-08-17) — second |
S-security |
canopy-security receiver slice (#1427) |
Done (2026-08-17) — third |
S-persons |
canopy-persons receiver slice (#1428) |
Done (2026-08-17) — fourth |
S-applications |
canopy-applications receiver slice (#1429) |
Done (2026-08-17) — fifth adopter, the fleet’s first ZERO-swap slice (no pure human-role gates exist — no guards change): |
S-eligibility |
canopy-eligibility receiver slice + hop-2 sender (#1430) |
Done (2026-08-22) — sixth |
S-snap |
canopy-snap receiver slice (#1431) |
Done (2026-08-22) — seventh |
S-caps |
canopy-caps receiver slice (#1432) |
Done (2026-08-22) — eighth |
S-wic |
canopy-wic receiver slice (#1433) |
Done (2026-08-23) — ninth |
S-verification |
canopy-verification receiver slice (#1434) |
Done (2026-08-23) — tenth |
S-enrollment |
canopy-enrollment receiver slice (#1435) |
Done (2026-08-23) — eleventh |
S-renewals |
canopy-renewals receiver slice (#1436) |
Done (2026-08-23) — twelfth |
S-notices |
canopy-notices receiver slice (#1437) |
Done (2026-08-23) — thirteenth |
S-reporting |
canopy-reporting receiver slice (#1438) |
Done (2026-08-23) — fourteenth |
S-appeals |
canopy-appeals receiver slice (#1439) |
Done (2026-08-23) — fifteenth and FINAL receiver of the chain, terminal target with zero user-only routes (uniform survey: 27 bare |
P1 |
Portal target/scope-aware token sources + self-validation contract (#1440) |
Done (2026-08-23) — |
P2 |
Portal |
Done (2026-08-23) — citizen-class carve-out in canopy-auth (compiled
recognition, both halves independent; |
P3 |
Origin-verifiable ownership binding — absorbs #665 (#1442) |
Done (2026-08-23) — portal-signed 120s ES256 X-Canopy-Applicant claim (canopy-signing ApplicantClaimIssuer, distinct aud namespace, cross-family confusion refused both ways); middleware lift + typed ApplicantOwnership extension + require_owned_{application,household, person} guards (inert for non-citizen principals, fail-closed for the portal); portal mints per resource-keyed call (intake/authn surfaces exempt); six origins verify via the shared fail-loud boot helper and enforce per route incl. the upload subject-person binding and the uniform-404 post-load compares; F4 CrossOwnerAccess live on the 6 pre-load classified rows (floor 1000 → 1005); ownership negatives code-pinned live (ownership_claim_missing / ownership_mismatch / middleware 401s); closes #665 (#1442) |
C1 |
Cutover cleanup — retire legacy guards + X-Canopy-Actor where migrated (#1443) |
Done (2026-08-24) — retirement UNCONDITIONAL (hop-2 proven, off-ramp
not triggered): the middleware 401s ANY request carrying the header;
|
N1 |
Keycloak deployment-notes consolidation (#1444) |
Done (2026-08-24) — idp-integration gains the stand-up-a-realm
checklist (KC 26.2+ floor, per-client exchange toggle, mappers incl.
the ADR-044 |
S6 |
Fleet conformance matrix closure (#1445) |
Done (2026-08-24) — the no-slice proofs land: canopy-rules gains its
2-row ServiceOnly tranche (still all-service, still rejecting user
bearers; |
T1 |
Program terminal — status reconciliation + epic closure (#1446) |
Done (2026-08-24) — every program row terminal; plan archived (nav → Archive); roadmap phase note added; epic &52 closed with the shipping summary; the five deferral issues confirmed open + unblocked (workflow::ready; #1449 stays needs-spec) and referenced from the closing summary (#1446) |
FU-A |
Deferral: per-service audience for service-class tokens (#1447) |
Deferred (post-program follow-up — tracked as #1447; the FU-A
residual broad- |
FU-B |
Deferral: nested-hop exchange + attribution preservation (#1448) |
Deferred (post-program follow-up — tracked as #1448; nested service hops under a user origin keep service identity today, attribution preserved at the origin write) |
FU-C |
Deferral: citizen-content process isolation (#1449) |
Deferred (needs-spec — tracked as #1449; ADR-043 A4 narrowed ADR-023 Decision 3 to credential+data isolation, process/RCE isolation is explicitly post-v1) |
FU-D |
Deferral: identity-revocation guidance correction (#1450) |
Deferred (trivial follow-up — tracked as #1450; ADR-043 A5 recorded the RFC 7009 token-value correction, the xtask guidance fix rides #1450) |
non-KC |
Deferral: non-Keycloak IdP deployment notes (#1451) |
Deferred (documentation follow-up — tracked as #1451; the N1 checklist + off-ramp carry the Keycloak-shaped contract any IdP must replicate) |
Epic: &52
Issues: #1418–#1451 (34 program issues; #546 was the decomposed placeholder, #1006 the
already-shipped upload-quarantine child)
Branch: feature/546-oidc-program-plan (this plan MR only — each child issue gets its
own feature/{iid}-… branch)
Context
ADR-023 (2026-05-23) mandates OIDC validation at every program service, token exchange for user-context requests, citizen-upload isolation, and service-class credential narrowing. Its original companion plan was a stub, and four of its premises are stale at HEAD:
-
The canopy-auth middleware is already fleet-mounted —
canopy-api’s bootstrap installs the `AuthLayercentrally (crates/canopy-api/src/bootstrap.rs:156-168); "add the middleware to each service" is done and was never the hard part. -
The citizen upload pipeline shipped behind quarantine (#1006, ADR-042).
-
The portal’s real downstream audience is 8 services (applications, security, verification, persons, notices, eligibility, snap, enrollment), not the 2 the stub guessed.
-
canopy-web already swapped JWT pass-through for service-token +
X-Canopy-Actor(ADR-019), so the migration starts from actor-attribution, not from raw user tokens.
The gap that remains is authorization, not authentication. Every service accepts
the same two audiences (canopy worker + canopy-internal-service) with
any-match semantics, and any service:* role passes require_service_caller
anywhere. One abused service credential — above all the portal’s, which parses
citizen-supplied bytes — authorizes calls across the whole fleet. This program
replaces that flat trust with per-target exchanged tokens for user-context
requests, a narrowly-authorized portal credential, and receiver-side contracts
that verify who is calling, for whom, and for what.
Frozen decisions (2026-08-10 ruling)
These are binding; deviations require a new ruling.
| # | Decision |
|---|---|
R1 |
Full program activated now; auth foundations (the stub’s "Steps 1–2") first. |
R2 |
The citizen-path mechanism is a dedicated narrow IdP service account, not RFC 8693 exchange — ADR-026’s opaque Redis sessions mean no citizen token exists to exchange. Recorded in amending ADR-043 (rides the first implementing MR, F1b; ADR-023 itself is immutable). |
R3 |
Rollout is sequential, FTI-first. |
R4 |
Exchanged-token cache: per-request. |
R5 |
Keycloak-only v1; other IdPs additive later. |
R6 |
|
R7 |
mTLS is a post-migration stretch goal. |
Scope
In scope (what v1 actually closes):
-
Worker-context request chains carry the worker’s validated identity in a per-target exchanged token; user-only routes stop honoring service-class tokens.
-
The portal operates on a dedicated narrow credential,
azp-allowlisted and ownership-bound at its 8 targets. -
Every exchange is audited (success and failure) before the token is used.
Out of scope (this plan MR): product code; the amending ADR (F1b’s MR); realm/devstack changes; implementing #515/#518; hop-3 chaining; per-service service-class audiences; citizen-content process isolation; mTLS.
Deferred with owners — each a linked follow-up issue, not a footnote:
| Residual | Follow-up | Milestone |
|---|---|---|
Per-service audience for service-class tokens — a leaked service token still reaches service-only/dual routes in v1 |
FU-A (#1447, weight 5) |
T5 |
Nested-hop exchange + attribution preservation (hop-3): snap→persons/enrollment, appeals→enrollment/snap, applications→persons, verification→persons, enrollment→applications stay service-class under user origins |
FU-B (#1448, weight 5) |
T5 |
Citizen-content process/RCE isolation — parsers still run in canopy-applications' privileged process; #1006 bounds the data/credential radius, not parser compromise. ADR-043 narrows ADR-023 D3 to credential+data isolation and names process isolation future defense-in-depth |
FU-C (#1449, weight 8) |
T5 |
|
FU-D (#1450, weight 2) |
T5 |
Non-Keycloak IdP portability notes (Authentik/Kanidm/Zitadel; joins the #512/#514/#515 set) |
non-KC (#1451, weight 2) |
T5 |
Design
A. EffectiveUser + fleet authorization-branch inventory (Phase-0 foundation)
Today "no actor claim" is read four incompatible ways across the fleet:
-
no-actor-passes —
require_supervisor_actor(services/canopy-applications/src/api/assignments.rs:36); -
no-actor-passes-with-audit —
gate_household_actor_access(services/canopy-enrollment/src/api/mod.rs:123); -
no-actor-rejects —
verified_reviewer(services/canopy-applications/src/api/documents_scan.rs:29); -
attribution-resolution (who to record, not allow/deny) —
actor().map_or(claims.sub, |a| a.sub): medicaidhandlers.rs:84-87(FTIaccessed_by), applicationssections.rs:31-41(422s on non-UUID sub), tanfdiscrepancy_handlers.rs:84, snaprecompute_handler.rs:236, enrollmentmod.rs:182.
An exchanged worker bearer has claims.actor() == None with the identity in the
token itself — all four readings would misclassify it. So before any receiver
flips, a typed EffectiveUser in canopy-auth resolves: direct user claims for a
user bearer; verified actor for a legacy service bearer; None only for genuine
system traffic. It exposes both an authorization verdict and a
subject-to-attribute projection (pattern 4 is attribution, not authz — the type
serves both).
F1a delivers the fleet manifest that seeds every slice: every actor() /
is_service() / service_id() / role / ownership / audit branch, per service,
with file:line. The require_* grep is only a starting index (plan-time counts:
appeals 27 · applications 36+3+1 · caps 9+4 · eligibility 1+8+1 · enrollment 18 ·
medicaid 4+14 · notices 8 · persons 28 · renewals 25 · reporting 0+1 · security 3
· snap 7+21 · tanf 6+21 · verification 1+5 plus the api-key surface · wic 3+6).
B. Receiver-first, per-target rollout
Each target service migrates FTI-first through five gates: prepare receiver (accept exchanged tokens, keep accepting service+actor) → switch that target’s senders (canopy-web + orchestrator) → observe (conformance rows + audit events) → enforce (user-only routes reject service-class) → rollback criteria (config flip restores legacy acceptance without redeploying senders). A sender is never switched before its receiver is ready; C1 is small cleanup, not a big-bang cutover.
C. Receiver contract
Kills the raw-aud=canopy bypass:
-
User-context arm: exact target audience +
azpin the authorized-exchanger allowlist + required worker role (preserves the caseworker-or-above bar). User-only routes stop acceptingaud=canopy/aud=canopy-internal-service. -
Service arm: dual routes keep the any-
service:*arm in v1 — that is precisely the FU-A residual. Nested hops mint scope-lessclient_credentialstokens today, so requiring operation scopes on the service arm would break snap→persons. Operation-scope requirements apply only where classified: the portal’s citizen-reachable routes (P2) and any route a slice explicitly hardens. -
Each slice’s route classification (user-only / service-only / dual / portal-only) gates which arm applies.
-
401/403 frozen: 401 = no/invalid token; 403 = validated but unauthorized (wrong aud/azp/role). ADR-043 + middleware + tests must all encode this; S6 asserts the rejection of a raw
aud=canopytoken on a user-only route fleet-wide.
D. TokenExchanger broker
-
Runtime output validation before any use: signature/iss/typ;
subpreserved;azp= this exchanger client; exactly the requested audience (nevercanopy/canopy-internal-service, never aservice:*role); required worker role present inrealm_access.roles— an exchange returning stripped roles fails loudly at the broker, not as a mystery 403 downstream; granted scope ⊆ requested; token_typeBearer; no refresh token;exp ≤ min(subject.exp, now+300s)(+5s clock-skew tolerance on the TTL arm, validation-time clock — #1565). -
Cache key = the normalized exchange request (audience + canonical scope set
purpose) — never "per audience" alone. Lifetime: per-request (ruling R4). -
Audit-before-use, fail-closed: the broker records
auth.token_exchange(including failed/denied exchanges) and does not release the token until the audit write commits. No ambient transaction on read requests — the broker owns its audit write path. Crash recovery + dedup key on the exchangejti. The sink (A1) is live before any enforced exchange. -
Operational spec: exchange timeout, response body bounds, end-to-end secret/token redaction (the subject bearer rides a non-
Debug, non-loggable request extension — F2), bounded retry + circuit breaker, cancellation safety, latency/error/cache metrics, and an explicit ban on silent fallback to a broad service token during IdP outage — the request fails instead. -
Dedicated exchanger clients (
canopy-web-exchanger,canopy-eligibility-exchanger) isolate exchange credentials, per-target scope policy, and the ≤300s exchanged-token lifetime from those services' ordinary tokens.
Keycloak GA semantics (26.2+ standard token exchange V2): per-requesting-client
toggle; the subject token must carry the requester in aud (hence R1’s audience
mappers on worker tokens); the exchanged aud derives from the requester’s
client scopes (the audience param only down-filters); the requester must be
confidential. RFC 8693 act-claim delegation is EXPERIMENTAL in Keycloak, so v1
uses the GA impersonation-style semantics: sub preserved, exchanging client
visible as azp, no act claim. ADR-043 records this.
E. Portal isolation
-
P1 — token sources (LANDED, #1440): pre-slice one process-wide scope-less token served all 8 targets with self-validation pinned to
canopy-internal-service. Acquisition is now per-target and scope-aware (PortalTokenSources, one narrow source per target minting withaud-canopy-<target>), each source self-validating against its own target audience. Devstack contracted atomically (code + realm deploy together); PRODUCTION narrowing runs as a rotation sequence (tokens live 1800s and are cached): expand → deploy → realm switch → drain/deny old broad tokens → contract, with rollback criteria per gate recorded on #1440. -
P2 — receiver-side narrowing (LANDED, #1441): the portal credential is a compiled CITIZEN CLASS in canopy-auth (
CITIZEN_CLASS_SERVICE_IDS— compiled, not config, because an empty env allowlist would fail OPEN by leaving the portal recognized as an ordinary service bearer; recognition checks theservice:*role half and theazphalf independently so a foreign role cannot mask the azp).Claims::require_service_callerkills it with 403portal_on_non_portal_route, and every service-accepting contract arm delegates there — so every non-portal route in the 8 targets rejects the portal through one check. Classified routes re-admit it through the portal arm (azpallowlist + a per-route-family operation scope from the 12-scopeportal:*vocabulary; 403portal_scope_missingwithout it) viarequire_portal_only/require_service_or_portal/require_dual_or_portal/require_service_or_exchanged_or_portal. Portal-only surfaces — verify-credential, drafts, recovery initiate/kill, all on canopy-applications — serve ONLY the citizen arm (403portal_only_routefor everything else, services included); audit-ingest is service-or-portal (the exchange-audit sinks post there with ordinary service tokens). Negatives: F4’s PortalLateralAccess runs on every row of the 8 targets (+ PortalScopeMissing on the classified rows; floor 945 → 1000) with live pins in narrow_token_test + the per-service receiver tests. -
P3 — ownership binding (LANDED, #1442; design adjudicated 2026-08-23: portal-signed claim on a dedicated header, recorded as an ADR-043 amendment): the portal mints a 120s ES256
X-Canopy-Applicantclaim per resource-keyed call from its ADR-026 session — application id assub, household/person bindings once resolved through the authenticated applications read — signed with a key that is deliberately NOT the OAuth2 client secret (a stolen narrow bearer cannot mint claims; resource routes fail closed 403ownership_claim_missing). Deliberately NOT the (since-retired, C1) worker actor channel — the applicant claim rides its own header +audnamespace. Six origins verify (shared fail-loud boot helper; kid derived from the raw pem on both sides) and enforce per route: applications drafts/read/documents (+ the upload’s subject-person binding — the documents.rs:240 hole), persons' person read, notices list + reads (post-load compares are UNIFORM 404s — no existence oracle), verification list/respond (the citizen arm’s session binding runs before the legacy 403/422 arms, closing their oracle), eligibility determinations, enrollment annual-summary. Non-citizen principals pass every guard untouched. F4: CrossOwnerAccess live on the 6 pre-load classified rows (post-load rows honestly Pending — absence and foreign are deliberately indistinguishable); the exempt intake surfaces (create-draft, verify-credential, recovery) are the authentication itself. P3 is the real #665 and closes it.
F. Audit events
auth.token_exchange payload: {sub, azp, target aud, granted scope, purpose ∈
worker_request | orchestrator_fanout, exp, jti, parent/subject jti, correlation
id} — IDs/enums only, populating canopy-security’s structured actor/resource
fields; covers web and eligibility hop-2, success and failure. The stub’s
citizen_upload/background_job purposes die — neither path exchanges (R2;
ADR-023 D4).
G. Conformance harness
Route-manifest-driven, landed before the first receiver flip (F4), extended per
slice, closed fleet-wide at S6. Matrix rows per service: exchanged-accepted ·
service-class-rejected-on-user-only · raw-aud=canopy-rejected ·
exchanged-token-missing-worker-role→403 · wrong-role · wrong-exchanger-azp ·
extra/absent audience · excessive scope/lifetime · actor-header
exchanged-token combination · portal lateral access inside an allowed target ·
cross-owner access · cache separation · IdP-failure behavior · mixed-version
state (the mixed-version kind was retired at S6 — the fleet is post-C1
uniform, so no transition combination remains for a two-version stack to
disagree about).
Phase map
34 issues, all epic_id-linked under epic &52. exchange and rules get no
slice — empty router / all-require_service_caller; nothing to migrate.
| Phase | ID | Issue | Delivers | Weight | Depends |
|---|---|---|---|---|---|
0 |
F1a |
#1418 |
Fleet authorization-branch inventory manifest (docs page; seeds every slice) |
3 |
— (sole DAG root) |
0 |
F1b |
#1419 |
|
5 |
F1a |
0 |
F2 |
#1420 |
Sensitive validated-bearer extension + exact-aud/ |
3 |
F1b |
0 |
F3 |
#1421 |
|
5 |
F2 |
0 |
F4 |
#1422 |
Conformance harness skeleton (Design G, pre-flip) |
3 |
F2 |
1 |
R1 |
#1423 |
Exchanger clients + realm wiring (toggles, audience mappers,
per-target client scopes, realm-role mappers, ≤300s exp, client policies);
|
5 |
F3 |
1 |
A1 |
#1424 |
|
3 |
R1 |
2 |
S-svc ×15 |
#1425–#1439 |
Per-target receiver slices (Design B), FTI-first: tanf → medicaid → security → persons → applications → eligibility → snap → caps → wic → verification → enrollment → renewals → notices → reporting → appeals |
3 for tanf/applications/security/eligibility/persons, else 2 |
A1 + F4 + the prior slice (sequential, R3) |
3 |
P1 |
#1440 |
Portal target/scope-aware token sources + self-validation contract + rotation sequence |
5 |
F3 |
3 |
P2 |
#1441 |
|
5 |
P1 + the 8 target slices |
3 |
P3 |
#1442 |
Origin-verifiable ownership binding (absorbs and closes #665) |
5 |
P2 |
4 |
C1 |
#1443 |
Cutover cleanup: retire legacy guards + |
3 |
All slices + P2 |
4 |
N1 |
#1444 |
Keycloak deploy-notes consolidation (idp-integration.adoc;
fixes the stale |
2 |
R1 |
4 |
S6 |
#1445 |
Fleet conformance matrix closure |
3 |
C1 |
4 |
T1 |
#1446 |
Terminal: status reconciliation, plan → Done + nav Archive, epic closure, roadmap update |
2 |
C1 + P3 + N1 + S6 |
— |
FU-A/B/C/D, non-KC |
#1447–#1451 |
The five deferrals (Scope table) |
5/5/8/2/2 |
FU-A, FU-B ← C1; FU-D ← R1; non-KC ← N1; FU-C relates F1b only (needs-spec) |
The full edge set is wired in GitLab (verified 2026-08-11): the foundation chain F1a→F1b→F2→{F3,F4}, F3→R1→A1, {A1,F4}→S-tanf, the 14 sequential slice edges, F3→P1→P2 (+ the 8 portal-target slices→P2), P2→{P3,C1}, S-appeals→C1, R1→N1, C1→S6, {C1,P3,N1,S6}→T1, C1→{FU-A,FU-B}, R1→FU-D, N1→non-KC.
Caller manifest
Seeds the slices; each slice verifies and completes its own rows from F1a.
| Class | Callers | v1 treatment |
|---|---|---|
User-context edge |
canopy-web direct calls; eligibility orchestrator fan-out |
Exchanged tokens (R1 wiring; eligibility slice for hop-2) |
Nested hops under a user origin |
snap recompute → persons ( |
Stay service-class (FU-B); their target routes classify dual |
Background |
appeals workers/stay/reconcile (the |
Stay service-class (ADR-023 D4) |
Issue matrix
Common to all 34: epic &52, testable ACs in-body, DAG links wired. Milestone
T1 — Correctness except the five deferrals (T5 — New Features; new capability
beyond the ratified program, T5-last per the tier order). Lifecycle: F1a opened
workflow::ready (sole root); every other spine child workflow::blocked;
FU-C workflow::needs-spec.
| Issues | Type / priority | Extra labels |
|---|---|---|
F1a #1418, N1 #1444, non-KC #1451 |
|
|
F1b #1419, F2 #1420, F3 #1421, F4 #1422 |
|
|
R1 #1423 |
|
|
A1 #1424 |
|
|
Slices #1425–#1439 |
|
|
P1 #1440, P2 #1441, P3 #1442 |
|
|
C1 #1443 |
|
|
S6 #1445 |
|
|
T1 #1446 |
|
|
FU-A #1447, FU-B #1448 |
|
|
FU-C #1449 |
|
|
FU-D #1450 |
|
|
non-KC #1451 |
|
|
Relations swept 2026-08-11: #665 re-pointed (blocked by P3 #1442, which absorbs
it); #1008 relates F1b; #985/#874/#731/#1356 relate C1; #515 relates F1b
non-KC; #518 relates R1 + N1; #512/#514 relate non-KC.
Off-ramps
-
Hop-2 rejected by Keycloak (chained exchange is not explicitly documented — R1 proves or disproves it in the devstack): the orchestrator keeps service-token + actor for its fan-out; FU-B owns the fix; C1’s actor retirement stays conditional on what actually migrated.
-
Keycloak-without-exchange deployments: dedicated per-target service accounts everywhere (the portal pattern generalized), documented in N1.
Verification
This plan MR is docs-only; its verification is structural:
-
cargo xtask plan-lint— Status vocabulary clean. -
cargo xtask check-docs— sync/drift gate clean. -
Full pre-push battery (the sole functional gate; docs-only changes must not regress it).
-
Epic &52 re-fetch: 36 children (34 open after #546 closes); DAG link types spot-checked; #665 re-pointed; roadmap tracker updated with history untouched.
Program-level verification lives in the child issues: F4/S6 conformance matrix,
per-slice observation gates, and the R1 identity verify exchange probe.
Documentation Updates
-
This plan page (rewritten in this MR; per-slice updates ride the slices).
-
Roadmap — Phase E.9 tracker entry (this MR).
-
ADR-043 (F1b’s MR) + ADR index.
-
authorization-inventory.adoc(F1a’s MR, nav-linked). -
Per-service
api/canopy-*.adocpages — each slice updates its own. -
IdP integration (R1/N1) + the security.adoc
X-Service-Api-Keycorrection (N1). -
CHANGELOG.adoc— implementing MRs only (this MR ships no code or contract change).
References
-
ADR-023 (ratified by this plan, amended by ADR-043)
-
ADR-019 (service identity +
X-Canopy-Actor, amended) -
ADR-014 (audit chain the exchange events extend)
-
ADR-026 (opaque applicant sessions — why R2 replaced the citizen-exchange mechanism)
-
Maintainer ruling: #546 note 3666918785 (2026-08-10)
-
Keycloak 26.2+ standard token exchange (V2, GA); RFC 8693, RFC 6749, RFC 7009, RFC 8705
-
IRS Pub 1075 §9 / Pub 4812 §3.5; HIPAA 45 CFR §164.312(b)