JOB-9 window — JobEngine consolidated route swap

Current routing boundary (2026-08-31): this is the future consolidated-owner proposal, not the RAR-6/7 native-consumer procedure. Console32907 is deployed and its native /api/v1/packs and /api/v1/scheduler browser reads pass. RAR-7 owns retirement of their old Pack Registry and /scheduler aliases, with separate conformance and live POST gates. Scheduler/PacksRegistry hosts remain the current owners; no data or host cutover is implied. Read rar6NativeConsumerRepoint in the manifest before applying any historical replaced set.

Update 2026-09-04 — the historical swap is RETIRED, not merely deferred. RAR-5/RAR-7 (81e276c2a3, 83bfc9359f, f4b1774cf6) executed the 2026-08-28 owner ruling (“all microservices needs to autopublish no router configuration for microservices endpoints”). All ten rows in job5-jobengine-route-swap.json are gone from both route tables, which now hold 22 routes total. Since ced8a2d286 pwsh tools/scripts/job5-swap-jobengine-routes.ps1 refuses on both invocations — -Check and a bare run each report the swap as RETIRED and exit 2, measured 2026-09-04 at HEAD. Step 2’s 10 of 10 expectation is unsatisfiable and step 3 no longer runs. A static entry out-ranks publication, so reinstating those ten rows — by applying the swap, or by “reconciling” the manifest until -Check passes and then applying — would claw the JobEngine plane back off the auto-published layer. Until ced8a2d286 a bare run wrote nothing but still reported Swap applied. Routes replaced: 0 and exited 0; that false success signal is fixed under SPRINT_20260904_001 RRS-1. Do instead: confirm the auto-published layer serves the native JobEngine/Scheduler/Packs surfaces through the gateway (authorized 2xx, unauthenticated 401/403). Model for this shape: offlinekit-cutover-ok6-route-swap.md.

Sprint: SPRINT_20260722_012 (JOB-5 prepares, JOB-9 applies). Manifest: tools/scripts/job5-jobengine-route-swap.json — the single source for the route set, read by both the swap script and the guard test. Script: tools/scripts/job5-swap-jobengine-routes.ps1. Guard: src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/JobEngineConsolidatedRouteConfigTests.cs.

Nothing in JOB-5 is installed live. scheduler-web and packsregistry-web keep serving until this window opens; the deliverable is the manifest, the script, the guard and this page.

Do not open this window until the consolidated host actually serves the consolidated paths. See “The 404 farm” below — this is the one prerequisite that is not a route-table fact and the one most likely to be assumed.

What changes

Ten routes across two host names collapse into one group:

^/api/jobengine/v1(.*)  ->  http://jobengine.stella-ops.local/api/jobengine/v1$1
Replaced routeCurrent hostNew external prefix
^/api/v1/scheduler/doctor(.*)scheduler/api/jobengine/v1/scheduler/doctor
^/api/v1/jobengine/registry/packs(.*)packsregistry/api/jobengine/v1/registry/packs
^/api/v1/jobengine/jobs(.*)scheduler/api/jobengine/v1/jobs
^/api/v1/jobengine/dag(.*)scheduler/api/jobengine/v1/dag
^/api/v1/mirrors(.*)packsregistry/api/jobengine/v1/mirrors
^/api/v1/compliance(.*)packsregistry/api/jobengine/v1/compliance
^/api/v1/jobs(.*)scheduler/api/jobengine/v1/jobs
^/api/v1/scheduler/jobs(.*)scheduler/api/jobengine/v1/jobs
^/scheduler(?=/|$)(.*)scheduler(none — removed)
^/api/scheduler(.*)scheduler(none — dead route, removed)

Route count per config: 203 → 194 (ten removed, one added).

Three facts that are not obvious from the config

1. Ordering is load-bearing — the target namespace is already occupied

^/api/jobengine(.*) (index 175 in both configs) belongs to ReleaseOrchestrator, and it captures /api/jobengine/v1/*. Simulated against the live table, /api/jobengine/v1/schedules resolves today to release-orchestrator. StellaOpsRouteResolver.Resolve is a strict first-match over declaration order, so appending the consolidated group hands this whole plane to the wrong service — and release-orchestrator answers 404/500 rather than failing visibly.

The script inserts the group at the position of the first route it replaces (index 85) and re-reads its own output to prove the group outranks index 175 before writing. It throws rather than write a table where it does not.

2. Most of the family’s routing is IMPLICIT, and no route list shows it

Eight of scheduler-web’s ten endpoint groups have no gateway route at all. /api/v1/scheduler/{schedules,runs,workers,failure-signatures,policy/runs, policies/simulations,vuln/resolver,audit} reach the service only through

^/api/v1/([^/]+)(.*)  ->  http://$1.stella-ops.local/api/v1/$1$2

which derives the microservice hostname from the first path segment. It works purely because the deployable registers as scheduler (StellaOps.Scheduler.WebService/Program.cs:391). Rename it to jobengine and every one of those breaks, with nothing in the route table to have warned you.

This is why the removal set in the manifest is ten routes plus an impliedRoutes block, and why ImpliedRoutes_AreServedOnlyByTheWildcard_AndTheGroupMustCoverThem asserts both halves. Schedule CRUD and run management — the family’s largest Console surface — are in this set.

3. PreserveAuthHeaders is inert on these routes

Program.cs:368-370 builds JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex). All ten replaced routes are regex, so none can enter the passthrough list and Authorization is stripped on every one of them. Four of the ten set the flag anyway and it changes nothing. Identity reaches the consolidated host through the signed identity envelope, exactly as it reaches scheduler-web and packsregistry-web today. The flag is carried on the group only so a future non-regex split inherits the intent.

The 404 farm — the prerequisite this window must not assume

The group forwards the tail verbatim, so the consolidated host must serve /api/jobengine/v1/** natively. It does not yet. The S4 move was byte-identical, so every carried MapGroup still hardcodes its old prefix — e.g. ResolverJobEndpointExtensions.cs:17 maps /api/v1/scheduler/vuln/resolver.

Re-mapping those groups is JOB-7/JOB-9 host-wiring work, not JOB-5’s. Applying the swap before it lands produces a uniform 404 farm, not a routing error, and the route tests will still be green — they assert routing, not that anyone answers.

Also owed at wiring time: ResolverJobEndpointExtensions.CreateJobAsync returns Results.Created("/api/v1/scheduler/vuln/resolver/jobs/{id}") — an absolute internal path in the Location header. The gateway rewrites request paths, not response headers, so post-swap that Location points at a path the gateway no longer routes.

Window steps

  1. Confirm the prerequisite. The consolidated host serves /api/jobengine/v1/**. Not “the image builds” — a real request answered.
  2. Dry run, from a clean tree: pwsh tools/scripts/job5-swap-jobengine-routes.ps1 -Check Expect 10 of 10 per config and RESULT: manifest and live route configs agree. Retired — the script now refuses and exits 2. Do not reconcile the manifest to make it pass.
  3. Apply: pwsh tools/scripts/job5-swap-jobengine-routes.ps1A bare run also refuses and exits 2, writing nothing. Until ced8a2d286 it wrote nothing but still printed Swap applied. Routes replaced: 0. Recreate the gateway so the deployed config reloads. and exited 0 — a false success signal, fixed under SPRINT_20260904_001 RRS-1. The remaining regression risk is reinstating the ten manifest rows by some other route — a static entry out-ranks publication.
  4. Update the four route tests that go red (measured, see below).
  5. Flip the Console registry. Six values in src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.tsjobengineJobs, jobengineDag, jobengineScheduler, jobengineSchedulerDoctor, jobengineSchedulerAudit, jobenginePackRegistry — to the values named in that file’s header. All six together: the gateway swap is atomic. Then bash tools/scripts/validate/check-console-jobengine-prefix-literals.sh must stay CLEAN, and npx ng build must stay green.
  6. Recreate the gateway so the deployed config reloads (--force-recreate; a same-tag image will not swap without it).
  7. Forcing function, not “the container is up”: an authorized call to a consolidated path returns the surface’s own payload, and an unauthorized one is rejected by the host reading the identity envelope.

Consumers NOT covered by this swap

Rollback

git checkout -- src/Router/StellaOps.Gateway.WebService/appsettings.json devops/compose/router-gateway-local.json, recreate the gateway, revert the six Console values. The swap is a pure config edit; nothing persists.

Rehearsal record (2026-08-05)

Applied to the working tree and restored. Measured, not predicted: