Integrations cutover — INT-4 gateway route swap

RETIRED — 2026-09-04. Do not execute this procedure. The owner ruling of 2026-08-28 (“all microservices needs to autopublish no router configuration for microservices endpoints”) was executed by SPRINT_20260809_001 RAR-5/RAR-7 (81e276c2a3, 83bfc9359f, f4b1774cf6). The one route this swap replaces — ^/api/v1/secret-authority(.*) — is gone from both route tables, which now hold 22 routes total; Integrations is served by the auto-published layer. A static gateway entry out-ranks publication, so reinstating that row — by applying the swap, or by “reconciling” the manifest until -Check passes and then applying — would claw the plane back off the published layer: a regression, not a no-op. (Concretely today the scripts cannot do this on their own — with nothing to match they write nothing, then falsely report a successful apply and exit 0; see the window steps.)

The pass criterion below (“-Check exits 0 with 1 of 1 matched”) is unsatisfiable. Since ced8a2d286 the script refuses on both invocations: pwsh tools/scripts/int4-swap-integrations-routes.ps1 -Check and a bare run each report the swap as RETIRED and exit 2 (measured 2026-09-04 at HEAD). That is the correct end state, not drift to reconcile.

Do instead: verify the auto-published Integrations surfaces answer through the gateway (/api/integrations/v1/** authorized 2xx, unauthenticated 401/403). Model for this shape: offlinekit-cutover-ok6-route-swap.md.

Status: PREPARED, NOT APPLIED. Nothing in this document has been installed on any gateway. Per DC-19 a family’s routing change happens once, inside its own window — for this family that is INT-6 (SPRINT_20260722_024). The INT-4 deliverable is the manifest, the script, the guard test and this runbook.

Sources of truth, in order:

  • tools/scripts/int4-integrations-route-swap.json — the ONE definition of the swap.
  • tools/scripts/int4-swap-integrations-routes.ps1 — applies it.
  • src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/IntegrationsConsolidatedRouteConfigTests.cs — guards it, reading the same manifest.

If this page and the manifest disagree, the manifest wins and this page is the defect.

What the swap does

One consolidated group replaces the family’s single explicit route and adopts two surfaces that have no gateway route at all:

PathTypeTarget
New group^/api/integrations/v1(.*)Microservicehttp://integrations.stella-ops.local/api/integrations/v1$1
Replaced^/api/v1/secret-authority(.*)ReverseProxyhttp://integrations.stella-ops.local/api/v1/secret-authority$1
Adopted (no route today)/api/v1/integrations/**reached via the derived wildcard
Adopted (no route today)/api/v1/integrations/registries/**reached via the derived wildcard

Both gateway configs carry the identical spelling, and this family has zero compose-overlay entries — so unlike the OfflineKit sibling there is no second half to apply and no dual-spelling selector to maintain.

Read this before opening the window

Four things about this family differ from every sibling runbook on this shelf. Three of them change what you look at when something goes wrong.

1. The route table understates this family badly — one route is not the size of the swap

integrations-web’s primary external plane has no gateway route and never has. The integration hub CRUD (/api/v1/integrations, /{id}, /{id}/auto-scan, /{id}/test, /{id}/resolve-credentials, /{id}/discover, /{id}/health, /{id}/impact, /providers, /ai-code-guard/run) and the registry browser (/api/v1/integrations/registries, /{id}/images) all reach the host through the derived-hostname wildcard ^/api/v1/([^/]+)(.*) at index 155, which translates to http://$1.stella-ops.local/api/v1/$1$2.

That works only because the deployable is registered as integrations(src/Integrations/StellaOps.Integrations.WebService/Program.cs:526, AddRouterMicroservice(serviceName: "integrations")). There is nothing to grep in the route table, so anyone sizing this family from the gateway config will size it at one route.

The same class as the opsMemory (AAI-5), jobengineScheduler (JOB-5) and notify (NTF-5) findings — but for those it was one sub-path, and here it is the whole surface.

2. The namespace is SHARED with release-orchestrator, by sub-path

Three routes inside or beside /api/v1/integrations/** belong to other services, and two of them sit above the wildcard so they win:

IndexPathOwner
9^/api/v1/integrations/([0-9a-fA-F-]{36})/request-delete/?$release-orchestrator
140^/api/v1/integrations/registry-search(.*)release-orchestrator
106^/api/v2/integrations(.*)platform-web

Route 9 is a sub-verb carve-out: release-orchestrator owns request-delete on an integration id while integrations-web owns everything else about the same id. TopologyOwnershipRouteConfigTests already pins that it matches /{guid}/request-delete and does not match /{guid}/name, so the estate already knew this namespace is shared.

Consequences you act on:

3. Removal fails as a 503 from a service that does not exist — not a 404, not a silent 200

This is the part of the sibling runbooks that does not transfer.

Five siblings’ removed routes fell through to the bare /api catch-all, so platform-web answered a 404 for a plane it does not serve. OfflineKit had three routes fall to the StaticFiles route, which answers 200 with the SPA’s index.html.

Here, remove ^/api/v1/secret-authority(.*) with no successor in place and /api/v1/secret-authority/targets falls to the derived wildcard, which builds the service name out of the first path segment:

/api/v1/secret-authority/targets
  -> [154] ^/api/v1/([^/]+)(.*)
  => http://secret-authority.stella-ops.local/api/v1/secret-authority/targets

There is no secret-authority deployable anywhere in the estate. A Microservice route naming an unregistered service returns 503 — the gateway’s own code says so at src/Router/StellaOps.Gateway.WebService/Middleware/GatewayRoutes.cs:23.

So: if the Vault/secret-authority surface starts returning 503 naming an unknown microservice, the group is missing while the old route is gone. Do not go looking for a platform-web 404. Measured and pinned by ReplacedRouteRemoval_FallsToAServiceNameThatDoesNotExist.

The adopted surfaces behave differently and more kindly: after the swap /api/v1/integrations/** still resolves through the wildcard to integrations-web, which by then serves /api/integrations/v1/**. A stale caller therefore gets an honest 404 from the owning service. That is the softest stale-caller failure of the six families, and it means the Console flip and the host re-mount do not have to be simultaneous to avoid a wrong-service answer — only to avoid downtime.

4. The identity contract does NOT change — and the reason is not this family’s route

Every sibling manifest reasons from Program.cs:368-371 (Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex)) that a route’s own flag decides whether the Bearer reaches the host. The matched route is never consulted at request time. IdentityHeaderPolicyMiddleware.InvokeAsync calls

StripReservedHeaders(context, ShouldPreserveAuthHeaders(context.Request.Path));

— a path test against two prefix lists, taken before any route is matched. A route contributes only its Path string to JwtPassthroughPrefixes, at startup, and only if it is non-regex.

The bare /api catch-all (index 199) is non-regex and its PreserveAuthHeaders is absent, which StellaOpsRoute.cs:37 defaults to true. So /api is in JwtPassthroughPrefixes, and /api is in ApprovedAuthPassthroughPrefixes in both configs. Every /api/** request forwards Authorization and DPoP.

For this window that is good news: the old prefix, the wildcard-served prefix and the consolidated prefix are all under /api, so the identity contract is unchanged and no host work is owed before the window. But the mechanism belongs to a route this family does not own, holding a default it never declares — and /rekor already carries an explicit PreserveAuthHeaders: false in the deployed config, so these do get edited. ApiCatchAll_IsWhatForwardsTheBearer_NotThisFamilysOwnRoute drives the real middleware over the real config and will go red the day someone hardens that catch-all.

Preconditions (all must hold before the window)

  1. integrations-web serves the new paths natively. The group forwards the tail verbatim, and the host maps /api/v1/integrations, /api/v1/integrations/registries and /api/v1/secret-authority today — not /api/integrations/v1/**. Applying the swap before the re-mount yields a 404 farm, and the route tests stay green because they assert routing and not answers.
  2. The Console values flip in the same window. Three keys in src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.ts: integrations, integrationsRegistries, integrationsSecretAuthority. Do not flip integrationsAudit / integrationsInstanceAudit — see “Open items” below.
  3. bash tools/scripts/validate/check-console-integrations-prefix-literals.sh exits 0. A literal outside the registry survives the flip and breaks when the swap lands.
  4. The compose key still resolves the service name integrations. The adopted surfaces ride the derived wildcard until the moment the group replaces them; a rename before the window breaks the plane with nothing in the route table to show it.
  5. pwsh tools/scripts/int4-swap-integrations-routes.ps1 -Check exits 0 with 1 of 1 matched in each config and the ordering guard PASS. Unsatisfiable since RAR-5/7 — the script refuses and exits 2, by design. See the banner.

Procedure

Historical — steps 1-2 no longer run. Both invocations refuse and exit 2 since ced8a2d286. Until then a bare run printed Swap applied. Routes replaced: 0. Recreate the gateway so the deployed config reloads. and exited 0 — a false success line and a false exit code, fixed under SPRINT_20260904_001 RRS-1.

# 1. Rehearse. RETIRED: the script now reports the swap as retired and exits 2.
pwsh tools/scripts/int4-swap-integrations-routes.ps1 -Check

# 2. Apply. RETIRED: a bare run also refuses and exits 2, writing nothing.
# pwsh tools/scripts/int4-swap-integrations-routes.ps1

# 3. Prove config generation still works.
bash devops/docker/router-gateway-config-generate.sh   # expect exit 0

# 4. Guard.
pwsh ./tools/scripts/test-targeted-xunit.ps1 \
  -Project src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj \
  -Class "StellaOps.Gateway.WebService.Tests.Configuration.IntegrationsConsolidatedRouteConfigTests"

# 5. Console.
#    api-path-prefixes.ts: integrations -> '/api/integrations/v1'
#                          integrationsRegistries -> '/api/integrations/v1/registries'
#                          integrationsSecretAuthority -> '/api/integrations/v1/secret-authority'
bash tools/scripts/validate/check-console-integrations-prefix-literals.sh
cd src/Web/StellaOps.Web && npx ng build --configuration production

Regeneration

devops/compose/router-gateway-local.json is generated from src/Router/StellaOps.Gateway.WebService/appsettings.json + devops/compose/router-gateway-overlay.local.json. Regeneration is safe here and was verified during INT-4: run at HEAD on a clean tree it exits 0, emits 203 routes and produces a byte-identical file (git status --porcelain empty). Because the swap edits both configs, a later regeneration reproduces the consolidated group rather than reverting it.

The standing rule still applies: if a regeneration ever produces a diff you did not ask for — most importantly a https:// route coming back as http://refuse it and restore the override. Never “expect the diff”, and never hand-edit the generated file: an edit that lives only there is invisible to the sources and is silently reverted by the next regenerator, at exit 0, with a green suite.

What goes red, and why that is correct

IntegrationsConsolidatedRouteConfigTests is green on both sides of the window: 25 tests pass before it (old route live, no group) and 25 pass after it (group live, old route gone). What goes red is a half-applied swap:

SymptomWhat it means
Swap_IsEitherFullyPending_OrFullyApplied redone config swapped, the other not, or the route removed without the group
Swap_IsSymmetricAcrossBothRouteConfigs redthe deployed gateway and the service default now route differently
ConsolidatedGroup_MustOutrankThePlatformApiCatchAll redthe group was appended instead of inserted — platform-web now answers the whole Integrations plane
UnroutedHostSurfaces_…DerivedWildcard… redthe adopted surfaces stopped resolving through the wildcard, i.e. the primary plane moved and the manifest is stale
ForeignRoutesInsideTheNamespace_StillBelongToTheirOwners reda release-orchestrator or platform route inside this namespace was retargeted
Overlay_MustNotOverrideAnyReplacedRoute redan overlay entry appeared; the swap now needs a second half or the generator will exit 2 for every family
ApiCatchAll_IsWhatForwardsTheBearer… redthe /api catch-all was hardened; the Bearer no longer reaches this family on either prefix

Watcher coverage before this stage

Zero. No gateway test named ^/api/v1/secret-authority(.*), its target or its type, and the two adopted surfaces could not have had a watcher because they have no route to name. This was confirmed empirically rather than by grep: during INT-4 the swap was applied to both configs and the full gateway project ran 685/685 green — removing the family’s only route and installing the group broke nothing that existed.

For contrast: Findings had 6 watchers, JobEngine 8, Notify 2, Graph 0. This suite is the first coverage this family’s routing has ever had.

Forcing functions for the window

Route tests assert routing, not answers. Before calling the window done, exercise:

  1. Secret-authority read: GET /api/integrations/v1/secret-authority/targets with a Bearer — expect 200 and a populated target list, not merely a non-500. This is the only replaced route and the only one whose removal 503s.
  2. Integration hub list: GET /api/integrations/v1 — expect the tenant’s integrations. This is the adopted primary plane; a 404 here means the host re-mount did not happen.
  3. Registry browser images: GET /api/integrations/v1/registries/{id}/images against a registry with a realistic image count. This is the family’s largest response body, and the swap moves the secret-authority surface from a direct HTTP proxy to the Valkey router transport, where a non-streaming handler’s body returns as a single buffered frame. Size it, do not assume it.
  4. Release-orchestrator is untouched: GET /api/v1/integrations/registry-search?q=… must still reach release-orchestrator, and POST /api/v1/integrations/{guid}/request-delete must still reach it too. If either moved, the group captured a foreign surface.
  5. The token realm is untouched: GET /api/registry/token?service=registry.stella-ops.local must still reach registry-token. Pinned independently by RegistryTokenRealmRouteConfigTests; re-checked here because zot and every pulling agent are configured against that exact path.

Open items handed to INT-5 / INT-6

  1. The host re-mount is not written yet. integrations-web must serve /api/integrations/v1/**. That is INT-6’s, together with the Console value flip.
  2. Two Console paths are 404 farms and must NOT be given a successor. /api/v1/integrations/audit/events and /api/v1/integrations/{id}/audit are deprecated per-service fallbacks in audit-log.client.ts, and integrations-web maps no audit endpoint at all. They are centralised as integrationsAudit / integrationsInstanceAudit so the literals are not scattered; moving them onto the new plane would carry the defect across the window. Retire the Console surfaces, or leave them pointing at the old prefix where they already 404.
  3. /internal/crypto/** and /internal/plugins/** stay off the front door. platform-web calls the former service-to-service over a named HttpClient (HttpCrossServiceCryptoControlClient). At the gateway they fall to the StaticFiles route and answer 200 with index.html, which is a reason to keep them unrouted rather than to route them.
  4. D-INT4-4 still bites at INT-6, and it is not a routing problem — now STAGED, and it has a sibling. integrations-web had no ScannerStorage__* env, so its Scanner reads ride the ConnectionStrings:Default fallback; when INT-6 repoints that at stellaops_integrations the reads silently follow it to a database with no scanner.*. INT-5 measured the same shape on a second plane (D-INT7-1): the policy-decision reader’s chain ends in ?? connectionString, so policy.gate_decisions and shared.tenants follow the repoint too, and that reader has no null-provider branch to degrade into. Both keys are now staged explicitly in devops/compose/docker-compose.integrations-consolidated.yml (ScannerStorage__Postgres__ConnectionString, PolicyStorage__Postgres__ConnectionString, both pinned to STELLAOPS_POSTGRES_CONNECTION) and pinned by IntegrationsConsolidationConformanceTests. The window still owes the assertion side: make the INT-6 forcing function assert a populated severity field and a populated gate verdict rather than a 200. Deploy procedure: integrations-deploy-int5-staged-repoint.md.