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_023GRA-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_001RAR-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-Checkpasses 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
ced8a2d286the script refuses on both invocations:pwsh tools/scripts/gra5-swap-graph-routes.ps1 -Checkand 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
Endpointsand 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 it | tools/scripts/gra5-swap-graph-routes.ps1 |
| Guard that reads the same manifest | src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GraphConsolidatedRouteConfigTests.cs |
| Configs touched | src/Router/StellaOps.Gateway.WebService/appsettings.json, devops/compose/router-gateway-local.json |
| Deploy half of the same window | docs/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.
| Removed | Target today | Successor |
|---|---|---|
^/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:
- Cartographer compat —
POST /api/graphs/builds+/api/graphs/overlayson graph-api. The consumer (StellaOps.JobEngine.Scheduling/GraphJobs/CartographerWebhookClient.cs) calls the host directly viaScheduler__Worker__Graph__Cartographer__BaseAddress: "http://graph.stella-ops.local". - X20’s Scanner writer —
POST /v1/reachgraphson reachgraph-web, called directly atSTELLAOPS_REACHGRAPH_URL/http://reachgraph:8080.
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:
| Surface | Resolves today to | After |
|---|---|---|
/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}/adjacency | no 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:
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 onlyrouter-gateway-local.jsonwould 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 behttps://in the committed compose config andhttp://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 thehttpsdeliberate and load-bearing, and7be9d3cd4erepaired it the only correct way — an overlayRouteTranslateToOverridesentry 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 committedrouter-gateway-local.jsonbyte-identically —cmpclean, 57,859 bytes, 203 routes, LF both sides.Related, and still only flagged:
^/api/v1/claims(.*)ishttp://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.The overlay is a third surface and it holds one of this family’s routes.
Gateway.RouteTranslateToOverrideshas 53 entries keyed by route Path, one of which is"^/api/graph(.*)": "http://graph.stella-ops.local:8080/graph$1"— this is where the:8080in 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 aMicroserviceroute, so a URL-port override is meaningless for it.Overlay_MustNotOverrideARouteTheSwapRemovedenforces this in both directions — it requires the override to be present before the swap and absent after.The generator WAS red on main;
7be9d3cd4ecleared it. Regeneration is available to this window. It used to fail before writing anything, with seven unmatched overrides — fivefederation, two vuln-plane (^/api/v1/vex(.*),^/api/vex(.*), left by the B7 removal) — so “edit the sources and regenerate” was blocked estate-wide androuter-gateway-local.jsonmatched its sources only because nobody had successfully regenerated since. All seven were verified absent fromappsettings.jsonand 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:
_commentis 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.loadkeeps the LAST, so a diff can look correct while the other copy decides the outcome.Overlay_MustNotOverrideARouteTheSwapRemovedcounts occurrences rather than testing presence precisely so a future duplicate cannot be half-deleted past it.The release-bundle copy is generated by the staging tool. The earlier statement that nothing regenerates
devops/release/bundle/config/router-gateway.jsonwas false:devops/release/bundle/tools/stage-config.pycopiesdevops/compose/router-gateway-local.jsonto 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.
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.pwsh tools/scripts/gra5-swap-graph-routes.ps1 -Check— must printRESULT: manifest and live route configs agree.and exit 0.- 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. Do not run this. A bare run refuses and exits 2, writing nothing. Untilpwsh tools/scripts/gra5-swap-graph-routes.ps1— applies to both configs.ced8a2d286it printedSwap applied. Routes replaced: 0. Recreate the gateway so the deployed config reloads.and exited 0 — a false success signal, fixed underSPRINT_20260904_001RRS-1. The remaining regression risk is reinstating the two manifest rows by some other route — a static entry out-ranks publication.- Delete
"^/api/graph(.*)"fromGateway.RouteTranslateToOverridesindevops/compose/router-gateway-overlay.local.json— see the section above. Skipping this leaves the generator unable to run. - Run the guard: whole-class run of
GraphConsolidatedRouteConfigTests— expect all green (18 cases as of 2026-08-05). Then the wholeConfigurationnamespace. - Required — prove the hand-edit equals a regeneration and refresh the bundle copy. Since
7be9d3cd4ethe generator runs clean, sobash devops/docker/router-gateway-config-generate.shafter steps 3-4 should leaverouter-gateway-local.jsonbyte-unchanged (git diff --statempty). 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 andcmp. Then runpython devops/release/bundle/tools/stage-config.pyfollowed by the same command with--check; the release-bundle route table must move with the generated compose copy. - Recreate the gateway so the deployed config reloads. Services re-send HELLO after restart; a gateway
503 "no instances available"is fixed by onedocker restart. - 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__PublishToReachGraphis switched on, and it is false in the default stack (onlydocker-compose.sprint057-smoke.override.ymlsets 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
- CLI — no
stellacommand targets either family prefix (swept 2026-08-05). ReachabilityClient(core/api/reachability.client.ts) — base URL isconfig.apiBaseUrls.scanner. It is SCANNER’s, despite the name. Do not repoint with this family.- Scanner’s reachability-drift API (
/scans/{id}/drift,/drift/{id}/sinks) — Scanner’s, and the live reachability-delta lineage. This is why GRA-5 ruled to drop graph-api’s parallelIReachabilityDeltaServicestub rather than ship it. - Signals / Platform reachability endpoints — Signals stays a separate service by owner ruling.
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.
-Checkagainst the live configs: 2 of 2 routes matched in each config, ordering guard PASS, exit 0.- Applied: 203 → 202 routes in both configs, group at index 67 vs
/apiat 198, both still valid JSON, group rendered in each file’s own house style (single-line inappsettings.json, expanded inrouter-gateway-local.json), zero leftovers of either replaced path. - Idempotence: second run reported
0 of 2matched andALREADY PRESENT, wrote nothing. - Guard: 18/18 green pre-swap and 18/18 green on the COMPLETE post-window state (swap applied AND the overlay override removed).
- Red controls (each verified to actually fail, not assumed):
- swap applied to one config only →
Swap_IsSymmetricAcrossBothRouteConfigsfailed; - group installed with
^/api/graph(.*)re-inserted →Swap_IsEitherFullyPending_OrFullyAppliedfailed; - group appended below
/api→ConsolidatedGroup_MustOutrankThePlatformApiCatchAllandConsolidatedGroup_WinsForEveryConsolidatedPathfailed; - swap applied with the overlay override LEFT IN PLACE (the realistic window mistake) →
Overlay_MustNotOverrideARouteTheSwapRemovedfailed. Removing the override then returned the class to 18/18, which is the proof that the documented window sequence is the complete one.
- swap applied to one config only →
- Red set in existing tests: ZERO — and that is the finding, not a reassurance. With the swap applied, the gateway
Configurationnamespace was fully green, because the Graph family had no gateway route tests at all before this task (grep forapi/graph,reachability,reachgraph,graph.stella-opsacross the gateway test tree returned nothing). Findings measured 6 red and JobEngine 8. An empty red set here meant nothing was watching.
