GRA-9 window — Graph consolidated route swap

RETIRED — 2026-09-04. Do not execute this procedure as written. This is the operator-facing copy of a ruling that until today existed only in the sprint row: SPRINT_20260722_023 GRA-9 is BLOCKED with “ROUTE LEG RE-SCOPED BY OWNER RULING 2026-08-28 — do NOT run the swap as written”, because “all microservices needs to autopublish no router configuration for microservices endpoints”. SPRINT_20260809_001 RAR-5/RAR-7 (81e276c2a3, 83bfc9359f, f4b1774cf6) executed that ruling. Both routes this swap replaces — ^/api/v1/reachability(.*) and ^/api/graph(.*) — are gone from both route tables, which now hold 22 routes total; Graph is served by the auto-published layer. A static gateway entry out-ranks publication, so reinstating those rows — by applying the swap, or by “reconciling” the manifest until -Check passes and then applying — would re-add exactly what RAR-5 exists to remove. (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.)

Window step 1 below can no longer pass. Since ced8a2d286 the script refuses on both invocations: pwsh tools/scripts/gra5-swap-graph-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: per the GRA-9 ruling, re-derive the route leg as a deletion gated on a coverage proof — capture graph-api’s declared HELLO Endpoints and show they cover every static row attributed to the family. Operationally today that means confirming the auto-published layer serves /api/graph/v1/** through the gateway (authorized 2xx, unauthenticated 401/403). Model for this shape: offlinekit-cutover-ok6-route-swap.md.

Prepared by SPRINT_20260722_023 GRA-5 (2026-08-05). Nothing is installed live by GRA-5. Per DC-19 a gateway route change belongs to the family’s single execution window, which for this family is GRA-9. This runbook is what the window executes.

Manifest (the one source of truth)tools/scripts/gra5-graph-route-swap.json
Script that applies ittools/scripts/gra5-swap-graph-routes.ps1
Guard that reads the same manifestsrc/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GraphConsolidatedRouteConfigTests.cs
Configs touchedsrc/Router/StellaOps.Gateway.WebService/appsettings.json, devops/compose/router-gateway-local.json
Deploy half of the same windowdocs/runbooks/graph/graph-deploy-gra8-staged-stack.md (compose join, aliases, doctor checks, abort criteria)
Sub-prefix map (what the host must serve)tools/scripts/gra5-graph-route-swap.json, key subPrefixMap — settled by GRA-7, guarded by GraphSubPrefixMapTests

What changes

Two routes become one group.

RemovedTarget todaySuccessor
^/api/v1/reachability(.*)reachgraph.stella-ops.local/api/v1/reachability$1/api/graph/v1/reachability
^/api/graph(.*)graph.stella-ops.local/graph$1 (:8080 in the compose-local copy)/api/graph/v1

Added: { "Type": "Microservice", "Path": "^/api/graph/v1(.*)", "IsRegex": true, "TranslatesTo": "http://graph.stella-ops.local/api/graph/v1$1", "PreserveAuthHeaders": true }.

Route count goes 203 → 202 in both configs; the group lands at index 67.

Read this before you believe the swap is small

Two routes is the smallest removal set of any family in this program — and it is the least representative. Findings removed nine, JobEngine ten. The Graph family’s gateway surface is almost entirely absent, and that absence is where the risk lives:

1. Both “silent data loss” contracts bypass the gateway entirely

GRA-1 pinned three contracts as break-equals-silent-loss. Two of them never touch the route table:

Consequence: this swap cannot break either one, and no route test can prove either one. Their real dependency is the compose network alias, which is GRA-8’s to preserve. If you are looking for the thing that can silently kill the Cartographer wire, it is an alias change, not a route.

Worth knowing: ^/api/graph(.*) maps /api/graphs/builds to /graphs/builds, which on the real host matches MapGet("/graphs/{graphId}") with graphId="builds" — a 400 on GET and a 405 on POST. So the Cartographer paths are not merely unrouted, they currently resolve to the wrong endpoint. CartographerCompat_IsNotGatewayServedToday_SoTheSwapCannotBreakIt pins that divergence so a future route that closes it fails loudly instead of quietly joining the removal set.

2. Ordering is load-bearing — the collider is the platform catch-all

/api is a non-regex ReverseProxy prefix route to platform-web (index 199 in both configs), and non-regex routes prefix-match (StellaOpsRouteResolver.cs:44-50). It therefore matches /api/graph/v1/anything. Resolution is strict first match over declaration order.

Appending the group — the obvious way to add a route — hands the entire consolidated Graph plane to platform-web, which answers 404/500 for paths it does not serve rather than failing visibly. This is the JobEngine hazard in a quieter costume. The script inserts at the first replaced route (index 67) and re-parses its own output to refuse a write that lands at or below /api; two tests pin it independently.

3. Much of the family has never had a route at all

Unlike the JobEngine sibling — whose hidden surface worked by naming coincidence through ^/api/v1/([^/]+)(.*) — this family’s hidden surface does not work at all. It falls through to microservice names nothing registers:

SurfaceResolves today toAfter
/api/v1/reachgraphs* (the reachability CAS)reachgraphs.stella-ops.local — unregistered (plural)/api/graph/v1/reachgraphs
/api/v1/cve-mappings* (nine endpoints)cve-mappings.stella-ops.local — unregistered/api/graph/v1/cve-mappings
/search, /paths, /assets/{id}/snapshot, /nodes/{id}/adjacencyno route (SPA fallback, or /api → platform)under /api/graph/v1

The consolidated group gives these a real route for the first time. Do not read the new traffic as a regression.

And note what the swap does NOT do: it does not retroactively fix the legacy paths. /api/v1/reachgraphs still resolves to the unregistered reachgraphs host after the window — UnroutableLegacyPaths_StayUnroutable_TheSwapDoesNotAdoptThem pins that. Consumers must move to the new prefix; the window does not move them.

4. PreserveAuthHeaders is inert on these routes

Program.cs:368-370 builds JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex). Every route in this swap is regex, so Authorization is stripped on all of them and identity arrives via the signed envelope — which is what both hosts read today. The flag is carried on the group only so a future non-regex split inherits the intent. EveryRouteInTheSwap_IsRegex_SoPreserveAuthHeadersStaysInert will go red if a non-regex route ever enters the set.

The 404 farm — source prerequisite is present; deployment proof is still mandatory

The 2026-08-05 deployed-source probe returned 404 for /api/graph/v1/search and /api/graph/v1/reachgraphs. That finding remains the live-estate baseline, not current-source truth.

The GRA-9 pre-live change now explicitly composes the lifted ReachGraph controllers and publishes real /api/graph/v1/* RouteEndpoint aliases for every subPrefixMap plane before Router’s HELLO refresh. Each alias preserves the carried handler’s authorization, tenant, rate-limit and model-binding metadata. This must be endpoint publication, not middleware rewriting: Router dispatches directly to discovered endpoints and bypasses middleware. The two hosts’ /graphs versus v1/reachgraphs collision remains designed out by distinct first segments. Legacy direct paths remain available for rollback and the Scanner writer.

Do not infer deployment from source or WebApplicationFactory. Before changing gateway routes, build graph-api’s published host and probe its own DLL, then recreate the live graph-api image and repeat the non-404/authorization probes. Applying the swap against the old image still creates a 404 farm with GREEN route tests.

The generated-config chain — a THIRD file this window must edit

This applies to all five families’ swaps, not just this one; it was found here and reported.

devops/compose/router-gateway-local.json is generated. Its own header says DO NOT EDIT BY HAND. Regenerate via devops/docker/router-gateway-config-generate.sh from src/Router/StellaOps.Gateway.WebService/appsettings.json + devops/compose/router-gateway-overlay.local.json. The swap script edits it by hand anyway, as all five sibling scripts do. Consequences:

  1. A regeneration after the window does NOT revert the swap — measured, not assumed. The swap edits the source (appsettings.json) as well as the generated file, so regeneration reproduces the group: running the generator’s own logic against post-swap sources emits 202 routes with the consolidated group at index 67, byte-identical, and 201 of 202 routes identical to the hand-edited file. The hand-edit is pre-applying what the generator would emit. (A swap that edited only router-gateway-local.json would be reverted — worth knowing, because that is the natural mistake to make.)

    CLOSED 2026-08-05 — the scheme hazard this item used to carry is fixed, and the instruction it carried is now the wrong one. ^/catalog-changes/(tenants|tenant_lifecycle)(.*) used to be https:// in the committed compose config and http:// from the sources with no overlay entry covering it, so a regeneration silently downgraded a working Authority route to plain HTTP at exit 0 with a green suite (the tests assert routing, not schemes). The orchestrator ruled the https deliberate and load-bearing, and 7be9d3cd4e repaired it the only correct way — an overlay RouteTranslateToOverrides entry matching the pattern its two siblings ^/api/v1/trust(.*) and ^/api/v1/tenants(.*) already used, never an edit to the generated file. There is no longer a flip to refuse. Re-measured for GRA-8 (2026-08-05): the generator’s own logic run against HEAD’s sources reproduces the committed router-gateway-local.json byte-identicallycmp clean, 57,859 bytes, 203 routes, LF both sides.

    Related, and still only flagged: ^/api/v1/claims(.*) is http:// in both files, so it is not a divergence and nothing here breaks it. It is, however, the one authority route the deployed config still serves over plain HTTP while three siblings are on HTTPS. Whether that is deliberate is an owner question.

  2. The overlay is a third surface and it holds one of this family’s routes. Gateway.RouteTranslateToOverrides has 53 entries keyed by route Path, one of which is "^/api/graph(.*)": "http://graph.stella-ops.local:8080/graph$1"this is where the :8080 in the compose copy comes from. It is a deliberate override, not drift. Once the swap removes that route from the source, the override matches nothing and the generator exits 2 (router-gateway-config-generate.sh:88-90). Delete that key in the same window, and do not re-add it for the consolidated group: the group is a Microservice route, so a URL-port override is meaningless for it. Overlay_MustNotOverrideARouteTheSwapRemoved enforces this in both directions — it requires the override to be present before the swap and absent after.

  3. The generator WAS red on main; 7be9d3cd4e cleared it. Regeneration is available to this window. It used to fail before writing anything, with seven unmatched overrides — five federation, two vuln-plane (^/api/v1/vex(.*), ^/api/vex(.*), left by the B7 removal) — so “edit the sources and regenerate” was blocked estate-wide and router-gateway-local.json matched its sources only because nobody had successfully regenerated since. All seven were verified absent from appsettings.json and deleted, the 16 duplicated keys were deduped, and the catalog-changes override was added.

    Current state, measured for GRA-8 on 2026-08-05: the generator exits 0; effective override keys 46, zero orphans (every key matches a source route, which is the exact condition at router-gateway-config-generate.sh:88-93); ^/api/graph(.*) present exactly once, so this window’s single deletion is still sufficient; and the committed file is reproducible byte-identically (see item 1). The stale figures a previous reader may quote are 53 keys / 7 orphans / 16 duplicates — all pre-7be9d3cd4e.

    Two mechanisms worth keeping even though their instances are gone. (a) A raw scan of the override map gives a count one higher than the effective one: _comment is a key in both maps and the generator strips _-prefixed keys before matching (strip_comments, lines 59-68), so a re-derivation that disagrees by one with a doc is probably not wrong. (b) Duplicate keys are benign only while the copies agree — json.load keeps the LAST, so a diff can look correct while the other copy decides the outcome. Overlay_MustNotOverrideARouteTheSwapRemoved counts occurrences rather than testing presence precisely so a future duplicate cannot be half-deleted past it.

  4. The release-bundle copy is generated by the staging tool. The earlier statement that nothing regenerates devops/release/bundle/config/router-gateway.json was false: devops/release/bundle/tools/stage-config.py copies devops/compose/router-gateway-local.json to that exact path. After the route generator is clean, run the staging tool and its --check; never hand-edit the bundle copy.

Window steps

Historical — do not run steps 1, 3 or 4. See the banner at the top of this page.

  1. pwsh tools/scripts/gra5-swap-graph-routes.ps1 -Check — must print RESULT: manifest and live route configs agree. and exit 0. It now refuses and exits 2. Under the original design a non-zero exit aborted the window; today it means the window is retired outright.
  2. Confirm the newly built graph-api host serves /api/graph/v1/* from its own DLL. Verify with real calls, not only route/WAF tests, and verify the live container is recreated from that image.
  3. pwsh tools/scripts/gra5-swap-graph-routes.ps1 — applies to both configs. Do not run this. A bare run refuses and exits 2, writing nothing. Until ced8a2d286 it 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 two manifest rows by some other route — a static entry out-ranks publication.
  4. Delete "^/api/graph(.*)" from Gateway.RouteTranslateToOverrides in devops/compose/router-gateway-overlay.local.json— see the section above. Skipping this leaves the generator unable to run.
  5. Run the guard: whole-class run of GraphConsolidatedRouteConfigTests — expect all green (18 cases as of 2026-08-05). Then the whole Configuration namespace.
  6. Required — prove the hand-edit equals a regeneration and refresh the bundle copy. Since 7be9d3cd4e the generator runs clean, so bash devops/docker/router-gateway-config-generate.sh after steps 3-4 should leave router-gateway-local.json byte-unchanged (git diff --stat empty). A non-empty diff means the hand-edit and the sources disagree — investigate before recreating the gateway. To check without touching the tree, run the generator’s embedded python with your own output path and cmp. Then run python devops/release/bundle/tools/stage-config.py followed by the same command with --check; the release-bundle route table must move with the generated compose copy.
  7. Recreate the gateway so the deployed config reloads. Services re-send HELLO after restart; a gateway 503 "no instances available" is fixed by one docker restart.
  8. Forcing functions, not a smoke test: a Scheduler-driven Cartographer build/overlay round-trip (that wire is live in the default stack), plus a reachability-CAS round-trip. Read the deploy runbook’s “two direct wires” table before choosing the second one: GRA-1’s stated S8 function — “a live scan whose subgraph is then queryable” — proves nothing unless Scanner__Worker__Reachability__PublishToReachGraph is switched on, and it is false in the default stack (only docker-compose.sprint057-smoke.override.yml sets it). A broken publisher is silent either way: scans succeed and graphs never arrive. 2026-09-14 (SPRINT_20260914_001 VRP-5): that publisher and both keys are retired at source; the reachability-CAS round-trip is a direct CAS PUT/GET on graph-api, not a scan.

Consumers NOT covered by this swap

Console state entering the window

The committed Console still points API_PATH_PREFIXES.graph at /api/graph. Its existing call shapes are already correct for the canonical host: CompatibilityEndpoints serves GET /graphs, /search, /paths, asset snapshots and node adjacency; AssetRegistryEndpoints serves query/detail paths. The window changes only that one registry value to /api/graph/v1, runs the adjacent wire specs, and deploys Console with graph-api—there is no seven-call-site rewrite.

Do not flip graphReachability or graphReachabilityDrift. Their Console callers require findings/symbols/scans/drift GET/compare/list/attest endpoints that neither Graph.Api nor the lifted ReachGraph application exposes. Those keys remain an explicit contract blocker; pointing them at a known 404/405 surface is not compatibility.

Rollback

Before step 3, copy the three route-source files into an ignored, timestamped tmp/gra9-route-rollback/<timestamp>/ directory and record their SHA-256 values. To roll back, stop the route change, verify the saved paths/hashes, restore those exact files with Copy-Item -LiteralPath, run the JSON/route guards, restage the release-bundle config, and recreate the gateway. Do not use git checkout in this shared repository: it can discard a concurrent edit. This section rolls back only the route/config portion; the database/service rollback remains in the GRA-8 deploy runbook.

Rehearsal record (2026-08-05)

Applied to the working tree and restored; the tree was verified clean afterwards.