stellaops-cli scope census and the operator-identity split
Verified against: commit 387a960602 (2026-09-05, SPRINT_20260722_016 AUTH-15). Re-verified for the analytics.read withdrawal against: commit dff4651e77 (2026-10-01, SPRINT_20260930_006 PCD-228). Re-verified for the policy:operate admission against: commit 78eb198a8f (2026-10-09, SPRINT_20261008_004 WSF-2). Re-verify:
# the ceiling, production source floor, YAML grant, migration lineage, startup companions and rollback agree
pwsh ./tools/scripts/test-targeted-xunit.ps1 \
-Project src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj \
-Class "*StellaOpsCliClientScopeCeilingTests*"
# the persisted row on a running estate is within the ceiling (reads authority.clients, not the YAML)
bash tools/scripts/deploy/postgres/apply-stellaops-cli-scope-ceiling.sh --verify
PCD-323 default-posture convergence
Startup migration 034_authority_stellaops_cli_scope_ceiling.sql applies the complete human CLI ceiling even when Authority:Bootstrap:Enabled=false. It intersects the persisted stellaops-cli.allowed_scopes with the same ceiling as S065 and repairs the properties.allowedScopes mirror. An absent client is a no-op, and a row already within the ceiling is not rewritten. The migration creates no client and adds no scope. Its successor 035_authority_stellaops_cli_scope_ceiling_policy_operate.sql (WSF-2) does the same with the current S066 ceiling; 034 stays applied and unchanged.
The Standard bootstrapper’s union cannot restore a surplus because the mounted descriptor is pinned within that ceiling. Keep the Startup companion, current Seed ceiling and StellaOpsCliClientScopeCeilingTests aligned when a future reviewed ceiling changes. The operator script remains a verification and explicit repair tool; normal upgrades converge through startup rather than requiring its --apply step.
Re-verify default-posture SQL behavior with CliScopeCeilingStartupTests in the Authority persistence suite: an old surplus row, JSON-only drift, unrelated-client isolation, NOBYPASSRLS execution, no Seed application and idempotent fresh/repeated startup.
PCD-285 source floor and read retirement at its accepted revision
The 2026-10-05 census finds 38 distinct StellaOpsScopes constants in production CLI source. The ceiling test pins that exact roster and verifies each value; test projects and build output are excluded. The earlier tier totals below are the dated AUTH-15 review, not a fresh source count.
S064 removes orch:read and scheduler:read from the permitted ceiling. Startup 032 removes exactly those permissions from the persisted CLI column and JSON mirror even with Seed disabled. The mounted YAML loses the same two grants, so a union reseed cannot restore them. Other clients and the scope catalog remain unchanged.
The source census also finds symbols:read and symbols:write on the existing Symbols client. S064 admits them to the permitted ceiling for that existing route/policy contract. Neither enters the YAML grant, and an intersection cannot add a missing scope. No standing Symbols or administrative grant is introduced. At the PCD-285 revision the ceiling contained 92 entries and the standing YAML grant narrowed from 84 to 82.
PCD-296 call graph upload
scan graph --upload calls POST /api/v1/scans/{scanId}/callgraphs through the backend operations client and requests ScannerCallGraphIngest (scanner.callgraph.ingest). Scanner registers that policy in ScannerWebService/Program.cs. S065 adds this one existing catalog permission to the permitted ceiling (93 entries), and the mounted CLI descriptor grants it. Registry handlers now name their three existing read/cache/admin constants, so the source floor becomes 42 constants. The standing grant becomes 83 scopes. A provider restart converges existing clients through the provisioning union; no other identity gains a permission.
WSF-2 risk budget consumption — 2026-10-09
The CLI budget consume command (src/Cli/StellaOps.Cli/Commands/Budget/RiskBudgetCommandGroup.cs) requests StellaOpsScopes.PolicyOperate (policy:operate) and calls the Policy Engine consume route listed in the budget command guide. src/Policy/StellaOps.Policy.Engine/Endpoints/RiskBudgetEndpoints.cs registers that route with RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate). Until this change the scope was outside the ceiling, so Authority refused the token request and the command never reached the owner.
Decision. Owner ruling 2026-10-09, recorded in the CLI scope-floor follow-up sprint (WSF-2, option (a)), admits policy:operate to the ceiling instead of withdrawing the verb. For the human CLI consume verb this ruling supersedes the AUTH-15 Table B entry, which classified policy:operate as per-incident on stellaops-break-glass-operator.
- Seed
S066_stellaops_cli_scope_ceiling_policy_operate.sqladds this one catalog permission to the permitted ceiling (94 entries). Startup035_authority_stellaops_cli_scope_ceiling_policy_operate.sqlapplies the same ceiling with bootstrap disabled. Both intersect and cannot add a scope. - The mounted CLI descriptor grants the scope, so the standing grant becomes 84 scopes and a provider restart puts it on existing rows through the provisioning union. No other identity gains a permission.
- The source floor pins
PolicyOperate(45 constants). The rollback list already containedpolicy:operatefrom the pre-AUTH-15 row, so it stays at 145 scopes.
Effect beyond the consume verb. A token issued to stellaops-cli with this scope also satisfies the other Policy Engine routes that require it: unknowns budget check (BudgetEndpoints.cs), console export jobs (ConsoleExportEndpoints.cs), four exception routes (Gateway/ExceptionEndpoints.cs), gate jobs (Gateway/GateJobEndpoints.cs), the NIS2 control-register snapshot (Nis2ControlRegisterEndpoints.cs) and two unknowns routes (UnknownsEndpoints.cs). policy:operate is not one of the PCD-290 operation-bound scopes, so a password grant that omits scope also receives it. Policy Studio scopes still require a tenant assignment.
Re-verify with StellaOpsCliClientScopeCeilingTests (the guard named above), MigrationS066StellaOpsCliPolicyOperateCeilingTests and CliScopeCeilingStartupTests in the Authority persistence suite. A running estate gets the new ceiling and grant only after Authority is rebuilt from a commit that carries them and restarted.
General login versus operation-bound grants
PCD-290 (owner decision, 2026-10-04): a password grant that omits scope receives the client’s allowed scopes except policy:publish, policy:promote, obs:incident, packs.approve, exceptions:approve and release:gate-bypass. A login supplies no operation metadata or approval context. Keeping these permissions in the ceiling lets an explicit operation request them under the existing metadata, MFA and authorization checks; it does not make them general login scopes. This changes no client ceiling, automation grant or explicitly requested scope.
Re-verify the rule and the explicit-request negative controls with PasswordGrantHandlersTests in StellaOps.Authority.Tests, including NoScopePasswordGrant_OmitsOperationBoundScopes_WithoutPrincipalFallbackReintroducingThem.
Watchlist source boundary — 2026-10-07
Verified against c0b0490da306d66a818486b09ae9fd440a3bda1b. Evidence composes the watchlist API at /api/evidence/v1/watchlist. WatchlistEndpoints.cs attaches the watchlist:read / watchlist:write policies; Security/AttestorPolicies.cs admits the corresponding trust:read / trust:write catalog scopes and the documented higher-privilege alternatives.
CLI consumer (PCD-449; re-verified 2026-10-08 against d1931de29f). The fail-closed placeholder this section described on 2026-10-07 is gone. The CLI now calls the owner. src/Cli/StellaOps.Cli/Commands/Watchlist/WatchlistCommandHandlers.cs sends POST and GET on the collection, GET, PUT and DELETE on {id} (update reads the definition first) and POST {id}/test, all under /api/evidence/v1/watchlist. List, get and test request trust:read. Add, update and remove request trust:write. Both scopes are in the ceiling and the standing grant, and the source-floor roster pins TrustRead and TrustWrite. The CLI never requests trust:admin, which stays outside the ceiling (Table B). Creating or deleting a global definition must pass the owner’s in-endpoint administrator check (trust:admin, watchlist:admin or the admin role); a caller who fails it gets 403. The CLI sends no alert-history request. Re-verify the CLI side with WatchlistOwnerRouteTests in StellaOps.Cli.Tests.
The Table A route bindings below for trust:read and trust:write cite the predecessor command group and route. They are historical AUTH-15 census evidence; the paragraph above is the current binding. Neither correction changes a grant or the scope ceiling.
The current ceiling/source roster is owned by src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOpsCliClientScopeCeilingTests.cs, which selects S066 and Startup 035. Re-run that guard rather than treating older tier counts or migration lists below as today’s grant. Startup 034 and its successor 035 apply the complete ceiling with bootstrap disabled, as the PCD-323 section above explains; older text saying an operator script is mandatory is superseded.
Guard failure resolved (2026-10-09). On 2026-10-08 SourceScopeFloor_MatchesTheCurrentProductionCliCensus failed on main because PCD-427’s budget consume names StellaOpsScopes.PolicyOperate, which was outside the ceiling. The owner chose to admit the scope (WSF-2, see the section above). S066, Startup 035, the standing grant and the guard’s roster now include it, and the whole class passes.
Historical AUTH-15 census and operator notes withdrawn 2026-10-07
The material below preserves the 2026-09-05 review, later dated annotations and its historical procedures. Its static counts, route bindings, rollback captures and pre-PCD-323 default-posture instructions do not override current source or the accepted changes above. No current database repair is authorized by this historical appendix.
Why this document exists
stellaops-cli is the shipped human CLI client (authorization_code password refresh_token). Until 2026-09-05 it was also the estate’s manual-mint and break-glass identity: every operator script, runbook curl and QA harness that needed a human-bound token minted through it, and every grant those procedures ever needed was appended to its allowedScopes. The live authority.clients row carried 143 scopes; the shipped CLI can exercise 88. Two consequences followed:
- every CLI login token was a potential estate-administration token, bounded only by the user’s role;
- the only audience common to every break-glass token was the estate-wide
stellaops, which is why the router gateway’s introspection identity forfindings:recoveryhad to be registered under it (SPRINT_20260722_010FND-17).
This page records the census that decided what the CLI client keeps, where everything else went, and the two mechanisms that keep the split from silently regressing.
Method — how “the CLI needs it” was decided
A scope stays on stellaops-cli when one of three machine-checkable facts holds:
| Tier | Evidence | Count |
|---|---|---|
| 1 | src/Cli (non-test) references the StellaOpsScopes constant or the exact string literal | 42 |
| 2 | the CLI’s own product documentation (docs/modules/cli/**) names the scope | +17 |
| 3 | a route literal in src/Cli targets an endpoint whose authorization policy requires the scope | +29 |
| OIDC | openid profile email offline_access | +4 |
These tier counts record the earlier review. The current source-constant floor is verified above; the ceiling contains 92 entries. AUTH-15 recorded 41 / +18 / +29 / +4 = 92 (this table said +19 until 2026-10-01; Table A as first committed, ced09a106c, lists 18 tier-2 rows). envstate:time-anchor:write joined tier 1 on 2026-09-14 (S060, 93) and analytics.read left tier 2 on 2026-10-01 (S063, 92).
orch:quota is named in docs/modules/cli but excluded: the token endpoint requires quota_reason for it and the CLI never sends one (S001 records the same decision). Tier 1 alone is the “27 constants” figure often quoted; it under-counts because the CLI supplies most scopes through the operator’s configured Authority:Scope rather than naming them, and because a whole endpoint group can be gated by a scope the CLI never spells out (/console/* requires ui.read, so stella auth token mint needs it).
Everything else on the live row (59 scopes) leaves the CLI client. Each was checked for a consumer: operator scripts under devops/ and tools/, .gitea workflows, QA harnesses and fixtures under src/__Tests and src/Web, and runbook curl recipes. The consumers group into five operational procedures, and each procedure now has its own password-grant identity carrying exactly the union of scopes its scripts request.
Ten ceiling scopes are not granted today and were not added by this change (a narrowing change adds nothing): authority:idp.read, authority:idp.write, obs:read, ops.admin, platform.setup.admin, platform.setup.read, policy:promote, vex:ingest. The CLI names them; symbols:read and symbols:write are also permitted but ungranted. The corresponding verbs cannot mint through stellaops-cli until an operator adds them, which the ceiling permits.
Table A — the ceiling (92 scopes; 82 granted)
2026-09-14 (SPRINT_20260914_002 EA-5): envstate:time-anchor:write joined the ceiling and the grant. stella airgap time-anchor declare PUTs /api/v1/platform/environment-state/time-anchor/{scope} and Platform’s custody policy for that class accepts only this scope (no legacy alias, unlike airgap-seal); the CLI now requests it per class (BackendOperationsClient.ResolveEnvironmentStateWriteScope). The widening shipped as migration S060 in S058’s intersection shape — S058 is applied and checksum-pinned, so it is never edited.
2026-10-01 (SPRINT_20260930_006 PCD-228): analytics.read left the ceiling and the grant; its row moved to Table B. It was tier 2 (docs/modules/cli/guides/commands/analytics.md). PCD-127 withdrew the CLI analytics command group with Platform’s /api/analytics/* endpoints, no src/Cli source names the scope, and the one surface that still enforces it, Platform’s NIS2 telemetry group (/api/telemetry/nis2, PlatformPolicies.AnalyticsRead), is called by no CLI route literal (stella export nis2-effectiveness reads a local --report file and posts it to ExportCenter under export.operator / export.viewer). The narrowing shipped as migration S063 in the same intersection shape, with startup companion 031 for the default posture (see the union-reseed section). The guard pins each successor’s exact delta: S058 → S060 adds envstate:time-anchor:write; S060 → S063 drops analytics.read.
| Scope | Tier | Granted to stellaops-cli | Evidence |
|---|---|---|---|
advisory-ai:operate | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Program.cs:194 |
advisory-ai:view | 3 (CLI route to endpoint policy) | yes | CLI advisory-ai chat/rate-limit GETs (dominated by advisory-ai:operate, kept for least-privilege reads) -> AdvisoryAI.WebService/Security/AdvisoryAIPolicies.cs:55 (ViewPolicy) |
advisory:ingest | 2 (CLI docs) | yes | docs/modules/cli/guides/commands/auth.md:56 |
advisory:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Program.cs:829 |
airgap:import | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:2214 |
airgap:seal | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:133 |
airgap:status:read | 3 (CLI route to endpoint policy) | yes | CLI airgap status (/api/offlinekit/v1) -> OfflineKit.WebService/Program.cs (OfflineKitPolicies.StatusRead) |
aoc:verify | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Program.cs:830 |
attest:create | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/AttestorLinkClient.cs:62 |
attest:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/AttestorLinkClient.cs:63 |
authority:idp.read | 1 (CLI source) | no (pre-existing gap, not added) | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3132 |
authority:idp.write | 1 (CLI source) | no (pre-existing gap, not added) | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3139 |
authority:roles.read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:6541 (GET console/admin/roles) -> Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs:161 |
authority:roles.write | 3 (CLI route to endpoint policy) | yes | BackendOperationsClient.cs:6564,6594 (POST/PATCH console/admin/roles) -> ConsoleAdminEndpointExtensions.cs:166,173 |
authority:tenants.read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Commands/Nis2SoaCommandGroup.cs:300 |
authority:tenants.write | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Commands/Assurance/OperatorComplianceConfigCommandGroup.cs:338 (PUT operator-compliance) -> ConsoleAdminEndpointExtensions.cs:88-89 |
concelier.jobs.trigger | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:25 |
concelier.merge | 2 (CLI docs) | yes | docs/modules/cli/architecture.md:210 |
crypto:admin | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PlatformSecretProviderClient.cs:127 |
crypto:kek:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:71 |
crypto:kek:rotate | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:72 |
crypto:profile:admin | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/TenantCryptoComplianceClient.cs:41 |
crypto:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:73 |
email | OIDC | yes | standard OpenID Connect scope |
envstate:time-anchor:write | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs (ResolveEnvironmentStateWriteScope, PUT api/v1/platform/environment-state/time-anchor/{scope}) -> Platform.WebService/Program.cs (PlatformPolicies.EnvironmentStateTimeAnchorWrite) |
evidence:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs:344 |
exceptions:approve | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Services/ExceptionClient.cs (/{id}/{transition}) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:65-85 |
exceptions:read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Services/ExceptionClient.cs (/api/policy/exceptions) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:53 |
exceptions:request | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Services/ExceptionClient.cs (POST /api/policy/exceptions) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:39 |
export.operator | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Commands/AssuranceProduceClient.cs:34 |
export.viewer | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Commands/AssuranceProduceClient.cs:34 |
findings:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/CvssClient.cs:69 |
findings:write | 2 (CLI docs) | yes | docs/modules/cli/findings-runtime.md:51 |
graph:read | 3 (CLI route to endpoint policy) | yes | CLI /api/graphs/ -> Graph.Api/Security/GraphPolicies.cs:36 |
integration:operate | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs:175 |
integration:read | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/integrations, /api/v1/sources, /api/v1/feeds -> Integrations.WebService/Program.cs (IntegrationPolicies.Read) |
integration:write | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/integrations (register/update) -> Integrations.WebService/Program.cs (IntegrationPolicies.Write) |
notify.operator | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/Nis2IncidentClient.cs:25 |
notify.viewer | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/Nis2IncidentClient.cs:26 |
obs:read | 1 (CLI source) | no (pre-existing gap, not added) | src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs:177 |
offline_access | OIDC | yes | standard OpenID Connect scope |
openid | OIDC | yes | standard OpenID Connect scope |
ops.admin | 2 (CLI docs) | no (pre-existing gap, not added) | docs/modules/cli/guides/admin/admin-reference.md:35 |
ops.health | 2 (CLI docs) | yes | docs/modules/cli/guides/admin/admin-reference.md:35 |
orch:backfill | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:62 |
orch:operate | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:55 |
packs.approve | 2 (CLI docs) | yes | docs/modules/cli/guides/packs-profiles.md:47 |
packs.read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PackClient.cs:74 |
packs.run | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PackClient.cs:136 |
packs.write | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/PackClient.cs:314 |
platform.setup.admin | 1 (CLI source) | no (pre-existing gap, not added) | src/Cli/StellaOps.Cli/Services/PlatformEnvironmentSettingsClient.cs:30 |
platform.setup.read | 1 (CLI source) | no (pre-existing gap, not added) | src/Cli/StellaOps.Cli/Services/PlatformEnvironmentSettingsClient.cs:29 |
policy:activate | 2 (CLI docs) | yes | docs/modules/cli/guides/policy.md:6 |
policy:audit | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Commands/Nis2SoaCommandGroup.cs:293 (api/v1/tenants/{t}/compliance-profile) -> Authority/Tenants/TenantComplianceProfileEndpointExtensions.cs:37 |
policy:edit | 2 (CLI docs) | yes | docs/modules/cli/guides/policy.md:6 |
policy:promote | 2 (CLI docs) | no (pre-existing gap, not added) | docs/modules/cli/guides/cli-reference.md:495 |
policy:publish | 2 (CLI docs) | yes | docs/modules/cli/guides/cli-reference.md:495 |
policy:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/ExceptionClient.cs:70 |
policy:run | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/CvssClient.cs:57 |
policy:simulate | 3 (CLI route to endpoint policy) | yes | CLI policy simulate (/api/v2/simulate, /api/vuln/simulate, /api/risk/simulate) -> Policy.Engine/Endpoints/ConsoleSimulationEndpoint.cs:19; JobEngine PolicySimulationEndpointExtensions.cs:20 |
profile | OIDC | yes | standard OpenID Connect scope |
registry.admin | 3 (CLI route to endpoint policy) | yes | RegistryCommandGroup.cs:1158,1204 (PUT/DELETE /api/v1/registry/upstreams) -> ReleaseOrchestratorPolicies.cs (RegistryUpstreamAdmin) |
registry:cache:admin | 3 (CLI route to endpoint policy) | yes | RegistryCommandGroup.cs:974,1022 (PUT/DELETE /api/v1/registry/cache/policies) -> ReleaseOrchestratorPolicies.cs (RegistryCacheAdmin) |
registry:read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Commands/RegistryCommandGroup.cs (GET upstreams, cache/stats) -> ReleaseOrchestratorPolicies.cs (RegistryUpstreamRead/RegistryCacheRead) |
release:publish | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/release-orchestrator/approvals -> ReleaseOrchestratorPolicies.cs:100 (ReleaseApprove = ReleasePublish) |
release:read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/OperatorDecisionHttpClientRegistration.cs (/api/v1/release-orchestrator/*) -> ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs (ReleaseRead) |
release:write | 3 (CLI route to endpoint policy) | yes | CLI release approve/reject/promote/deploy/rollback (/api/v1/release-orchestrator/operator-decisions) -> ReleaseOrchestratorPolicies.cs:94 (ReleaseWrite) |
replay:read | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/replay/bundles (GET only) -> Replay.WebService/ReplayAuthorizationPolicies.cs:41 |
sbom:read | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/sboms, /api/v1/sboms/{id}/export -> SbomService/Program.cs:98 (SbomPolicies.Read) |
sbom:write | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/sbom/upload -> SbomService/Program.cs:1518-1520 (SbomPolicies.Write) |
scanner:export | 2 (CLI docs) | yes | docs/modules/cli/architecture.md:719 |
scanner:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/ScmImportClient.cs:93 |
scanner:scan | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/ScmImportClient.cs:69 |
scanner:write | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/scans, /api/v1/triage, /api/v1/sources -> Scanner.WebService/Program.cs (ScansWrite/SourcesWrite/TriageWrite) |
score.evaluate | 3 (CLI route to endpoint policy) | yes | CLI api/v1/score/evaluate -> Signals/Score/Endpoints/ScoreEndpoints.cs:92 (ScorePolicies.ScoreEvaluate) |
score.read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Program.cs:803 |
signer:read | 2 (CLI docs) | yes | docs/modules/cli/architecture.md:522 |
signer:sign | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Commands/DoraRoiSigningPipelineFactory.cs:196 |
symbols:read | 1 (CLI source) | no | Existing Symbols reader in src/Cli/StellaOps.Cli/Services/SymbolOwnerClientRegistration.cs; owner SymbolManagementEndpoints registers this read scope |
symbols:write | 1 (CLI source) | no | Existing manifest writer requests this scope plus ops.admin; owner writes require Symbols write and admin policies |
timeline:read | 3 (CLI route to endpoint policy) | yes | CLI /api/v1/timeline/events, /snapshots (GET only) -> Timeline.WebService/Program.cs (TimelinePolicies.Read) |
trust:read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Commands/WatchlistCommandGroup.cs (GET /api/v1/watchlist) -> Attestor.Application/AttestorWebServiceComposition.cs:390 (watchlist:read) |
trust:write | 3 (CLI route to endpoint policy) | yes | WatchlistCommandGroup.cs (POST/PUT/DELETE /api/v1/watchlist) -> AttestorWebServiceComposition.cs:405 (watchlist:write) |
ui.admin | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3131 |
ui.read | 3 (CLI route to endpoint policy) | yes | src/Cli/StellaOps.Cli/Services/AuthorityConsoleClient.cs (console/tenants, console/token/*) -> StellaOps.Authority/Console/ConsoleEndpointExtensions.cs:27 (/console group RequireStellaOpsScopes(UiRead)) |
vex.admin | 2 (CLI docs) | yes | docs/modules/cli/architecture.md:722 |
vex:ingest | 2 (CLI docs) | no (pre-existing gap, not added) | docs/modules/cli/architecture.md:722 |
vex:read | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs:146 |
vuln:audit | 2 (CLI docs) | yes | docs/modules/cli/guides/vuln-explorer-cli.md:19 |
vuln:investigate | 2 (CLI docs) | yes | docs/modules/cli/guides/vuln-explorer-cli.md:17 |
vuln:operate | 2 (CLI docs) | yes | docs/modules/cli/guides/vuln-explorer-cli.md:18 |
vuln:view | 1 (CLI source) | yes | src/Cli/StellaOps.Cli/Services/ConcelierObservationsClient.cs:364 |
Table B — the 62 scopes that left stellaops-cli
“Consumers” counts the census entries (Table C) that requested the scope through stellaops-cli. 59 left at AUTH-15 (2026-09-05); analytics.read left on 2026-10-01 (PCD-228), and the two JobEngine reads left under PCD-285.
| Scope | Consumers | New home | Note |
|---|---|---|---|
orch:read | 0 | Purpose-scoped operators keep it | PCD-285: withdrawn CLI JobEngine commands have no current consumer; S064 and Startup 032 retire it from the CLI only |
scheduler:read | 0 | Purpose-scoped operators keep it | PCD-285: withdrawn CLI JobEngine client has no current consumer; S064 and Startup 032 retire it from the CLI only |
analytics.read | 0 | none needed by the CLI; stella-ops-ui keeps it for the NIS2 effectiveness dashboard | PCD-228: tier 2 until PCD-127 withdrew the CLI analytics group; no CLI route reaches /api/telemetry/nis2. Removed by S063 (Seed) and 031 (Startup) |
authority.audit.read | 0 | none; per incident on stellaops-break-glass-operator | console audit read; no CLI route |
authority.revocation.read | 0 | none; per incident on stellaops-break-glass-operator | /revocation/status read; the CLI reads revocation exports through the bootstrap key, not this scope |
authority:clients.read | 2 | stellaops-estate-admin-operator, stellaops-onboarding-operator | console client administration; no CLI route |
authority:clients.write | 2 | stellaops-estate-admin-operator, stellaops-onboarding-operator | console client administration; no CLI route |
authority:signing-keys.admin | 0 | none; per incident on stellaops-break-glass-operator | operator signing administration; no CLI verb |
authority:signing-keys.enroll | 0 | none; per incident on stellaops-break-glass-operator | operator signing enrollment; no CLI verb |
authority:tokens.read | 0 | stellaops-estate-admin-operator | console token inventory; no CLI route |
authority:tokens.revoke | 0 | stellaops-estate-admin-operator | POST /console/admin/tokens/revoke, the break-glass revoke step |
authority:users.read | 4 | stellaops-estate-admin-operator, stellaops-onboarding-operator | console user administration; no CLI route |
authority:users.write | 4 | stellaops-estate-admin-operator, stellaops-onboarding-operator | console user administration; no CLI route |
decision-signing | 0 | none; per incident on stellaops-break-glass-operator | no CLI verb |
doctor:admin | 0 | none; per incident on stellaops-break-glass-operator | no endpoint requires it (S049 retired the doctor scopes) |
doctor:run | 0 | none; per incident on stellaops-break-glass-operator | no endpoint requires it (S049 retired the doctor scopes) |
evidence:create | 5 | stellaops-onboarding-operator, stellaops-compliance-operator, stellaops-release-ops-operator | the CLI only reads evidence; compliance, onboarding and release-ops windows create it |
evidence:hold | 1 | stellaops-release-ops-operator | evidence legal hold (VerdictPolicies.Admin); release-ops window |
export.admin | 0 | none; per incident on stellaops-break-glass-operator | profile archive / legacy DELETE; no CLI verb |
issuer-directory:admin | 0 | none; per incident on stellaops-break-glass-operator | see issuer-directory:read |
issuer-directory:read | 0 | none; per incident on stellaops-break-glass-operator | the CLI states no issuer-directory backend is wired |
issuer-directory:write | 0 | none; per incident on stellaops-break-glass-operator | see issuer-directory:read |
notify.admin | 0 | none; per incident on stellaops-break-glass-operator | channel create/test/send/retry are notify.operator; no CLI route needs admin |
notify.escalate | 0 | none; per incident on stellaops-break-glass-operator | no CLI verb |
operator.verify.admin | 0 | none; per incident on stellaops-break-glass-operator | no policy registration or endpoint requires it |
operator.verify.read | 0 | none; per incident on stellaops-break-glass-operator | no policy registration or endpoint requires it |
orch:quota | 0 | none; per incident on stellaops-break-glass-operator | client_credentials quota-raise scope; the CLI never sends quota_reason (S001 decision) |
platform.context.read | 5 | stellaops-onboarding-operator | /api/v2/context/* (Platform); the CLI never calls it. QA harnesses requested it without using it |
platform.context.write | 2 | stellaops-onboarding-operator | /api/v2/context/* (Platform); the CLI never calls it |
platform:federation:read | 0 | none; per incident on stellaops-break-glass-operator | ReleaseOrchestrator federation; the CLI federation verbs target Concelier (Concelier.Advisories.*) |
platform:federation:write | 0 | none; per incident on stellaops-break-glass-operator | see platform:federation:read |
platform:sar:erase | 1 | stellaops-estate-admin-operator | subject-access erasure (privacy administration) |
platform:sar:read | 1 | stellaops-estate-admin-operator | subject-access requests (privacy administration) |
policy:approve | 0 | none; per incident on stellaops-break-glass-operator | promotion-gate override; not a CLI route |
policy:author | 5 | stellaops-compliance-operator | exception/gate authoring endpoints the CLI does not call; the compliance golden path uses it |
policy:operate | 0 | none; per incident on stellaops-break-glass-operator | budget check / console export jobs; the CLI only lists budgets (policy:read). Superseded 2026-10-09 by the WSF-2 owner ruling: back in the ceiling and the standing grant for the CLI budget consume command (S066, Startup 035) |
policy:review | 0 | none; per incident on stellaops-break-glass-operator | no serving endpoint; the CLI review verbs target routes no backend registers |
registry.token.issue | 0 | none; per incident on stellaops-break-glass-operator | registry token service (audience registry); no CLI verb |
release.policy-bindings.read | 2 | stellaops-release-ops-operator | environment policy bindings; Invoke-Ef5SameSubjectProof.ps1 PUTs them |
release.policy-bindings.write | 2 | stellaops-release-ops-operator | see release.policy-bindings.read |
release.policy.manage | 0 | none; per incident on stellaops-break-glass-operator | approval-policy routes; no CLI verb |
release.policy.read | 0 | none; per incident on stellaops-break-glass-operator | approval-policy routes; no CLI verb |
replay:write | 0 | none; per incident on stellaops-break-glass-operator | the CLI only downloads replay bundles |
router:routing:read | 0 | none; per incident on stellaops-break-glass-operator | no policy registration or endpoint requires it |
router:routing:write | 0 | none; per incident on stellaops-break-glass-operator | no policy registration or endpoint requires it |
sbom:attest | 0 | none; per incident on stellaops-break-glass-operator | POST /api/v1/lineage/diff/export; the CLI export lineage verb is local |
sbom:operate | 0 | none; per incident on stellaops-break-glass-operator | SbomPolicies.Internal (service-to-service) |
scheduler:operate | 1 | stellaops-onboarding-operator | JobEngine run operations; the CLI JobEngine client only needs scheduler:read (JobEngineRead group) |
signals:read | 0 | none; per incident on stellaops-break-glass-operator | DB-only survivor (S001), no consumer |
signals:write | 0 | none; per incident on stellaops-break-glass-operator | DB-only survivor (S001), no consumer |
signer:admin | 0 | none; per incident on stellaops-break-glass-operator | Signer ceremony approver; no CLI verb |
signer:rotate | 0 | none; per incident on stellaops-break-glass-operator | Signer key management; no CLI verb |
stellaops.admin | 0 | none; per incident on stellaops-break-glass-operator | not in the scope catalogue; S001 seed survivor |
stellaops.api | 3 | none; per incident on stellaops-break-glass-operator | not in the scope catalogue; already refused invalid_scope before AUTH-15 |
timeline:admin | 0 | none; per incident on stellaops-break-glass-operator | no consumer |
timeline:write | 0 | none; per incident on stellaops-break-glass-operator | the CLI only reads the timeline |
trust:admin | 0 | none; per incident on stellaops-break-glass-operator | no watchlist route policy requires it; the owner’s in-endpoint check for global definitions accepts it, and the CLI does not request it (2026-10-08) |
ui.preferences.read | 0 | none; per incident on stellaops-break-glass-operator | Console-only |
ui.preferences.write | 0 | none; per incident on stellaops-break-glass-operator | Console-only |
vexhub:read | 1 | none; per incident on stellaops-break-glass-operator | no endpoint enforces it; dropped from the stella-connectors.sh password default |
vexlens.read | 2 | none; per incident on stellaops-break-glass-operator | no endpoint enforces it (/api/v1/vexlens is served by no service); two Web e2e specs still request it, see Limitations |
vexlens.write | 1 | none; per incident on stellaops-break-glass-operator | no endpoint enforces it; see vexlens.read |
Table C — every consumer that minted through stellaops-cli, and where it mints now
| Consumer | Procedure | Scopes requested | Client after AUTH-15 | Change |
|---|---|---|---|---|
.gitea/workflows/auto-sbom-readiness-e2e.yml | ci-e2e | 6 | stellaops-cli | unchanged (all kept) |
.gitea/workflows/console-layout-audit.yml | ci-e2e | 2 | stellaops-cli | unchanged (OIDC only) |
.gitea/workflows/e2e-analyzer-coverage.yml | ci-e2e | 6 | stellaops-cli | unchanged (all kept) |
devops/agents-targets/scripts/customer-onboarding/01b-register-inline-integrations.sh | onboarding | 9 | stellaops-onboarding-operator | unchanged |
devops/agents-targets/scripts/customer-onboarding/_common.ps1 | onboarding | 31 | stellaops-onboarding-operator | unchanged scope string |
devops/compose/docker-compose.cli.yml | cli-default | 4 | stellaops-cli | default STELLAOPS_AUTHORITY_SCOPE loses stellaops.api (already refused) |
devops/compose/env/stella-cli.env.example | cli-default | 4 | stellaops-cli | default STELLAOPS_AUTHORITY_SCOPE loses stellaops.api (already refused) |
devops/compose/scripts/compliance-golden-path.ps1 | compliance | 18 | stellaops-compliance-operator | default $ClientId; unchanged scopes |
devops/compose/scripts/compliance-golden-path.sh | compliance | 19 | stellaops-compliance-operator | default CLIENT_ID; unchanged scopes |
devops/compose/scripts/compliance-setup.ps1 | compliance | 5 | stellaops-compliance-operator | default $ClientId; unchanged scopes |
devops/compose/scripts/compliance-setup.sh | compliance | 5 | stellaops-compliance-operator | default CLIENT_ID; unchanged scopes |
devops/compose/scripts/sprint005-cycle003-verify.sh | probe | 3 | stellaops-cli | unchanged (OIDC only) |
devops/compose/scripts/stella-connectors.sh | connectors-password-path | 11 | stellaops-cli (opt-in password path; default isstellaops-cli-automation) | vexhub:read dropped from OIDC_SCOPE_PASSWORD |
docs/runbooks/general/tenant-onboarding.md | runbook | 6 | step 1stellaops-estate-admin-operator; step 4stellaops-cli`` | unchanged scopes |
src/Web/StellaOps.Web/e2e/subject-access-request.e2e.spec.ts | qa-harness | 5 | stellaops-estate-admin-operator | unchanged scopes |
src/Web/StellaOps.Web/e2e/vexlens-token-scope-forcing-function.e2e.spec.ts | qa-harness | 3 | stellaops-cli (unchanged) | requests vexlens.read, which no client can mint; see Limitations |
src/Web/StellaOps.Web/tests/e2e/integrations/distro-vex-advisory-only-not-capped.spec.ts | qa-harness | 6 | stellaops-cli (unchanged) | requests vexlens.read/write and posts to /api/v1/vexlens/*, which no service serves; see Limitations |
src/Web/StellaOps.Web/tests/e2e/integrations/empty-topology-live.spec.ts | qa-harness | 8 | stellaops-estate-admin-operator | unchanged scopes |
src/__Tests/Fixtures/Setup-TwoTenantFixture.ps1 | qa-harness | 7 | stellaops-estate-admin-operator | unchanged scopes |
src/__Tests/playwright/_shared/auth.fixture.ts | qa-harness | 3 | stellaops-cli | default scope openid profile stellaops.api -> openid profile (the old default was already refused) |
tools/scripts/deploy/customer/scan-reachability.ps1 | qa-scan | 8 | stellaops-cli | unchanged (all kept) |
tools/scripts/lab/seed-packsregistry-lab-data.sh | lab-seed | 4 | stellaops-cli | unchanged (all kept) |
tools/scripts/qa/Capture-Baseline.ps1 | qa-scan | 17 | stellaops-cli | platform.context.read dropped (requested, never used) |
tools/scripts/qa/Invoke-AdvancedAssuranceRerun.ps1 | probe | 3 | stellaops-cli | unchanged; requests roles, which is not a scope (pre-existing, see Limitations) |
tools/scripts/qa/Invoke-Ef5SameSubjectProof.ps1 | qa-release | 17 | stellaops-release-ops-operator | unchanged scopes (PUTs environment policy bindings) |
tools/scripts/qa/Invoke-OperatorGoldenRerun.ps1 | qa-release | 20 | stellaops-cli | evidence:create evidence:hold dropped (requested, never used) |
tools/scripts/qa/Invoke-OperatorScenarios.ps1 | qa-scan | 17 | stellaops-cli | platform.context.read dropped (requested, never used) |
tools/scripts/qa/Invoke-SowLiveCaptures.ps1 | estate-admin | 5 | stellaops-estate-admin-operator | unchanged scopes |
tools/scripts/qa/Invoke-Wave2IsolateRetest.ps1 | estate-admin | 5 | stellaops-estate-admin-operator | unchanged scopes |
tools/scripts/qa/Provision-Ef5SourceFixture.ps1 | qa-release | 28 | stellaops-cli | release.policy-bindings.read/write dropped (requested, never used) |
tools/scripts/qa/rar5-route-reachability-probe.py | compliance | 19 | stellaops-compliance-operator | default CLIENT_ID; unchanged scopes |
tools/scripts/qa/seed-real-scan.ps1 | qa-scan | 12 | stellaops-cli | unchanged (all kept) |
tools/scripts/qa/verify-os-analyzer-scan.ps1 | qa-scan | 7 | stellaops-cli | unchanged (all kept) |
tools/scripts/qa/verify-rar7-native-route-window.mjs | probe | 4 | stellaops-cli | unchanged (all kept) |
tools/scripts/run-console-four-role-live.ps1 | estate-admin | 5 | stellaops-estate-admin-operator | unchanged scopes |
Consumers that stay on stellaops-cli do so because every scope they request is a product-CLI scope: they simulate a CLI user and should keep exercising the product path. Where a harness requested a scope it never used (platform.context.read, release.policy-bindings.*, evidence:create/hold), the request was trimmed rather than the identity widened.
Table D — the operator identities
All five follow the shape stellaops-advisory-ai-operator established: allowedGrantTypes: password (human-bound, role-gated), tenantId and tenants pinned to default (two tenants without a singular tenantId suppresses the tenant bind and advisory scopes are rejected), allowedAudiences: stellaops only (the router gateway validates aud=stellaops; per-service audiences are inert under Router__AuthorizationTrustMode=GatewayEnforced), public (the credential is the operator’s own login).
| Client | Scopes | Grant |
|---|---|---|
stellaops-estate-admin-operator | 20 | authority:clients.read authority:clients.write authority:roles.read authority:roles.write authority:tenants.read authority:tenants.write authority:tokens.read authority:tokens.revoke authority:users.read authority:users.write email evidence:read integration:read offline_access openid platform:sar:erase platform:sar:read profile ui.admin ui.read |
stellaops-onboarding-operator | 32 | authority:clients.read authority:clients.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write evidence:create evidence:read findings:read findings:write graph:read integration:operate integration:read integration:write offline_access openid orch:backfill orch:operate orch:read platform.context.read platform.context.write profile registry.admin release:publish release:read release:write sbom:read scanner:read scanner:write scheduler:operate scheduler:read ui.admin |
stellaops-compliance-operator | 22 | authority:tenants.read authority:tenants.write evidence:create evidence:read export.operator export.viewer notify.operator notify.viewer offline_access openid orch:operate orch:read policy:author policy:read profile release:read release:write sbom:read sbom:write scanner:read scanner:scan ui.admin |
stellaops-release-ops-operator | 23 | attest:read email evidence:create evidence:hold evidence:read export.operator export.viewer findings:read offline_access openid orch:operate orch:read policy:read profile release.policy-bindings.read release.policy-bindings.write release:publish release:read release:write replay:read scanner:read scanner:write vuln:operate |
stellaops-break-glass-operator | 2 | openid profile |
Which identity an operator reaches for:
| Procedure | Identity |
|---|---|
| tenant / user / client / role / token administration over the Authority console API; subject-access requests; console role harnesses | stellaops-estate-admin-operator |
customer onboarding (devops/agents-targets/scripts/customer-onboarding) | stellaops-onboarding-operator |
| compliance golden path, operator-compliance setup, route-reachability probes | stellaops-compliance-operator |
| release-orchestration windows: environment policy bindings, evidence hold | stellaops-release-ops-operator |
| AdvisoryAI / OpsMemory forcing functions | stellaops-advisory-ai-operator (unchanged) |
findings:recovery, and any scope no standing identity carries, appended for the duration of an incident and reset after | stellaops-break-glass-operator |
| anything a CLI verb does | stellaops-cli |
The stella CLI itself can mint through any of them: set STELLAOPS_AUTHORITY_CLIENT_ID (or --Authority:ClientId) to the identity and request only the scopes the procedure needs.
Scope break-glass after this change
This is the scope break-glass (an elevated grant for one incident). It is distinct from the credential-recovery break-glass in docs/runbooks/authority/authority-break-glass-recovery.md.
- Scope issuance is gated by the client ceiling only.
PasswordGrantHandlersresolves the requested scopes against the client’sallowedScopes(ClientCredentialsHandlers.ResolveGrantedScopes); there is no intersection with the user’s role permissions at mint time. Consequently a scope that sits on any public password-grant client is mintable by every user who can log in — which is exactly why the CLI client’s ceiling is a security boundary and whystellaops-break-glass-operatorcarries no break-glass scope at rest (openid profile; non-empty because an empty allowed set grants anything requested). findings:recoveryis granted to no bootstrap client (RepositoryStandardDescriptors_GrantFindingsRecoveryToNoClientpins the FND-17 doctrine). The procedure indocs/runbooks/findings/findings-service-deploy.md: append the scope to the break-glass row for the incident, assign theincident-findings-recoveryrole, mint throughstellaops-break-glass-operator, run the audited transitions, revoke role and token, then reset the break-glass row in the database (the reseed never removes what was appended).- Any other emergency scope (the “none” rows of Table B:
signer:rotate,authority:signing-keys.*,issuer-directory:admin,platform:federation:*, …) follows the same path — onto the break-glass row for the incident, through the Console client editor (authority:clients.write, i.e. viastellaops-estate-admin-operator) orstandard.yaml+ restart, and off it again with the sameUPDATE. Never ontostellaops-cli: the ceiling test fails the commit and the migration removes it from the database. - Superseded 2026-09-08 by AUTH-17. When this census was written the gateway’s break-glass introspection client was registered under
stellaops— the audience every token in the estate carries — so it covered every break-glass token by construction, whichever client minted it. AUTH-17 narrowed it: the introspection client is nowstellaops-gateway-introspection, onlystellaops-break-glass-operatornames that id in itsallowedAudiences, and the estate-widestellaopsclient is deleted (migrationS059_retire_estate_wide_introspection_client.sql). Consequence for the procedures above: afindings:recoverytoken must be minted throughstellaops-break-glass-operator, or the gateway refuses it503 break_glass_introspection_not_authorized_partybefore any revocation question is asked.
The union-reseed asymmetry — why the database step is mandatory
StandardClientProvisioningStore.CreateOrUpdateAsync merges DB ∪ YAML for allowedScopes, audiences and tenants on every Authority boot and never shrinks a grant (Sprint 20260520_084, RESIDUAL-2). Therefore:
- Adding a scope to
standard.yamllands on the live row with a plain restart. - Removing a scope from
standard.yamlchanges nothing on a running estate, and on a fresh databaseS001+S034…S057re-append the historical grants before the bootstrapper runs.
The narrowing is therefore a forward-only migration, S058_stellaops_cli_scope_ceiling.sql, which sorts after every append migration and intersects the persisted row with the ceiling. Its successors restate the ceiling in the same shape: S060 (adds envstate:time-anchor:write) and S063_stellaops_cli_scope_ceiling_analytics_withdrawal.sql (drops analytics.read), followed by current S064_stellaops_cli_scope_ceiling_jobengine_withdrawal.sql. S064 drops orch:read and scheduler:read and admits the existing Symbols scopes to the ceiling without adding standing grants.
These are Seed-category migrations. The startup host applies Seed migrations only when AUTHORITY_BOOTSTRAP_ENABLED=true (RunSeedMigrations); the shipped default is false. With bootstrap enabled, the ceiling converges an existing estate (on the first boot of an image that embeds it) and a fresh bootstrap (after S057) on the same set. In the default posture none of them runs: a fresh install holds only the YAML grant, which the guard keeps inside the ceiling, and an existing row is narrowed to the ceiling only by the script below. A scope that a successor drops must also leave existing rows in the default posture, so it ships with a Startup-category companion that removes exactly that scope from stellaops-cli: 031_authority_stellaops_cli_analytics_read_retirement.sql for analytics.read, and 032_authority_stellaops_cli_jobengine_read_retirement.sql for the two JobEngine reads. A narrowing successor converges because the YAML narrows with it: the guard keeps the YAML grant inside the current ceiling, so the reseed union cannot put the dropped scope back.
Because embedded SQL needs an image rebuild, an estate that has not taken that image applies the identical statement by hand:
bash tools/scripts/deploy/postgres/apply-stellaops-cli-scope-ceiling.sh --apply # snapshots the row first
bash tools/scripts/deploy/postgres/apply-stellaops-cli-scope-ceiling.sh --verify # row ⊆ ceiling, reads the DB
bash tools/scripts/deploy/postgres/apply-stellaops-cli-scope-ceiling.sh --rollback # the 143-scope pre-change row
bash tools/scripts/deploy/postgres/apply-stellaops-cli-scope-ceiling.sh --self-test # proves --verify detects
The script parses the ceiling out of the migration file rather than carrying its own copy, so the two cannot disagree. The rollback file (rollback-stellaops-cli-scope-ceiling.sql) is the exact pre-change row captured on 2026-09-05 — 139 YAML scopes plus four DB-only survivors (signals:read, signals:write, stellaops.admin, stellaops.api) — and RollbackScript_RestoresThePreChangeRowAndCoversEveryCurrentGrant pins that it is a superset of the current grant. Restoring the YAML alone is not a rollback: the reseed re-adds exactly what the YAML declares and nothing else.
Verify against the database, never the file. The YAML is the intent; the row is what Authority mints from.
The guard
StellaOpsCliClientScopeCeilingTests (StellaOps.Authority.Plugin.Standard.Tests) pins:
- the ceiling is catalogued, distinct, sorted and non-vacuous (92 entries, every one in
StellaOpsScopes.Allor OIDC, the current 38 source constants present); stellaops-cli’sallowedScopesinstandard.yaml⊆ ceiling, and carriesopenid profile email offline_accessand at least 50 scopes (a rewrite that drops half the grant fails too);- the
ARRAY[...]literal in the CURRENT ceiling migration (S064) equals the ceiling; - every ceiling-shaped migration on disk is in the recorded lineage (
S058,S060,S063,S064), and each successor differs from its predecessor by exactly the recorded delta; - every scope a successor drops has a Startup-category companion that retires exactly that scope from
stellaops-cli(031foranalytics.read,032for the two JobEngine reads), and the YAML grant does not declare it; - the rollback file lists the 143-scope pre-change row plus the EA-5 scope (144) and is a superset of the current grant;
- the five operator identities have the D-AAI9 shape and only catalogued scopes; the break-glass identity is exactly
openid profile; findings:recoveryis granted to no bootstrap client at all.
Red-proof (2026-09-05): adding signer:rotate to the YAML grant fails fact 2 naming signer:rotate; deleting vuln:operate from the migration array fails fact 3 naming it. The deploy script’s --self-test proves its own detector the same way (an in-ceiling row passes, one out-of-ceiling scope fails). Red-proof (2026-10-01, PCD-228): with the pre-change YAML and no S063, facts 2, 3, 4 and 5 fail (4 of 9 cases); with analytics.read put back into S063’s array, facts 3 and 4 fail naming it. MigrationS063StellaOpsCliAnalyticsWithdrawalTests and Migration031StellaOpsCliAnalyticsReadRetirementTests (StellaOps.Authority.Persistence.Tests, real PostgreSQL) prove both files remove the scope from the column and the JSON mirror, keep every other scope, leave other clients alone and are idempotent.
Limitations recorded, not fixed
src/Web/StellaOps.Web/e2e/vexlens-token-scope-forcing-function.e2e.spec.tsandsrc/Web/StellaOps.Web/tests/e2e/integrations/distro-vex-advisory-only-not-capped.spec.tsmintvexlens.read/vexlens.writethroughstellaops-cli. No endpoint enforces either scope and no service registers/api/v1/vexlens/*, so both specs were already non-functional against the current estate; they now also fail at the token endpoint (invalid_scope). They need re-homing onto thestella-ops-uigrant they actually assert, which is Console work, not Authority work.tools/scripts/qa/Invoke-AdvancedAssuranceRerun.ps1requestsroles, which is not a scope; the request was refused before this change and is unaffected by it.stellaops-clikeeps audiencessignerandregistry(needed by the keptsigner:signandregistry.*scopes) andapi://issuer-directory(no kept scope needs it; audiences union on reseed exactly like scopes, so removing it is its own DB step and was not part of this change).- The ten ceiling scopes the CLI names but is not granted (see Method) remain a pre-existing gap.
stellaops-clistill declarestenants: default tenant-golden-local; the operator identities aredefault-only and gain further tenants only through the console tenant-create auto-grant, which extends everyfirstPartyclient.- The ceiling migrations are Seed-category, so in the default posture (
AUTHORITY_BOOTSTRAP_ENABLED=false) astellaops-clirow that predates AUTH-15 keeps every scope the YAML ever granted untilapply-stellaops-cli-scope-ceiling.sh --applyruns. The Startup companions031and032retire onlyanalytics.read,orch:readandscheduler:read; they do not apply the rest of the ceiling. - A CLI configuration whose
Authority:Scope(STELLAOPS_AUTHORITY_SCOPE) still listsanalytics.readis now refused as a whole withinvalid_scope: the password grant rejects a request that names any scope outside the client’s allowed set. No shipped default lists it. - PCD-324 removes the unrequested
analytics.readgrant fromstellaops-cli-automationinstandard.yamland Startup migration033_authority_cli_automation_analytics_read_retirement.sql. Source verification:bb8ae339ad(2026-10-05). The migration narrows bothallowed_scopesand the optionalproperties.allowedScopesmirror, including JSON-only drift, and leaves other scopes, clients, audiences and tenant bindings intact. This runs with bootstrap disabled; the Standard reseed cannot re-add a scope absent from YAML. The scope catalog and the Console’s NIS2 grant remain. Re-verify withRepositoryAutomationGrant_DoesNotIncludeUnrequestedAnalyticsReadandMigration033AutomationAnalyticsReadRetirementTests, plus both adjacent plugin and persistence suites. This does not apply the separate whole-CLI ceiling decision tracked by PCD-323.
