FND-9 window — Findings consolidated route swap
RETIRED — 2026-09-04. Do not execute this procedure. The owner ruling of 2026-08-28 (“all microservices needs to autopublish no router configuration for microservices endpoints”) was executed by
SPRINT_20260809_001RAR-5/RAR-7 (81e276c2a3,83bfc9359f,f4b1774cf6). All eight rows this swap replaces are gone from both route tables, which now hold 22 routes total; Findings is served by the auto-published layer. A static gateway entry out-ranks publication, so reinstating these eight rows — by applying the swap, or by “reconciling” the manifest until-Checkpasses and then applying — would claw the plane back off the published layer: a regression, not a no-op, andRetiredRouteRegistryTestsfails the build on it. (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.)The script now says so itself. Since
ced8a2d286it refuses on both invocations:-Checkand a bare run each printFND-5 route swap: RETIRED -- nothing applied, nothing checked.and exit 2 (measured 2026-09-04 at HEAD). It also records what this page did not: the swap ALREADY RAN —d25921373dlanded it in all three configs — and RAR retired the consolidated group it installed. There is nothing here to run again.Do instead: verify the auto-published Findings surfaces answer through the gateway (
/api/findings/v1/**authorized 2xx, unauthenticated 401/403). The retired end state lives insrc/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/retired-routes.json. Read the rest for the reasoning that shaped the design; do not run it. Model for this shape:offlinekit-cutover-ok6-route-swap.md.
SOURCE GATE RESOLVED; RUN ONLY IN THE FND-9 WINDOW (2026-08-23). The owner rejected static gateway rewrites. An image containing the FND-5 route-publication checkpoint must be composed:
findings-webmaps the canonical/api/findings/v1/*aliases as real ASP.NET endpoints before Router discovery refresh, so they are served directly and advertised through HELLO. The production host conformance test crosses that seam; the route-config tests and script-Checkstill prove only gateway mechanics. Before applying the swap, verify the running image’s HELLO catalogue and perform the authorized/unauthorized forcing calls below./api/vuln-explorer/*remains a pre-existing broken contract with no owning backend endpoint and is deliberately not manufactured by this checkpoint.
Owner sprint: SPRINT_20260722_010 FND-5 · Executes inside: the FND-9 maintenance window, together with the compose flip (FND-8) and the Console prefix flip — owner D8 / recipe DC-19: routes are REPLACED in the window, one routing change per program, never aliased early.
The change is a script, not a patch. The gateway configs are edited most weeks; a frozen diff would rot before the window. The reviewed artifacts are a manifest and the script that applies it:
tools/scripts/fnd5-findings-route-swap.json— the route set, the consolidated group, the per-prefix fall-through and the consumer list.tools/scripts/fnd5-swap-findings-routes.ps1— removes the nine old routes and inserts the group at the first replaced position, in BOTH route configs, refusing to write anything that no longer parses as JSON. Idempotent.
FindingsConsolidatedRouteConfigTests reads the same manifest, so the guard and the swap cannot describe different route sets. That is the one thing the B7 window taught that this package is built around.
What changes
Nine routes across two host names collapse into one group:
| Old gateway prefix | Current target | New path under the group |
|---|---|---|
/api/v1/finding-ledger/* | findings.stella-ops.local | /api/findings/v1/ledger/* |
/api/v1/scoring/* | findings.stella-ops.local | /api/findings/v1/scoring/* |
/api/v1/capabilities/* | findings.stella-ops.local | /api/findings/v1/capabilities/* |
/vuln/ledger/events | findings.stella-ops.local | /api/findings/v1/ledger/events |
/v1/alerts/* | findings.stella-ops.local | /api/findings/v1/alerts/* |
/v1/vex-decisions/* | findings.stella-ops.local | /api/findings/v1/vex-decisions/* |
/api/vuln-explorer/* | findings.stella-ops.local | /api/findings/v1/vuln-explorer/* (no owning backend; explicit product disposition remains) |
/api/v2/security/* | findings-security.stella-ops.local | /api/findings/v1/security/* |
/api/risk/aggregated-status | findings-security.stella-ops.local | /api/findings/v1/risk/aggregated-status |
The group is ^/api/findings/v1(.*) -> http://findings.stella-ops.local/api/findings/v1$1.
Two facts that are not obvious from the config
1. PreserveAuthHeaders is inert on a regex route — verified 2026-08-05. src/Router/StellaOps.Gateway.WebService/Program.cs:368 builds IdentityHeaderPolicyOptions.JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex). Every findings route is IsRegex: true, so none of them can enter the passthrough list; IdentityHeaderPolicyMiddleware.ShouldPreserveAuthHeaders returns false and Authorization is stripped. All nine routes already run that way, and so will the group — identity reaches the service through the signed identity envelope, not through a forwarded Bearer. Do not “fix” a 401 after the swap by adding the flag; look at the envelope and the host’s BypassNetworks instead.
2. Removing a route does not make its old path fail cleanly. Measured on the live route tables 2026-08-05 by simulating first-match after removal (identical in both configs):
| Old path | Falls through to | What an operator sees |
|---|---|---|
/api/v1/finding-ledger/*, /api/v1/scoring/*, /api/v1/capabilities/* | ^/api/v1/([^/]+)(.*) -> <segment>.stella-ops.local | 404 — the substituted service name is not registered |
/api/v2/security/* | ^/api/v2/([^/]+)(.*) -> security.stella-ops.local | 404 — same shape |
/vuln/ledger/events, /v1/alerts, /v1/vex-decisions | no API route -> SPA static fallback | 200 text/html— check the content type, not the status (B7 measured this class live 2026-08-04) |
/api/vuln-explorer/* | /api prefix route -> platform-web | wrong service answers |
/api/risk/aggregated-status | the policy risk catch-all ^/api/(risk or risk-budget)(.*) -> policy-engine | wrong service answers silently — the dangerous one |
This is why the Console must be fully off the old prefixes before the window, not after it. It is: FND-5 routed all 37 Console call sites through src/app/core/api/api-path-prefixes.ts, so the Console half of the window is flipping six values in one file, guarded by tools/scripts/validate/check-console-findings-prefix-literals.sh.
Step 0 — grants, before anything routes at the new host
Do this first, and do not take the shape of it from R-FND3-8’s wording: its first remedy was measured in FND-7 and fails in both of its forms. Fourteen findings tables carry ENABLE + FORCE row-level security whose policy calls findings_ledger_app.require_current_tenant() and raises rather than filtering, so a wrong grant here produces a service that starts, converges its schema, passes its connection check, and cannot read one ledger row — after the routes already point at it.
| Instruction | What actually happens | Source |
|---|---|---|
GRANT findings_ledger_admin TO <owner> | Nothing. BYPASSRLS is a role ATTRIBUTE; attributes are never inherited through membership. The unscoped read still raises P0001. | FindingsRoleGrantTests.NeitherHalfOfTheBypassRoleRemedyWorksAsTheBaselineShipsIt |
SET ROLE findings_ledger_admin | Confers the attribute and discards the owner’s privileges — the baseline creates that role with no grants at all. The read then fails 42501 permission denied for schema findings, which reads like a privilege bug and is really the bypass path. | same test |
ALTER ROLE <owner> BYPASSRLS | Works, unchanged. The only one-step remedy against the baseline as it ships. | same test |
Set app.current_tenant per session | Works (R-FND3-8 option b), but the service must then do it on every connection. | TheOwnerRole_ReadsAndWritesOnlyItsOwnTenantWhenScoped |
Two more grant facts the window depends on, both measured rather than reasoned:
GRANT … ON ALL TABLES IN SCHEMAis a one-time snapshot, and a partition does NOT inherit privileges from its parent. Every per-tenant partition created after the grant step is unreadable until it is granted explicitly orALTER DEFAULT PRIVILEGESis in place. The window’s grant step therefore cannot be a singleGRANT ON ALL TABLES.- Once granted, a leaf partition is readable regardless of the session’s tenant scope — policies on a partitioned parent apply to access through the parent, and the leaves carry none of their own. Do not hand this role to anything expected to see one tenant only. (Both pinned in
ANewTenantPartition_IsUngrantedUntilItIsGranted_AndThenBypassesTheParentPolicy.)
Verify the outcome rather than the statements: call GET /doctor/findings-web/checks on the consolidated host and read doctor.findings.ledger.rls-posture. It reports which of the remedies is missing, and it is the one check that distinguishes “cannot read” from “nothing to read”. Full deploy detail in findings-service-deploy.md.
Related, and also FND-9’s to rule: nothing in src/ creates a per-tenant partition, so every tenant’s rows currently live in the _default twin. The per-tenant Merkle copy this window is defined around has no per-tenant unit to copy until that changes (FND-7 finding F-FND7-2; doctor.findings.ledger.tenant-partition-coverage measures it).
Window steps
The steps below are the historical procedure and are not executable — see the banner. They are kept for the reasoning, the red-suite inventory and the Console prefix list.
pwsh tools/scripts/fnd5-swap-findings-routes.ps1 -Check— now refuses and exits 2. Under the original design a non-zero exit meant “do not open the window”; today it means the window is retired outright.- Confirm the running target image actually serves and advertises the group. Source now maps the native canonical aliases and the production-host contract proves Router HELLO discovery, but no target container is live as of 2026-08-24. Source proof is not permission to swap before the direct target canary.
The script refuses. A bare run prints the RETIRED notice and exits 2; it writes nothing. Untilpwsh tools/scripts/fnd5-swap-findings-routes.ps1— applies to both configs.ced8a2d286it instead printedSwap applied. Routes replaced: 0. Recreate the gateway so the deployed config reloads.and exited 0 — a false success line and a false exit code, fixed underSPRINT_20260904_001RRS-1. The remaining regression risk is reinstating the eight manifest rows by some other route (editing the manifest or the configs by hand); a static entry out-ranks publication andRetiredRouteRegistryTestsfails the build on it.- Flip the six
findings*values insrc/app/core/api/api-path-prefixes.ts(the list is in that file’s header), rebuild and redeploy the Console. - Recreate the gateway (
docker compose ... up -d --force-recreate --no-deps gateway) so the deployed config reloads — the compose overlay copy is the one the live gateway reads. - Update the route suites that assert the old routes exist. Measured 2026-08-05 by applying the swap to the working tree and running the
Configurationnamespace: 161 cases, 6 red, all in ONE suite. Red, and to be updated in the SAME window change —GatewayRouteSearchMappingsTests:RouteTable_ContainsUnifiedSearchMappingsAndKeepsThemAheadOfApiCatchAll(itsRequiredMappingstable carries a^/api/v2/security(.*)row),RouteTable_RoutesVexDecisionsToFindingsAheadOfSpaFallback, andRouteTable_RoutesRiskAggregatedStatusToFindingsSecurityAheadOfPolicyRiskCatchAll, each x 2 configs. Green, do not go hunting for failures here:VulnerabilitiesHubRouteConfigTests,OperationalApiOwnershipRouteConfigTests,TopologyOwnershipRouteConfigTestsand the rest of the namespace.FindingsConsolidatedRouteConfigTestsis green on both sides by design. - Verify:
FindingsConsolidatedRouteConfigTests— 10/10, and note that a swap applied to only one of the two configs turnsSwap_IsSymmetricAcrossBothRouteConfigsred (rehearsed 2026-08-05);- a through-gateway forcing call on
/api/findings/v1/security/findingswith a scoped token — authorized succeeds, unauthorized is rejected; bash tools/scripts/validate/check-console-findings-prefix-literals.sh— clean.
Consumers that are NOT covered by this swap
src/Authority/StellaOps.Authority/StellaOps.Authority/Console/FindingsConsoleClient.cscalls the ledger host directly on/v1/vulns,/v1/vulns/{id}and/v1/vex-decisions. Those are service-side paths on a service-to-service client, not gateway routes, so this script does not touch them — they ride the FND-8 host composition. If the consolidated host does not keep serving them, Authority’s console workspace breaks with no gateway change involved.- QA scripts:
tools/scripts/qa/Invoke-OperatorScenarios.ps1probes/api/v1/finding-ledger;tools/scripts/qa/seed-real-scan.ps1probes/api/risk/aggregated-status. Both go stale at the window.
Rollback
Valid ONLY before FND-9 revokes the old schema grants: git checkout of the two configs plus a gateway recreate restores the old routing, and reverting the six Console prefix values restores the old Console. After the data move, fix-forward applies.
Rehearsal record (2026-08-05)
-Check reported 9 of 9 in both configs. A full apply was rehearsed on the working tree: 203 routes -> 195 per config (9 removed, 1 added), both files re-parsed as JSON, the inserted route rendered in each file’s own house style (single-line in appsettings.json, expanded in router-gateway-local.json), and a first-match simulation confirmed the group wins all ten consolidated-path probes in both configs. A second run was a no-op (“ALREADY PRESENT”, nothing written). The Configuration namespace was run against the swapped tree to measure the red set (6 of 161), then the configs were restored and re-run green (161/161). No live stack was touched.
Window record (2026-08-26): THE SWAP IS APPLIED LIVE. The first window run of the day stopped before this swap on the 010×023 asset-registry blocker; the seam then shipped (3eeed9b925) and the re-opened window landed the swap: the script applied 9-of-9 in BOTH configs, the release-bundle third copy (devops/release/bundle/config/router-gateway.json) took the identical change via a scratch manifest, the Console flipped its five values in the same change set, the six red route-suite cases were rewritten to the post-swap truth (full gateway suite 738/738; FindingsConsolidatedRouteConfigTests 10/10), and the live gateway reloaded the bind-mounted config on restart. Post-swap, FND-11’s window half dropped the dead ^/api/vuln-explorer(.*) row from the manifest (8 replacedRoutes remain; -Check reports 0-of-8 pending, group ALREADY PRESENT). Live fall-through samples: /api/v2/security/* answers the gateway’s 503 no-instances (unregistered name — louder than the predicted 404), /v1/vex-decisions answers 401 (auth ahead of the static fallback), /api/risk/aggregated-status answers policy-engine’s 404 — all fail loudly; none silently serves.
