Authority cutover — AUTH-5 gateway route change (issuer-directory fold)
Sprint: SPRINT_20260722_016 (Authority consolidation program) Task: AUTH-5 (prepared here) → applied in the AUTH-9 window Manifest: tools/scripts/auth5-issuer-directory-route-swap.json Script: tools/scripts/auth5-swap-issuer-directory-routes.ps1 Guard: src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/AuthorityIssuerDirectoryRouteConfigTests.cs Console guard: tools/scripts/validate/check-console-issuer-directory-prefix-literals.sh
Sibling precedent: Findings (5cf408bb25), JobEngine (112fff29d2), AdvisoryAI (4ee33800d4), Graph (43bd785c92), Notify (dff746edfc), OfflineKit (58cf840cda).
What the change does
One route. One field.
/issuer-directory ReverseProxy, non-regex, PreserveAuthHeaders: true
from http://issuerdirectory.stella-ops.local/issuer-directory
to <scheme+host of the /console route in the same file>/issuer-directory
Path, Type, IsRegex and PreserveAuthHeaders are unchanged. Nothing is removed. The Console needs no edit at all.
If that looks too small for a service consolidation, it is because this family’s difficulty is not in the routing — see What this route change does NOT do below.
Read this before opening the window
1. This family does not collapse, and must not be made to
Every sibling replaced N routes with one regex group. Authority’s routes are not a collapsible set. Four of them are OIDC protocol paths — /connect, /connect/revocation, /.well-known, /jwks — whose spellings are pinned by the issuer metadata document that every service, every token validator and every external client resolves. Renaming them under a group prefix breaks token issuance and discovery estate-wide, for every service at once, with no migration path. /console, /console/admin, /authority and /authority/console are under the same constraint on the SPA side.
issuer-directory contributes exactly one route, and every path its host serves already lives under that prefix (IssuerEndpoints.cs:18, IssuerKeyEndpoints.cs:17, IssuerTrustEndpoints.cs:16, OperatorSigningKeyEndpoints.cs:28,69,78, OperatorProviderChangeEndpoints.cs:18). EveryFoldedEndpoint_LivesUnderTheRetargetedPrefix re-checks that on every run, so a new endpoint group outside the prefix cannot quietly land a surface this one route would leave unrouted.
Recorded as D-AUTH5B-1.
2. The target scheme is DERIVED — do not replace it with a literal
The script reads the scheme and host from the /console route in the same file and joins it to /issuer-directory, rather than writing a literal. Keep it that way: it inherits any later flip of Authority’s scheme instead of silently diverging from its own host.
F-AUTH5-1 is ANSWERED (2026-08-11), and the premise was wrong — NEITHER surface is stale. The original finding read the compose and Dockerfile surfaces (docker-compose.stella-services.yml:492 gives the container ASPNETCORE_URLS: "http://+:8440" and nothing else; Dockerfile.hardened.template:136 bakes http://+:${APP_PORT} with no TLS endpoint; a sweep for https://+ finds only unrelated services) and concluded that nothing serves https://authority.stella-ops.local:443, while seven deployed routes target exactly that.
That sweep could not have found the answer, because the https listener is added in CODE, not configuration. TryAddStellaOpsLocalBinding("authority") (src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsLocalHostnameExtensions.cs:61) resolves {service}.stella-ops.local and binds BOTH port 443 (https) and port 80 (http) in addition to whatever ASPNETCORE_URLS asked for, presenting the Kestrel default certificate that compose does configure for this container (Kestrel__Certificates__Default__Path).
Measured live, read-only:
$ docker exec stellaops-authority ... # listening TCP ports
80 443 8440
$ curl -sk https://stella-ops.local/connect/token -d 'grant_type=password&client_id=stellaops-cli&...'
400 {"error":"invalid_request","error_description":"authority_provider parameter is required ..."}
An application-level 400 from Authority’s own OpenIddict — not a gateway 502/503 — proves the gateway’s https upstream reaches the service. The seven https routes are correct as they stand; nothing is owed here.
Read-across worth more than the finding: in this estate, never conclude “nothing serves this scheme” from a compose/Dockerfile sweep. Every host that calls TryAddStellaOpsLocalBinding adds 80 and 443 listeners that appear in no compose file, and the published-port list does not show them either (443 is reachable on the compose network without being published to the host).
3. The identity contract is PRESERVED — three “cleanups” would break it
The OfflineKit sibling’s central finding was that its swap inverted the identity contract: Program.cs:368-371 builds JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex), so collapsing non-regex passthrough routes into a regex group stops the Bearer being forwarded and the consolidated host must authenticate from the signed envelope before the window or every authenticated call 401s.
That trap applies to this route’s shape too. It is avoided by construction — the route keeps its path, its type, its non-regex-ness and its flag, so the folded endpoints keep the exact authentication path they have today (they read JWT scope claims through IssuerDirectoryPolicies + ScopeAuthorization, and Authority already receives forwarded Bearers on /console and /authority under the same mechanism).
Three edits would silently undo that, and each looks like tidying:
- turning the route into a regex group;
- dropping
PreserveAuthHeaders; - removing
/issuer-directoryfromApprovedAuthPassthroughPrefixesbecause “the issuer-directory service is gone”.
The fold retires the container, not the prefix. All three are pinned (RetargetedRoute_KeepsTheBearerPassthroughContract, ApprovedAuthPassthroughPrefixes_MustStillCarryTheIssuerPrefix), on all three surfaces including the overlay. Recorded as D-AUTH5B-2.
A fourth way, and it is the one a later stage will actually propose. The passthrough decision is taken from the request path, not the matched route, and the bare /api catch-all is non-regex with PreserveAuthHeaders absent — which defaults to true — so every /api/** path forwards Authorization regardless of which route serves it. So a “tidy” that moves this surface under /api/authority/v1/... keeps Bearer forwarding working, breaks no test, and silently swaps a specific, revocable grant for this one prefix into an inherited blanket grant covering every /api path in the estate. A specific entry can be removed to revoke forwarding for this surface alone; an inherited one cannot be removed without changing everything under /api at once — on the estate’s most privileged administrative surface, operator decision-signing keys. If a later stage wants that namespace it owes an explicit decision on the trade, not a path rename. NoOtherPrefixCoversThisPath_SoTheRoutesOwnPassthroughEntryIsLoadBearing is what goes red, with the reasoning in its message. (Mechanism established by the int5-repoints agent on SPRINT_20260722_024 INT-4, verified independently here.)
4. Removal fails as a silent 200
/issuer-directory does not start with /api, so it misses the bare /api catch-all (index 199) and falls to the StaticFiles route (index 200), which answers the SPA’s index.html with status 200. If the route is dropped instead of retargeted, /issuer-directory/operator-signing-keys/... returns 200 with an HTML body — the Console fails on parse, not on status, and an operator watching status codes sees a healthy plane.
This is why the script only ever rewrites a field and never deletes a route, and why the guard asserts the route exists before asserting anything about it.
5. Watcher coverage before AUTH-5 was ZERO
Counted, not assumed: no test under src/Router/__Tests named /issuer-directory or issuerdirectory.stella-ops.local. Retargeting or deleting this route broke nothing in CI. (Findings has 6 route tests, JobEngine 8, OfflineKit 21; Graph had 0, which is what GRA-5 found.) AuthorityIssuerDirectoryRouteConfigTests is the first watcher and it pins the pre-swap state as hard as the post-swap one, so it cannot pass by being vacuous. Recorded as D-AUTH5B-4.
What this route change does NOT do
The canonical transaction is in docs/runbooks/authority/authority-deploy-auth8-staged-fold.md. This table is a dependency summary, not a second cutover order:
| Order | Owner | What |
|---|---|---|
| 1 | AUTH-7 | The Authority host must map the folded endpoint groups from StellaOps.IssuerDirectory.Api (lifted byte-identically at AUTH-4, 23c0e34ec2) and register their DI, options binder, tenant resolver, scope policies and persistence runtime. |
| 2 | AUTH-8/9 | Enable the fold, then repoint every consumer that addresses the host directly — they do not use this gateway route. Source measurement found six SDK projects, while compose has three addressed services: platform (a seventh, non-SDK direct HttpClient), excititor-web, and excititor-worker. The four remaining SDK projects are dispositioned explicitly in the companion runbook (one inherited library target, two optional/unconfigured hosts, one inert library). All three live addresses resolve through AUTHORITY_ISSUER_DIRECTORY_DIRECT_BASE_ADDRESS, default-legacy for safe staging and set to Authority only in the window. |
| 3 | AUTH-9 | Re-measure and copy the four source issuer domain tables into stellaops_authority under row-count + spot-check parity. On this estate they are 0=0; the target’s fifth table is target-only operator-provider receipt state, not a copy source. |
| 4 | this script | The route change. |
| 5 | AUTH-8/9 | Run the smallest through-gateway forcing pair, then immediately stop stellaops-issuer-directory by name. Long forcing/soak work follows the stop. |
The data step is not optional ordering pedantry. Migration 024 created Authority’s issuer tables empty. Retargeting before the AUTH-9 copy points the Console’s enrollment surface at an empty directory: every operator signing key disappears from the UI behind a 200 and an empty list. That is worse than an outage because it reads as data loss.
Running it
# report only; exit 1 while anything is pending or drifted
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1 -Check
# apply
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1
# read-only rollback preflight, then rollback if the window aborts
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1 -Rollback -Check
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1 -Rollback
Idempotent in either direction: a repeated cutover or repeated rollback writes nothing.
The script refuses to write when:
- the route is missing from either config (a removal, not a retarget);
- the
/consoleanchor is missing or no longer targets Authority; - the route’s
Type/IsRegex/PreserveAuthHeadershave drifted from the identity contract the window preserves; ApprovedAuthPassthroughPrefixeshas lost/issuer-directory;- the overlay has grown an override naming this route;
- its own output would not re-parse, would change the route count, or would touch any route other than this one.
Both configs, or neither
devops/compose/router-gateway-local.json is generated from src/Router/StellaOps.Gateway.WebService/appsettings.json + devops/compose/router-gateway-overlay.local.json (devops/docker/router-gateway-config-generate.sh). The script edits both sources so a later regeneration reproduces the retarget rather than reverting it — this route has no overlay override, which is why the two files are byte-identical here and why regeneration is safe.
Swap_IsEitherFullyPending_OrFullyApplied catches a half-applied state. It is not theoretical: during the AUTH-5 rehearsal the appsettings.json write was reverted while router-gateway-local.json kept its change, and this assertion named it immediately. The cause was benign and is worth stating precisely, because it generalises: a peer (SPRINT_20260722_024 INT-4) was rehearsing their swap on the same two files at the same minute, and their restore-from-backup landed between this stage’s two writes.
Structural consequence, and it outlives both families: two agents can never rehearse gateway swaps concurrently. The guard suites read fixed repository paths, so proving the post-swap direction requires writing the real shared config files — there is no sandboxed copy to rehearse against. Tightening the apply → test → revert window into one command (which this stage did, and which the next family should) shrinks the exposure but cannot remove it. Before rehearsing, check that no other family is mid-window on src/Router/StellaOps.Gateway.WebService/appsettings.json or devops/compose/router-gateway-local.json; if one is, wait. Raised by the int5-repoints agent, who hit the mirror image of it from the other side.
Regeneration reproduces this swap; it does not revert it — measured, not assumed. The hazard is real in general: a value that exists only in the generated file is silently reverted by the next regeneration, at exit 0, with a green suite, and the reverter reads the diff as their own change. That is what happened to ^/catalog-changes/… and cost OK-6 a repair commit (631c5c087a). It does not apply here because the script edits the source too. Proven on 2026-08-05: the generator at HEAD on a clean tree reproduces router-gateway-local.json byte-identically (baseline determinism holds), and after applying the swap to both configs a regeneration reproduces the swapped file byte-identically, with "TranslatesTo": "https://authority.stella-ops.local/issuer-directory" intact.
No overlay entry is needed or wanted. The catalog-changes ruling was to make the https reproducible; this swap achieves that through the source, and adding an overlay override would create a second place the truth lives — the failure the ruling was about. Never hand-edit only router-gateway-local.json; run the script, which does both files or neither.
Do not verify this with grep -c
The naive count is asymmetric and every number is misleading, in both directions:
grep -c 'issuer-directory' src/Router/StellaOps.Gateway.WebService/appsettings.json -> 2
grep -c 'issuer-directory' devops/compose/router-gateway-local.json -> 3
Parsed, both files hold exactly one route object and one ApprovedAuthPassthroughPrefixes entry. The extra hits decompose differently, which is why neither number means what it looks like:
- one hit in each file is the auth-passthrough prefix, a legitimately separate occurrence that is not a route and must not be counted as one (nor deleted as a duplicate — see §3);
router-gateway-local.jsonhas one hit more thanappsettings.jsonpurely because it expands route objects across lines, puttingPathandTranslatesToon separate lines, where the service default keeps a route on one line.
So 2 vs 3 is the healthy state. An operator who reads it as drift will go looking for a duplicate route that does not exist, and an operator who “fixes” the asymmetry will delete the passthrough entry and silently strip the Bearer. Verify by parsing the Routes array and the ApprovedAuthPassthroughPrefixes array — which is what the guard suite does.
Do not carry this decomposition to another family — decompose theirs instead. The counts above are diagnostic of which structural case a family is in, and the two cases have opposite correct actions. One lookup settles it: is the prefix in Gateway:Auth:ApprovedAuthPassthroughPrefixes?
| Present (this family) | Absent (e.g. Integrations) | |
|---|---|---|
| What forwards the Bearer | the family’s own entry — load-bearing (§3) | the bare /api catch-all |
| Part of the grep count | one hit is the passthrough entry, not a route | 100% is route-object formatting |
| “Normalising” the count | deletes the entry, strips the Bearer | harmless but pointless |
/issuer-directory is present, alongside /connect, /console, /authority, /doctor, /api, /policy/shadow, /policy/simulations (plus /export-center in the deployed config only). Neither integrations nor secret-authority appears in either config — verified — which is why that family’s identical-looking asymmetry has the opposite diagnosis. Trap surfaced by the int5-repoints agent on /api/v1/secret-authority; the generalisation is theirs, corrected after this family’s numbers contradicted their first form.
Verifying after the window
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1 -Check→ “already retargeted”, both configs.- The guard suite green:
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj -Class "*AuthorityIssuerDirectoryRouteConfigTests*" - Authenticated forcing call, not a bare probe — the whole point of this route is that it forwards a Bearer:
GET /issuer-directory/operator-signing-keys/{issuerId}/keyswith a token carryingauthority:signing-keys.enrollmust return the operator’s keys. A 401 means the passthrough contract broke (see §3). A 200 with an HTML body means the route is gone, not retargeted (see §4). A 404 meansISSUERDIRECTORY__FOLD__ENABLEDis still false, so the retarget landed on a host that maps nothing (AUTH-5/D-AUTH7-5 — see the ordering table inauthority-deploy-auth8-staged-fold.md). A 200 with an empty list means AUTH-9’s copy did not land — except on this estate, where the source is empty (measured 2026-08-11: 0 rows in all fourissuertables instellaops_platform), so an empty list is the correct answer here and the forcing signal has to come from a write instead: create a deterministic, no-key issuer through the folded surface, read it back, and delete it throughDELETE /issuer-directory/issuers/{id}before stopping the legacy container. The active issuer row must not survive. Keep the target-only create/deleteissuer.auditrows as window evidence; never SQL-delete or copy them back. Pre-swap baseline for comparison, measured 2026-08-11 through the gateway: unauthenticatedGET /issuer-directory/issuersanswers403(the legacy container). Post-swap it must still be a 4xx from the folded surface and never a 404. - Console: load the decision-signing enrollment panel and confirm the existing key list renders — it is the only Console consumer of this plane.
bash tools/scripts/validate/check-console-issuer-directory-prefix-literals.sh→ CLEAN.
Rollback
Keep the maintenance write fence. Clean an active issuer canary through its owning API. The tenant forcing procedure must already have restored the exact captured canonical and legacy-shared business values through PATCH; never delete or tombstone that tenant. Retain the immutable audit/outbox history. Then:
docker start stellaops-issuer-directory; wait for healthy and prove the legacy direct endpoint answers.- Run
pwsh tools/scripts/auth5-swap-issuer-directory-routes.ps1 -Rollback -Check, then the same command without-Check. It changes both configs or neither and restores the manifest-pinned legacy target. - Set
AUTHORITY_ISSUER_DIRECTORY_DIRECT_BASE_ADDRESSback tohttp://issuerdirectory.stella-ops.local(or unset it), recreateplatform,excititor-web, andexcititor-workeragainst their own recorded compose chains, pluspolicy-engineandrelease-orchestratorwhen the recorded chain includes the opt-in operator-signing proof overlay. Then prove none still targets Authority for this client. - Recreate Authority with
AUTHORITY_ISSUER_DIRECTORY_FOLD_ENABLED=falseand the protected pre-window connection. Only then may Database Operations run the owner-role rollback in the companion runbook.
Because the route path, client SDK and scopes never changed, no consumer code or scope-catalog rollback is needed. Do not hand-edit the two route files.
