OfflineKit cutover — OK-6 gateway route swap

EXECUTED AND SUPERSEDED — 2026-09-04. This procedure is history, not a plan. RAR-5’s owner ruling of 2026-08-28 retired the static canonical group because OfflineKit auto-publishes this surface, and the five legacy ExportCenter routes went with the same realignment. There is no route left to swap: the intended end state is zero canonical groups and zero legacy routes, recorded in retired-routes.json, enforced by the guard, and now also understood by ok6-swap-offlinekit-routes.ps1 (which previously called that state DRIFT and refused to run — the two assets contradicted each other until this date).

Correction, 2026-09-04 (ced8a2d286). That sentence was true of -Check only, and -Check was the only path anyone had measured. A BARE run of ok6-swap-offlinekit-routes.ps1 printed Swap applied. Legacy routes removed: 0 and exited 0 on a tree where nothing matched — this script was itself an instance of the zero-work-reports-success defect that SPRINT_20260904_001 RRS-1 exists to close, while other pages were citing it as the exemplar. It now exits 3 on that path (“zero work, target state positively confirmed”), deliberately not 0, so a caller keyed on the exit code cannot read confirmation as a completed cutover. -Check keeps its exit-0 contract. Regression check: tools/scripts/validate/check-swap-script-exit-contract.sh.

What actually made the plane work, in the owner-granted window of 2026-09-04: deploying OfflineKit (offlinekit-web + offlinekit-worker, stellaops_offlinekit converged), and giving the host the router transport configuration it lacked — specifically Router__Frame__Envelope__ActiveKeyId and __Keys__primary, without which it registered but had no IFrameEnvelopeVerifier, so its endpoints never became routable while it looked correctly registered. Verified live: TargetService=offlinekit, with GET /status 204, GET /manifest 204, POST /validate 200 and POST /import 400 problem+json — real domain responses, no static route anywhere.

Read the rest for the reasoning that shaped the design; do not execute it.

Sprint: SPRINT_20260722_025 (OfflineKit consolidation program) Task: OK-6 (prepared here) → applied in the OK-9/OK-10 window Manifest: tools/scripts/ok6-offlinekit-route-swap.json Script: tools/scripts/ok6-swap-offlinekit-routes.ps1 Guard: src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/OfflineKitConsolidatedRouteConfigTests.cs Console guard: tools/scripts/validate/check-console-offlinekit-prefix-literals.sh

Q-25 update (owner 2026-08-27; no live action in this settlement). OfflineKit is the sole canonical HTTP import/status/manifest/validate owner. The coordinated repository source cut must delete both Scanner generic route groups, repoint CLI/Console/gateway callers to /api/offlinekit/v1, and update this runbook’s manifest/guard consumers before the procedure is used. OfflineKit owns carrier verification, quarantine/CAS, lifecycle and its import ledger; Vulnerabilities will activate vulnerability/VEX parts. Current source extracts and verifies the exact Scanner artifact-BOM part, calls Scanner’s bounded owner API, and records per-part quarantined -> staged -> activated|failed state. Carrier-level verified custody and aggregate contentActivated: false do not imply whole-bundle activation. Generic dispatch, Vulnerabilities/VEX activation and live acceptance remain open. Do not run the pre-Q-25 route manifest/guard unchanged.

Sibling precedent: Findings (5cf408bb25), JobEngine (112fff29d2), AdvisoryAI (4ee33800d4), Graph (43bd785c92), Notify (dff746edfc).


What the swap does

Six gateway routes collapse into one group:

^/api/offlinekit/v1(.*)  ->  http://offlinekit.stella-ops.local/api/offlinekit/v1$1
#Replaced route (service default)Deployed spellingSuccessor
1`^/api/export-center/v1/exports(?=/$)(.*)`/api/export-center/v1/exports (non-regex)
2`^/api/export-center(?=/$)(.*)`/api/export-center (non-regex)
3`^/export-center/v1/exports(?=/$)(.*)`/export-center/v1/exports (non-regex)
4`^/export-center(?=/$)(.*)`/export-center (non-regex)
5^/api/v1/export(.*)samenone — dies
6^/v1/audit-bundles(.*)same/api/offlinekit/v1/audit-bundles

Three host groups that have no route at all today also gain one: /v1/incidents, /v1/promotions, /v1/risk-bundles/api/offlinekit/v1/{incidents,promotions,risk-bundles}.


Read this before opening the window

Four things about this family have no sibling precedent. Each one is a way to run a correct-looking swap and still take the plane down.

1. The canonical group is offlinekit; Scanner’s generic groups retire

At the 2026-08-05 baseline, OK-6’s literal /api/offline-kit/* namespace belonged to Scanner: OfflineKitEndpoints.cs mapped both /api/offline-kit and /api/v1/offline-kit, and the gateway routed the v1 path to Scanner. That collision is why the prepared successor used /api/offlinekit/v1 and why F-OK6-1 existed.

Q-25 resolves F-OK6-1 in favor of OfflineKit. Keep the unhyphenated canonical spelling; delete both Scanner groups and their gateway/Console callers in the source cut. Do not retain the old paths as aliases. The separate CLI-local stella airgap import / MirrorBundleImportService path is not this HTTP plane; OK-7 owns its convergence or explicit disposition.

2. The swap CHANGES the identity contract — every sibling’s swap did not

Every previous manifest concluded PreserveAuthHeaders was inert, because Program.cs:368-371 builds JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex) and all their replaced routes were regex.

This family is the exception. In the deployed config the four export-center routes are IsRegex: false with PreserveAuthHeaders: true, so they DO enter the passthrough list and the Bearer IS forwarded to export-web today. The deployed ApprovedAuthPassthroughPrefixes even carries a ninth entry the service default lacks — /export-center — added by the compose overlay.

The consolidated group is regex, so it strips Authorization. Before the window, offlinekit-web must authenticate from the signed identity envelope.

If you skip this, every authenticated export call 401s the moment the swap lands. Not a degradation — a total, immediate outage of the family’s live surface. Tracked as F-OK6-2.

3. The overlay is a hard dependency, and nothing in the route table shows it

devops/compose/router-gateway-local.json is generated from appsettings.json + devops/compose/router-gateway-overlay.local.json. The overlay keys ten per-route overrides (six RouteTranslateToOverrides, four RouteFieldOverrides) by the appsettings regex path, and devops/docker/router-gateway-config-generate.sh:88-93 exits 2 when a key matches no source route.

Remove the six routes without removing the ten keys and the next person to regenerate the gateway config hits a hard failure — after your window, in their change. The swap script does both halves; do not pass -SkipOverlay in a real window.

The overlay is also why the two configs disagree about what these routes are called: four of the six are regex in the service default and non-regex literals in the deployed file. The manifest carries both spellings (path / localPath) and the script matches either.

4. Removal fails as a SILENT 200, on a live surface

Three of the six do not start with /api, so they miss the bare /api catch-all and fall to the StaticFiles route — which answers 200 OK with the SPA’s index.html:

The other shapes: the two /api/export-center routes fall to /api → platform-web (a 404 from the wrong service); ^/api/v1/export(.*) falls to ^/api/v1/([^/]+)(.*)http://export.stella-ops.local → 503 for a container that has never existed.

Operator rule for this family: do not discriminate on status code. Half the failure modes return 200. Check Content-Type and the responding service — a text/html body on a /v1/** path means the route is gone and the SPA fallback answered.


Preconditions (all must hold before the window)

#PreconditionVerify
P1offlinekit-web serves the consolidated paths nativelySource maps /api/offlinekit/v1/{import,status,manifest,validate}, /health/ready, and /doctor/offlinekit/checks. The window must prove those exact routes on the candidate image; see hostMustServeTheseNatively in the manifest.
P2The host REGISTERS as ServiceName offlinekitSource calls config-gated AddRouterMicroservice as offlinekit, and the doctor reports whether registration occurred. The window must enable the Router section and prove HELLO; without it the whole plane 503s.
P3The container carries the offlinekit.stella-ops.local aliasOK-9 compose work.
P4The host reads identity from the signed envelopeSee §2 above.
P5The Console flip is stagedRAR-6 first moves exportCenter from the rewriting alias to ExportCenter’s published /v1/exports; that source is not live until its Console build is deployed. The later OK window moves it again to /api/offlinekit/v1/exports, moves offlinekitAuditBundles to /api/offlinekit/v1/audit-bundles, and keeps the canonical /api/offlinekit/v1 status/manifest client.
P6Server-returned urls match the active baseThe deployed standalone ExportCenter already emits /v1/exports/... for bundleDownloadUrl, signedBundleUrl, exportUrl, artifact.downloadUrl, and runEndpoint. Before the later OfflineKit ownership flip, verify the replacement host emits urls beneath /api/offlinekit/v1/exports; otherwise resolveServiceRelativeUrl concatenates two origins and creates a silent 404.

P7 - legacy Scanner state disposition

Before recreating Scanner or OfflineKit, inventory <scanner ContentRoot>/data/offline-kit on every Scanner instance. Record tenant, file count, byte count and SHA-256 for every file. The flagship compose has no /app/data volume, so a recreate can erase this state. For every non-empty inventory, either export it into a producer-compatible signed bundle and import it through the canonical OfflineKit API after the DSSE producer gap closes, or record an owner-attended accepted discard. Record a zero-file result too. Do not copy these files directly into OfflineKit CAS and do not write its ledger/database. The recorded inventory and disposition are a blocking live-window gate; this repository stage performs no automatic migration.


Procedure

# 1. Rehearse. Reports per-file drift; writes nothing; exit 1 on drift.
pwsh tools/scripts/ok6-swap-offlinekit-routes.ps1 -Check

# 2. Apply (routes in both configs + the overlay's ten keys + the dead
#    /export-center auth passthrough prefix).
pwsh tools/scripts/ok6-swap-offlinekit-routes.ps1

# 3. Prove config generation still works — this is the check the overlay half exists for.
bash devops/docker/router-gateway-config-generate.sh   # must 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 "*OfflineKitConsolidatedRouteConfigTests*"

# 5. Console.
bash tools/scripts/validate/check-console-offlinekit-prefix-literals.sh

Before any Scanner/OfflineKit recreate, satisfy P7 and attach its inventory/disposition receipt to the window evidence. Then recreate the gateway so the deployed config reloads.

The script is idempotent — a second run reports 0 of 6 and ALREADY PRESENT, and writes nothing.

Regeneration: now available, with one thing you must REFUSE

Both reasons this runbook originally gave for avoiding regeneration are fixed (2026-08-05, cross-program cleanup commit — see F-OK6-6). router-gateway-config-generate.sh now exits 0 and emits a file byte-identical to the committed router-gateway-local.json across all 203 routes, so step 3 passes and either path is safe: regenerate, or let the swap script edit the file directly.

What was wrong, and why it mattered more than “drift”:

Standing rule: if a regeneration ever produces that scheme flip again, REFUSE it. Do not commit it and do not “expect the diff” — restore the override. The generated file is never edited by hand; the overlay is the only correct place to fix a divergence.

AuthorityCatalogChangesRoute_KeepsItsHttpsOverride_SoRegenerationCannotDowngradeIt pins this, verified red by removing the override.


What goes red, and why that is correct

src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/ExportCenterRouteConfigTests.cs — specifically LocalComposeRouteTable_ExportCenterRoutesAreLiteralAuthPreservingPrefixes.

It pins the four deployed literals (existence, ReverseProxy, IsRegex == false, PreserveAuthHeaders == true, TranslatesTo with :8080, and the ordering of the full path before the compat path) and that ApprovedAuthPassthroughPrefixes contains /api and /export-center. Every one of those fails after the swap.

Retire the whole file. OfflineKitConsolidatedRouteConfigTests covers strictly more: both configs (the old one reads only the compose file), all six routes (the old one watches four), probe-path resolution, ordering against the /api catch-all, and the overlay coupling.

Historical rehearsal 2026-08-05 against copies of both configs: the six-route export swap applied cleanly and left Scanner’s /api/v1/offline-kit route untouched. Q-25 invalidates that negative control. Rehearse again after the source/manifest/guard update and require both Scanner paths absent plus the canonical OfflineKit group present before opening the live window.

Watcher coverage before this stage

Counted rather than assumed, because “no test went red” and “nothing was watching” look identical from a rehearsal:

So for the two unwatched routes — and for the appsettings spelling of all six — OfflineKitConsolidatedRouteConfigTests is the first watcher they have ever had.


Forcing functions for the window

Not “the container is up”. Prove the plane:

  1. A compliance export run end-to-end through /api/offlinekit/v1/exports, with an authenticated session — this is what proves P4 (envelope identity), not just routing.
  2. A realistically sized bundle download (/api/offlinekit/v1/exports/runs/{runId}/artifacts/{artifactId}/download and /api/offlinekit/v1/audit-bundles/{bundleId}/download). The four export-center routes are ReverseProxy today and become Microservice: a non-streaming handler’s body comes back as a SINGLE FrameType.Response whose whole payload is one message, where a ReverseProxy route proxied the same bytes as an HTTP stream. Bundle downloads are the largest bodies in the estate by design, so this is the transport change’s real test.
  3. The SSE stream at /api/offlinekit/v1/exports/runs/{runId}/events, re-proven over the router transport.
  4. A Console bundle download from the UI, which is the only thing that exercises resolveServiceRelativeUrl against the real emitted URLs (P6).
  5. Ownership replacement controls: canonical /api/offlinekit/v1/{status,manifest,validate} answers from OfflineKit, while both /api/offline-kit/status and /api/v1/offline-kit/status return 404. Current acceptance must show latestVerifiedCarrier, custodyStatus: "verified", aggregate contentActivated: false, manifestArtifacts, and parts[], with no current/components/manifestComponents/importedAt compatibility fields; it must not infer activation from manifest inventory. The manifest response must be byte-for-byte the verified raw JSON. Nullable carrier metadata must remain null when the signed manifest omits it. The Scanner-part forcing function must distinguish staged from activated/failed and exercise one exact Scanner artifact-BOM part through Scanner’s owner API. Extend the same proof to one Vulnerabilities/VEX part when that still-pending owner adapter lands.

Resolved and remaining items handed to OK-7 / later stages

IDItem
F-OK6-1RESOLVED, owner 2026-08-27 (Q-25): OfflineKit owns canonical HTTP import/status/manifest/validate plus carrier/quarantine/CAS/lifecycle/import-ledger state. Both Scanner groups retire in the repository cut. Current API status keeps carrier-level verified custody separate from parts[]; the Scanner artifact-BOM adapter and per-part lifecycle are implemented. Vulnerabilities/VEX activation and live evidence remain OK-7/OK-10 work.
F-OK6-1ALIVE-WINDOW GATE: inventory every legacy Scanner <ContentRoot>/data/offline-kit tree and record export/re-import, accepted discard or a measured zero-file result before recreation (P7). There is no automatic state migration and direct CAS/database copying is prohibited.
F-OK6-2offlinekit-web must read identity from the signed envelope before the swap (§2).
F-OK6-3features/lineage/services/audit-pack.service.ts:15 calls /api/v1/export/**, which has no backend anywhere. Retire the Console surface; do not invent a registry key for a route that dies without a successor.
F-OK6-4src/tests/audit_bundle/audit-bundles.client.contract.spec.ts pins base /api/exportcenter — a spelling in no route table and no config — while production wires the bare gateway root. Neither matches the other.
F-OK6-5export-web maps /api/v1/lineage but gateway route 46 sends /api/v1/lineage(.*) to sbomservice. Those endpoints have been unreachable for as long as that route existed. Decide: re-home under /api/offlinekit/v1/lineage, or retire as dead wiring.
F-OK6-6RESOLVED 2026-08-05. router-gateway-config-generate.sh exited 2 on seven orphaned overlay keys left by the G1 cutover, blocking regeneration estate-wide. Cleared in a separate cross-program cleanup commit together with the catalog-changes https override and a 16-key dedupe; generator now exits 0 and reproduces the committed file byte-for-byte. The overlay guard is correspondingly widened from family-scoped to estate-wide.