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 byok6-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-Checkonly, and-Checkwas the only path anyone had measured. A BARE run ofok6-swap-offlinekit-routes.ps1printedSwap applied. Legacy routes removed: 0and exited 0 on a tree where nothing matched — this script was itself an instance of the zero-work-reports-success defect thatSPRINT_20260904_001RRS-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.-Checkkeeps 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_offlinekitconverged), and giving the host the router transport configuration it lacked — specificallyRouter__Frame__Envelope__ActiveKeyIdand__Keys__primary, without which it registered but had noIFrameEnvelopeVerifier, so its endpoints never became routable while it looked correctly registered. Verified live:TargetService=offlinekit, withGET /status204,GET /manifest204,POST /validate200 andPOST /import400 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-partquarantined -> staged -> activated|failedstate. Carrier-levelverifiedcustody and aggregatecontentActivated: falsedo 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 spelling | Successor |
|---|---|---|---|
| 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(.*) | same | none — 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:
/export-centerand/export-center/v1/exports/v1/audit-bundles— a live Console surface (audit-bundles.client.ts)
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-Typeand the responding service — atext/htmlbody on a/v1/**path means the route is gone and the SPA fallback answered.
Preconditions (all must hold before the window)
| # | Precondition | Verify |
|---|---|---|
| P1 | offlinekit-web serves the consolidated paths natively | Source 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. |
| P2 | The host REGISTERS as ServiceName offlinekit | Source 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. |
| P3 | The container carries the offlinekit.stella-ops.local alias | OK-9 compose work. |
| P4 | The host reads identity from the signed envelope | See §2 above. |
| P5 | The Console flip is staged | RAR-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. |
| P6 | Server-returned urls match the active base | The 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”:
- Seven orphaned override keys left by the G1 cutover (
1d30d87222) made the generator exit 2 for everyone, not just this family. Deleted, with the G1 window executor’s confirmation that all seven routes were deliberately removed. - The Authority catalog-changes route was a silent security downgrade.
^/catalog-changes/(tenants|tenant_lifecycle)(.*)ishttps://in the deployed config andhttp://inappsettings.json— born divergent atf2510d1e22. A regeneration downgraded a working Authority route to plain HTTP at exit 0 with a green suite, because the route tests assert routing, not schemes. Repaired by adding the missingRouteTranslateToOverrideshttps entry, matching the pattern its siblings^/api/v1/trust(.*)and^/api/v1/tenants(.*)already use. - Sixteen duplicated keys in
RouteTranslateToOverrides(benign only while the copies agreed — verified they did, then deduped).
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_SoRegenerationCannotDowngradeItpins 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:
- Watched (4 of 6): the four export-center routes — but only in their deployed spelling, and only in
router-gateway-local.json. Nothing has ever asserted theappsettings.jsonregex forms or theirTranslatesTo. - Unwatched (2 of 6):
^/api/v1/export(.*)and^/v1/audit-bundles(.*). - Not in the shared table:
GatewayRouteSearchMappingsTests.RequiredMappings— the file five sibling programs contend over — contains none of this family’s routes. No edit is owed there and no merge contention arises from it. This is the one sibling hazard that does not apply. - Not watchers:
GatewayOptionsValidatorTests:317andAspNetRouterRequestDispatcherTests:376mention/v1/audit-bundlesbut build synthetic in-memoryStellaOpsRoutefixtures and read no config file. Do not count them.
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:
- 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. - A realistically sized bundle download (
/api/offlinekit/v1/exports/runs/{runId}/artifacts/{artifactId}/downloadand/api/offlinekit/v1/audit-bundles/{bundleId}/download). The four export-center routes areReverseProxytoday and becomeMicroservice: a non-streaming handler’s body comes back as a SINGLEFrameType.Responsewhose 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. - The SSE stream at
/api/offlinekit/v1/exports/runs/{runId}/events, re-proven over the router transport. - A Console bundle download from the UI, which is the only thing that exercises
resolveServiceRelativeUrlagainst the real emitted URLs (P6). - Ownership replacement controls: canonical
/api/offlinekit/v1/{status,manifest,validate}answers from OfflineKit, while both/api/offline-kit/statusand/api/v1/offline-kit/statusreturn 404. Current acceptance must showlatestVerifiedCarrier,custodyStatus: "verified", aggregatecontentActivated: false,manifestArtifacts, andparts[], with nocurrent/components/manifestComponents/importedAtcompatibility 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 distinguishstagedfromactivated/failedand 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
| ID | Item |
|---|---|
| F-OK6-1 | RESOLVED, 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-1A | LIVE-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-2 | offlinekit-web must read identity from the signed envelope before the swap (§2). |
| F-OK6-3 | features/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-4 | src/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-5 | export-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-6 | RESOLVED 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. |
