Gateway authorization: unmappable policy register and inventory

Origin: SPRINT_20260904_003_Router_gateway_authorization_fail_open.md (ROA-1). Verified against commit 6f71ae03e5 (the original source scan) and devops/compose/docker-compose.stella-services.yml at that commit (deployment posture). The fail-closed flip, the zero-claim register and the dead-knob deletion are ROA-1(b), verified against f27cacfc08 on 2026-09-07. Re-verify: dotnet test src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/StellaOps.Architecture.Contracts.Tests.csproj (class GatewayUnmappablePolicyConformanceTests), and rg -n "RequireAssertion\(" src --glob '!**/__Tests/**' --glob '!**/*.Tests/**' --glob '!**/__Obsoleted/**'.

  1. The host cannot enforce its own policies on the router path. AspNetRouterRequestDispatcher (src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs) builds an HttpContext and invokes endpoint.RequestDelegate directly. UseAuthorization() is middleware; it never runs for a router-dispatched request, so a minimal-API RequireAuthorization(policy) is NOT a backstop there. Router__AuthorizationTrustMode only selects how the principal is populated (signed envelope vs headers); the bypass is unconditional.
  2. The gateway is therefore the only enforcement point, and until 2026-09-07 it failed open. src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cs looks up the endpoint’s published claim requirements; when the list was empty it called _next, so an authenticated caller with any token passed. ROA-1(b) closed that branch — see “The fail-closed flip” below. The middleware now refuses an empty claim list unless the service has positively said the endpoint requires only authentication.
  3. Requirements are published by the service, and assertion policies publish nothing. DefaultAuthorizationClaimMapper maps ClaimsAuthorizationRequirement, RolesAuthorizationRequirement, NameAuthorizationRequirement, and any requirement type exposing RequiredScopes / Scopes / AllowedScopes (+ RequireAllScopes). A RequireAssertion(...) lambda is an opaque AssertionRequirement: it maps to no claim, so the endpoint is published with zero claim requirements. Since ROA-1 the mapper logs this at Warning, once per (policy, requirement type), naming the policy and the service.

Consequence for a service author: scope protection cannot be assumed on any endpoint until its policy registration has been checked for RequireAssertion versus a typed requirement. A direct probe against the host cannot answer that question — the host’s own middleware runs there — only a router-path probe or reading the registration can.

The register (unmappable-policy-register.json)

Shrink-only, modelled on docs/architecture/build-boundary/legacy-edge-register.json. One pin per production file with its count of RequireAssertion call sites; the count may only decrease, a resolved pin is deleted, and a new call site anywhere fails GatewayUnmappablePolicyConformanceTests.Estate_EveryRequireAssertionPolicyIsPinnedAndShrinkOnly (unpinned, grown, and STALE pins all fail, so the register cannot outlive its cause). A second gate, Estate_EveryCustomAuthorizationRequirementExposesMappableScopes, fails any production IAuthorizationRequirement type that exposes none of the scope properties the mapper reads.

The fix for a new registration is never a pin. It is a typed requirement the mapper can read, and which typed requirement depends on what the host actually registers — determine it, do not assume:

Two traps this ordering exists to avoid. Using the resource-server helpers in a host that does not register that scheme binds the policy to a scheme that is absent and needs a handler that is not there — it does not fail open, it breaks the direct path. And the question is per branch, not per host: AirGap’s AddAuthorization block is unconditional while its authentication is not, so its Testing/development branch (HeaderScopeAuthenticationHandler) would have taken scheme-bound policies it cannot satisfy. Registering policies without their handler has the same effect — a typed requirement with no evaluator denies everything, which ROA-1 hit in two test-composed hosts.

Register history: seeded 2026-09-05 with 66 call sites; shrunk to 60 by the AdvisoryAI conversion (ROA-1) and to 54 by the Notify + AirGap conversions (ROA-2), all in the same sprint (shrinkLog in the JSON).

Inventory of the RequireAssertion registrations (66 at seed, 54 after ROA-2)

Reachability is the deployment posture per docker-compose.stella-services.yml:

FamilyFileLinesCountShapeScopes checkedReachability
Scannersrc/Scanner/StellaOps.Scanner.WebService/Program.cs1151-117726permit-all (_ => true)noneBRANCH-NOT-TAKEN — else of scanner:Authority:Enabled; compose ships SCANNER_SCANNER__AUTHORITY__ENABLED=true, under which all 26 policies are AddStellaOpsScopePolicy/AddStellaOpsAnyScopePolicy
Notifysrc/Notify/StellaOps.Notify.WebService/Program.cs678, 686 (pre-conversion)2 → 0any-ofOperator: notify.operator|notify.admin; NotifyEscalate: notify.escalate|notify.operator|notify.adminREACHABLE-UNENFORCED at seed — CONVERTED in ROA-2 (9d6f4b2c03) to StellaOpsScopeRequirement via RequireAnyStellaOpsScopes; live closure needs a redeploy of notify-web
Notifysame710-7265permit-allnoneBRANCH-NOT-TAKEN — allowAnonymousFallback; compose ships false
Notifysame762-7945any-of / singleViewer, AssuranceReportingViewer, Operator, Admin, NotifyEscalate over notify.*, policy:read, policy:audit, notify.escalateBRANCH-NOT-TAKEN — development-JWT branch (Authority disabled)
Authority (IssuerDirectory)src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs141-145, 3316 → 0RESOLVED BY DELETION, not by conversion (SPRINT_20260722_016 AUTH-10, 2026-09-11). The host was retired to src/__Obsoleted/Authority/ when the AUTH-9 fold went live. Nothing enforced anywhere changed: five of the six were the Authority-disabled else branch the deployed compose never took (ISSUERDIRECTORY__AUTHORITY__ENABLED=true), and the sixth was the already-DEAD RequireScope helper with no callers. The live surface is served by authority from StellaOps.IssuerDirectory.Api, whose policies are typed. Register: totalCallSites 48 → 42.
Evidence (Attestor)src/Evidence/__Libraries/StellaOps.Attestor.Application/Security/AttestorPolicies.cs93, 98, 104 (post-conversion)3 → 0any-of OR trusted-network bypass, no RequireAuthenticatedUser()write: attest:create (22 routes); verify: attest:read|attest:create (2 + 2 shared); read: attest:read|attest:create (26 routes)REACHABLE-UNENFORCED at seed — CONVERTED in ROA-3 to AttestorScopeRequirement + AttestorScopeAuthorizationHandler (AddAttestorAuthorization). The bypass SURVIVES the conversion — see “The bypass and the conversion are independent” below; AttestorBypassTenantHeaderTests passes unchanged (4/4). Live closure needs an evidence-web redeploy
Evidence (Attestor)same114, 129 (post-conversion)3 → 0any-of, RequireAuthenticatedUser() presentwatchlist read (4 routes) / write (3) / admin (0 routes) over trust:* and legacy watchlist:* / watchlist.*REACHABLE-UNENFORCED at seed — CONVERTED in ROA-3. read/write are typed AttestorScopeRequirement keeping RequireAuthenticatedUser(); watchlist:admin was DELETED, not converted — it guarded zero routes and the admin capability is enforced in-handler by WatchlistEndpoints.IsAdmin
AdvisoryAIsrc/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Security/AdvisoryAIPolicies.cs54, 64, 73, 86, 95, 101 (pre-conversion)6 → 0any-of / singleView: advisory-ai:view|operate|admin; Operate: operate|admin; Admin; Health: ops.health; OpsMemory Read/WriteREACHABLE-UNENFORCED at seed — CONVERTED in ROA-1 (19258fb497) to AdvisoryAiScopeRequirement; live closure needed a redeploy of the AdvisoryAI host, which landed 2026-09-05; the compose key is now advisoryai-web
AirGap (retired OK-5)src/__Obsoleted/AirGap/StellaOps.AirGap.Controller/Program.cs54, 56, 58, 69 (pre-conversion)4 → 0singleairgap:status:read, airgap:seal, airgap:import, airgap:status:readREACHABLE-UNENFORCED at seed — CONVERTED in ROA-2 (9d6f4b2c03) to AirGapScopeRequirement (registrations moved to Security/AirGapPolicies.cs); RESOLVED BY RETIREMENT 2026-09-11 (SPRINT_20260722_025 OK-5) — the redeploy this row was waiting for can never happen: the host is frozen at src/__Obsoleted/AirGap/StellaOps.AirGap.Controller/ and stellaops-airgap-controller is stopped. The /system/airgap surface that survives is Policy Engine’s (live probe 2026-09-14: 401, error body "service":"policy-engine"), and posture custody is Platform’s environment_state; neither inherits this host’s seed registrations
Workflowsrc/Workflow/StellaOps.Workflow.WebService/Authorization/WorkflowPolicies.cs66, 77, 873any-of / singleView: workflow:view|operate|admin; Operate: operate|admin; AdminNOT-DEPLOYED — WebService removed from compose 2026-07-06 (Slot 46); unenforced the day it returns
Signalssrc/Signals/StellaOps.Signals/Program.cs429, 430, 4343permit-allnoneBRANCH-NOT-TAKEN — anonymous-harness branch; compose ships Signals__Authority__Enabled=true

Totals at seed (66): REACHABLE-UNENFORCED 18 (Notify 2, Attestor 6, AdvisoryAI 6, AirGap 4), BRANCH-NOT-TAKEN 44 (Scanner 26, Notify 10, IssuerDirectory 5, Signals 3), NOT-DEPLOYED 3 (Workflow), DEAD 1 (IssuerDirectory helper), UNVERIFIED 0. After ROA-1 (60): REACHABLE-UNENFORCED 12 (Notify 2, Attestor 6, AirGap 4). After ROA-2 (54): REACHABLE-UNENFORCED 6, all Attestor. After ROA-3 (48): REACHABLE-UNENFORCED 0 — every production RequireAssertion registration that the shipped compose posture actually reaches is now a typed, mappable requirement. The 48 that remain are 44 BRANCH-NOT-TAKEN, 3 NOT-DEPLOYED and 1 DEAD.

The bypass and the conversion are independent (ROA-3 corrects ROA-2’s premise)

Superseded, 2026-09-05. This section replaces “Why Attestor is deferred (ROA-2)”. If you find that heading, or ROA-2’s matching sprint bullet, do NOT re-derive “converting a RequireAssertion policy removes its bypass” from either — the reasoning below is why that is wrong, and the owner adopted this correction on 2026-09-05.

ROA-2 deferred the Attestor six on a stated premise: “a typed scope requirement has no second branch, so converting removes the bypass a live keyless consumer depends on.” ROA-3 converted them without removing anything, because that premise is true only of StellaOpsScopeRequirement, whose SHARED StellaOpsScopeAuthorizationHandler owns the predicate. A family-local requirement is a data carrier for the mapper while its OWN handler owns the decision, so it can publish RequiredScopes to the gateway and still evaluate HasAnyScope(...) || IsServiceBypass(...) on the direct path. AttestorScopeRequirement carries AllowTrustedNetworkBypass for exactly the three policies that had the second branch; AttestorScopeAuthorizationHandler evaluates it.

The published claim set is exactly correct for the gateway path either way, which is what makes this faithful rather than a fudge: IsServiceBypass requires the Authorization header to be ABSENT, and the gateway refuses to forward a request without a token (RequiresAuthentication=true). The bypass branch therefore can never fire for a router-dispatched request, so on the gateway path the policy reduces exactly to its scope set.

Evidence the direct path is untouched: AttestorBypassTenantHeaderTests — the suite that pins the tokenless in-range POST /api/evidence/v1/rekor/entries returning 200 and persisting the caller-supplied tenant — passes unchanged, 4/4, against the converted registrations.

Removing the bypass is still the right end state, and it is now a separate change with a named precondition: four in-estate consumers submit to the Attestor routes with no Authorization header and no identity envelope, and each needs a token-bearing identity first.

The keyless consumer inventory (ROA-3 STEP 1, measured 2026-09-05)

Each row was confirmed by reading the outbound client registration, not inferred from the route.

ConsumerRegistrationUsesIdentity today
Scanner ScanAttestationTransparencySubmittersrc/Scanner/StellaOps.Scanner.WebService/Program.cs:586-600AddHttpClient with no message handlerPOST /api/v1/rekor/entries, attest:createnone; the source comment states the bypass is the auth
ReleaseOrchestrator DeploymentAttestationTransparencySubmitter.../StellaOps.ReleaseOrchestrator.WebApi/Program.cs:1114-1132AddHttpClient with no message handlerPOST /api/v1/rekor/entries, attest:createnone; same comment
Findings.Ledger AttestorVexOverrideTransparencySubmitter.../StellaOps.Findings.Ledger.Application/FindingsLedgerApplicationServiceCollectionExtensions.cs:459-470AddHttpClient with no message handlerPOST /api/v1/rekor/entries, attest:createnone; same comment
Excititor AttestorRemoteAttestationClientsrc/Concelier/StellaOps.Excititor.WebService/Program.cs:303 — bare AddHttpClient<,>POST /api/v1/attestations:sign, POST /api/v1/rekor/entriesnone; sets only the tenant header. Host is not deployed — no excititor-web container exists on this estate
ReleaseOrchestrator HttpScanAttestationVerifier, HttpProvenanceAttestationResolverServices/HttpScanAttestationVerifier.cs:237-262, Services/ProvenanceAttestationResolver.cs:221-262attestor read routesA broken token path that lands on the bypass. Both request the scope "attestor:read" (ProvenanceAttestationResolver.cs:79) — a POLICY NAME, not a catalog scope, and not in stellaops-release-dispatch’s allowedScopes. Authority answers invalid_scope, the catch logs “querying anonymously”, the request goes out tokenless, and the bypass authorizes it

Two callers that look like bypass consumers and are not:

Not currently reachable, which is why removing the bypass breaks nothing today and everything tomorrow. All four keyless consumers are configured against http://attestor.stella-ops.local (or :8442). The predecessor stellaops-attestor container is absent; evidence-web took the NEW aliases evidence.stella-ops.local / evidence-web.stella-ops.local (devops/compose/docker-compose.evidence.yml:336-345) precisely so the two files could not both serve. Measured from inside stellaops-scanner-web on 2026-09-05: attestor.stella-ops.local resolves to 127.1.0.6 (a host loopback publish for the absent container) while evidence.stella-ops.local resolves to 172.19.0.44. Every submission has been failing as BackendUnavailable and degrading the report to self-signed since the cutover. Re-pointing them is the separate, owner-planned EVD-HTTP program (docs/implplan/_evidence/20260903-evd-prewindow-repoint-plan.md, stage 7(b), blocked on G2/B2/B3).

What the bypass removal needs, per consumer. Scanner and ReleaseOrchestrator each already hold a first-party client (stellaops-scanner-web, stellaops-release-dispatch) and an IStellaOpsTokenClient; each needs a token-attaching DelegatingHandler on the attestor HttpClient — the EvidenceLockerServiceTokenHandler shape, taking the per-request tenant from the X-StellaOps-TenantId header the submitters already set — plus an attest:create grant on its client, and RO additionally needs attest:read and the attestor:readattest:read default corrected on both readers. Findings-web and Excititor-web have no suitable outbound identity: findings-web’s only AddStellaOpsAuthClient is the doctor registrar (StellaOps.Findings.WebService/Program.cs:103, scope platform.doctor.register), and widening a doctor identity to sign attestations is a posture decision; excititor-web registers no auth client at all and is not deployed. Those two are an owner call, not an implementer’s, which is why ROA-3 converted the policies and left the bypass in place rather than half-migrating two of four identities.

One more finding, recorded here because it changes what “removing the bypass” buys: the consolidated Evidence host registers AddStellaOpsResourceServerAuthentication with the DEFAULT section (StellaOps.Evidence.WebService/Program.cs:159), so StellaOpsResourceServerOptions.BypassNetworks IS bound there and StellaOpsBypassEvaluator (mode Strict) still grants a tokenless in-range caller any StellaOpsScopeRequirement that is not in NeverBypassableScopes. Deleting AttestorBypassEvaluator alone would therefore NOT close the host’s tokenless in-network surface — the resource-server bypass is a second, independent door. Any posture change has to address both.

ROA-4 outcome (2026-09-05): every inventoried consumer presents an identity; both bypass doors are shut

Re-measured first on the ROA-3 image (e82b1de4dc): through the gateway a scope-less token is 403 on every attestor route, attest:read cannot sign or submit, only attest:create writes; a tokenless caller inside 172.19.0.0/16 gets 401 Bearer on verify, entry GET and submit. The bypass branch was already inert (NetworkMask.Contains refuses an IPv4-mapped IPv6 RemoteIpAddress before comparing a byte, and the dual-stack http://+:8080 listener always presents one), so its removal changed no live behaviour and the posture question resolved as “delete the bypass having established that nothing was using it”.

ConsumerIdentity nowGrantProof
Scanner ScanAttestationTransparencySubmitterScanAttestationServiceTokenHandler on the typed client — one attest:create token per tenant, tenant from X-StellaOps-TenantIdstellaops-scanner-web + attest:createhandler tests 5/5; live mint + submit
ReleaseOrchestrator DeploymentAttestationTransparencySubmitterDeploymentAttestationServiceTokenHandler, same shapestellaops-release-dispatch + attest:create attest:readhandler tests 5/5; live mint + submit
ReleaseOrchestrator readers (HttpScanAttestationVerifier, ProvenanceAttestationResolver)already minted per tenant; the default scope was the POLICY NAME attestor:read (EVD-9 review D-B) — corrected to the claim attest:read in both options and both fallbacksas abovelive mint of attest:read
Findings.Ledger AttestorVexOverrideTransparencySubmitterVexOverrideAttestationServiceTokenHandler (Auth.Client added to the Application library, an allowed client SDK)stellaops-findings-doctor widened with attest:create — findings-web holds ONE auth client, so the registrar-named client IS the host identity; a rename is the Findings owner’s follow-uphandler tests 5/5; live mint
Excititor AttestorRemoteAttestationClientAttestorRemoteAttestationServiceTokenHandler; the host now registers AddStellaOpsAuthClient from Excititor:Attestation:Attestor:Identity and fails closed at startup when the Attestor section is configured without itnew stellaops-excititor-web (attest:create), secret EXCITITOR_WEB_AUTHORITY_CLIENT_SECREThandler tests 4/4; live mint (host not deployed here)

Every handler sends NOTHING it cannot mint: no tenant header, no registered auth client, or a failed mint leaves the request unauthenticated and the Attestor refuses it visibly — never a tokenless write.

A fifth tokenless consumer the ROA-3 inventory did not list: Excititor’s Rekor verifier (RekorHttpClient in StellaOps.Excititor.Attestation, registered by the Excititor:Attestation:Rekor section, census EVD-HTTP-04) reads GET api/v1/rekor/entries/{uuid} on the internal Attestor tokenless. It is a generic transparency-log client with no tenant in its shape, the host is not deployed, and it is refused (401) after ROA-4 like any anonymous caller. Re-homing it belongs with Excititor’s deployment, not this row.

Both doors, closed together, in code and in compose. AttestorScopeRequirement lost AllowTrustedNetworkBypass, AttestorBypassEvaluator is deleted and AttestorScopeAuthorizationHandler evaluates the scope set alone; StellaOps.Evidence.WebService/Program.cs sets StellaOpsBypassMode.Disabled and clears BypassNetworks in the resource-server configure lambda, so a stray Authority:ResourceServer:BypassNetworks entry cannot reopen the shared door; docker-compose.evidence.yml declares no bypass key and EvidenceConsolidationComposeConformanceTests pins the absence on both roles. The anonymous X-StellaOps-TenantId tenant vehicle (ANCHOR-04-1) went with the bypass — the tenant comes from the token claim only. AttestorBypassTenantHeaderTests asserts the NEW behaviour on a host whose configuration still names a matching bypass CIDR: tokenless in-range → 401, nothing persisted (red-proved against the pre-ROA-4 code: 2 of 4 fail there).

Reconciling the sprint’s “80” — raw, production, reachable

Three numbers, because they answer three different questions:

CountValueWhat it is
Raw, every .cs under src/128 occurrences / 46 files at seed commit 6f71ae03e5 (rg -c "RequireAssertion\s*\("; grows with every test fixture that mentions the token, including ROA-1’s own), or 80 as the sprint row counted itIncludes test scaffolding (Concelier, ReleaseOrchestrator, Notify, Scanner, Platform, AirGap test projects; EvidenceLockerWebApplicationFactory.cs:267 in the retiring predecessor’s tests; EvidenceHostCompositionTests.cs:469), src/__Obsoleted/, and comment mentions. None of these can be “converted”; a register seeded from this number would carry entries that never burn down.
Production registrations66 at seed, 60 after ROA-1, 54 after ROA-2What the register pins and the conformance scan counts: RequireAssertion call sites in non-test, non-obsoleted src/, comments and strings stripped. The row’s 80 minus ExportCenter 11 (StellaOps.ExportCenter.Tests/), EvidenceLocker 1 (StellaOps.EvidenceLocker.Tests/ — test projects not under a __Tests/ folder, which is how a __Tests-only filter counted them) and Authority’s 2 // comment mentions in IssuerDirectory Program.cs.
Production AND gateway-reachable in the shipped posture18 at seed, 12 after ROA-1, 6 after ROA-2 (all Attestor)The number the decision on (b) turns on: registrations in a router-dispatched host, in the branch compose takes. Everything else is an Authority-disabled branch (44), an undeployed host (3) or dead code (1).

Finding 2: Router__OnMissingAuthorization was a dead knob — DELETED 2026-09-07

Found by the EVD-9 review lane on 2026-09-05, verified in source, resolved by ROA-1(b).

The fail-closed flip (ROA-1(b), landed 2026-09-07)

AuthorizationMiddleware no longer passes an authenticated request through on an empty claim list. The rule, in the order it is evaluated:

The ROA-1(b) rule, verbatim. AuthorizationMiddleware evaluates, in this order:

endpoint shapegateway
AllowAnonymouspass
not authenticated, not anonymous401
claims publishedenforce them
zero claims, no named policy declaredpass — a bare RequireAuthorization() / [Authorize], or no authorization metadata at all. Authentication IS the whole rule the service declared, and the 401 gate already applied it
zero claims, named policy, AuthorizationFullyMappedpass — the service resolved the policy and found only an authentication requirement
zero claims, named policy, anything else403 — either a requirement the mapper cannot read, or a service build that does not report. Both mean the gateway does not know the rule it is meant to enforce

The distinction is PUBLISHED by the service, never inferred by the gateway: EndpointDescriptor.AuthorizationFullyMapped (default false) and UnmappedAuthorizationRequirements are set by DefaultAuthorizationClaimMapper, the only component that knows whether it could read a requirement. Keying the refusal on the DECLARED-POLICY list is what bounds the blast radius to sets enumerable from source and gated shrink-only; keying it on “claims are empty” also catches every authentication-only endpoint, which is an estate-wide outage.

The 48 RequireAssertion registrations pinned above and the one zeroClaimByDesign row ARE those enumerable sets, and both gates are shrink-only, so the bound stays true. The default is the unsafe-looking one on purpose: a descriptor from a service built before this field says nothing, and “says nothing” must not mean “trusted”.

What that means for a rollout, stated plainly. The flip bites immediately for un-rebuilt services for their named-policy zero-claim endpoints — that is what makes a gateway-only redeploy worth doing — and it needs graph-api rebuilt in the same window, because Graph’s controller fallback is exactly that shape and its descriptors will not carry the new flag until it is. The three carried ReachGraph controllers (v1/cve-mappings, v1/reachgraphs, v1/reachability) are the routes at stake. GraphAuthenticationOnlyPolicyPublicationTests measures the assurance on the real Graph host so the precondition is a test result rather than a belief.

Pinned by PublishedAuthorizationSemanticsTests on the real chain (framework policy → mapper → wire → claims store → gateway): an assertion policy is now refused whether the framework would have denied it or allowed it, an opaque custom requirement is refused, and an authentication-only policy is still served to an authenticated caller and still 401 to an anonymous one. The row that pinned the old divergence, AssertionPolicy_DeniedByFramework_ButPassesGateway_UntilFailClosedLands, was deleted in the same change. Red-proved 2026-09-07 by restoring the fail-open branch: 5 rows fail, three of them with Expected: 403 / Actual: 204.

The layer the flip does NOT close, measured

An endpoint with no authorization metadata at all declares no policy, so the gateway passes it to any authenticated caller — before and after the flip. That is Finding 2’s layer, and this is its first real measurement, taken from the production Graph host’s own discovery output on 2026-09-07 (GraphAuthenticationOnlyPolicyPublicationTests run, AspNetCoreEndpointDiscoveryProvider warnings):

23 distinct graph-api routes carry no authorization metadata, each published twice (legacy and canonical alias) for 45 descriptor rows. They include POST /graph/query, POST /graph/search, GET /search, GET /graphs, GET /graphs/{graphId}, GET /paths, POST /graph/lineage, POST /graph/diff, POST /graph/export, GET /nodes/{nodeId}/adjacency, GET /assets/{assetId}/snapshot and the saved-views GET/POST/DELETE trio.

This is recorded, not fixed: it is Graph’s authorization posture, not the Router defect ROA-1 is about, and closing it means giving each route a scope or taking the RequireExplicit decision estate-wide.

Treat it as a FINDING, not as a pending knob decision (orchestrator ruling, 2026-09-07). The knob that was supposed to govern this never existed as configuration and has now been deleted, so nothing about a knob is outstanding. What is outstanding is a measured exposure: routes the gateway serves to any authenticated caller because the service declared no rule at all. The 23 above are the first datum; the estate-wide inventory is the next row’s work, and RequireExplicit — which refuses to start a host carrying an unannotated endpoint — cannot be turned on before that inventory exists.

What preceded remediation (b) — the checklist, and how it closed

Recorded as it stood, with each item’s outcome, because the ordering is the reusable part.

  1. Convert or re-home every live unmappable registration. Done before the flip: AdvisoryAI 6 (ROA-1), Notify 2 + AirGap 4 (ROA-2), Attestor 6 (ROA-3), each with a live redeploy. reachableUnenforcedNow reached 0 at ROA-3 and the flip landed against that number, which is why it changed no live verdict on any converted family.
  2. Decide the fate of the BRANCH-NOT-TAKEN rows (44), NOT-DEPLOYED (3) and DEAD (1). They stay pinned and the register did not shrink at the flip. They are Authority-disabled branches the gateway already answers 401 for (the branch registers permit-all policies but the endpoints are still not AllowAnonymous, so the unauthenticated gate fires first), an undeployed host, and a helper with no callers. The flip changes their outcome from “pass” to “refuse” the day one of those branches is taken, which is the intended posture — converting them is not a precondition for it.
  3. Inventory the endpoints that publish zero claims for reasons OTHER than an unmappable policy, so (b) does not turn those into 403. Done, and it split in two:
    • Named policies with no claim-bearing requirement: exactly ONE in production src/GraphPolicies.Authenticated. Pinned under zeroClaimByDesign with its reason and gated shrink-only by Estate_EveryZeroClaimAuthorizationPolicyIsPinnedAndShrinkOnly (red-proved by planting one in src/Signals/StellaOps.Signals/Program.cs, which failed naming the file).
    • Endpoint-level authentication-only gates — a bare RequireAuthorization()/[Authorize] (12 production call sites) and endpoints with no authorization metadata at all. Not pinned and not refused: they declare no policy, so the rule does not fire. Measured for Graph in the section above.
  4. Delete the pin that held the old behaviour. Done, replaced by its inverse plus two rows the old pin did not cover.

Re-verifying this document