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.

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:

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:

TierEvidenceCount
1src/Cli (non-test) references the StellaOpsScopes constant or the exact string literal42
2the CLI’s own product documentation (docs/modules/cli/**) names the scope+17
3a route literal in src/Cli targets an endpoint whose authorization policy requires the scope+29
OIDCopenid 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.

ScopeTierGranted to stellaops-cliEvidence
advisory-ai:operate1 (CLI source)yessrc/Cli/StellaOps.Cli/Program.cs:194
advisory-ai:view3 (CLI route to endpoint policy)yesCLI advisory-ai chat/rate-limit GETs (dominated by advisory-ai:operate, kept for least-privilege reads) -> AdvisoryAI.WebService/Security/AdvisoryAIPolicies.cs:55 (ViewPolicy)
advisory:ingest2 (CLI docs)yesdocs/modules/cli/guides/commands/auth.md:56
advisory:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Program.cs:829
airgap:import1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:2214
airgap:seal1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:133
airgap:status:read3 (CLI route to endpoint policy)yesCLI airgap status (/api/offlinekit/v1) -> OfflineKit.WebService/Program.cs (OfflineKitPolicies.StatusRead)
aoc:verify1 (CLI source)yessrc/Cli/StellaOps.Cli/Program.cs:830
attest:create1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/AttestorLinkClient.cs:62
attest:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/AttestorLinkClient.cs:63
authority:idp.read1 (CLI source)no (pre-existing gap, not added)src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3132
authority:idp.write1 (CLI source)no (pre-existing gap, not added)src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3139
authority:roles.read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:6541 (GET console/admin/roles) -> Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs:161
authority:roles.write3 (CLI route to endpoint policy)yesBackendOperationsClient.cs:6564,6594 (POST/PATCH console/admin/roles) -> ConsoleAdminEndpointExtensions.cs:166,173
authority:tenants.read1 (CLI source)yessrc/Cli/StellaOps.Cli/Commands/Nis2SoaCommandGroup.cs:300
authority:tenants.write3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Commands/Assurance/OperatorComplianceConfigCommandGroup.cs:338 (PUT operator-compliance) -> ConsoleAdminEndpointExtensions.cs:88-89
concelier.jobs.trigger1 (CLI source)yessrc/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:25
concelier.merge2 (CLI docs)yesdocs/modules/cli/architecture.md:210
crypto:admin1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PlatformSecretProviderClient.cs:127
crypto:kek:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:71
crypto:kek:rotate1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:72
crypto:profile:admin1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/TenantCryptoComplianceClient.cs:41
crypto:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs:73
emailOIDCyesstandard OpenID Connect scope
envstate:time-anchor:write1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs (ResolveEnvironmentStateWriteScope, PUT api/v1/platform/environment-state/time-anchor/{scope}) -> Platform.WebService/Program.cs (PlatformPolicies.EnvironmentStateTimeAnchorWrite)
evidence:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs:344
exceptions:approve3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Services/ExceptionClient.cs (/{id}/{transition}) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:65-85
exceptions:read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Services/ExceptionClient.cs (/api/policy/exceptions) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:53
exceptions:request3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Services/ExceptionClient.cs (POST /api/policy/exceptions) -> Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs:39
export.operator1 (CLI source)yessrc/Cli/StellaOps.Cli/Commands/AssuranceProduceClient.cs:34
export.viewer1 (CLI source)yessrc/Cli/StellaOps.Cli/Commands/AssuranceProduceClient.cs:34
findings:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/CvssClient.cs:69
findings:write2 (CLI docs)yesdocs/modules/cli/findings-runtime.md:51
graph:read3 (CLI route to endpoint policy)yesCLI /api/graphs/ -> Graph.Api/Security/GraphPolicies.cs:36
integration:operate1 (CLI source)yessrc/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs:175
integration:read3 (CLI route to endpoint policy)yesCLI /api/v1/integrations, /api/v1/sources, /api/v1/feeds -> Integrations.WebService/Program.cs (IntegrationPolicies.Read)
integration:write3 (CLI route to endpoint policy)yesCLI /api/v1/integrations (register/update) -> Integrations.WebService/Program.cs (IntegrationPolicies.Write)
notify.operator1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/Nis2IncidentClient.cs:25
notify.viewer1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/Nis2IncidentClient.cs:26
obs:read1 (CLI source)no (pre-existing gap, not added)src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs:177
offline_accessOIDCyesstandard OpenID Connect scope
openidOIDCyesstandard OpenID Connect scope
ops.admin2 (CLI docs)no (pre-existing gap, not added)docs/modules/cli/guides/admin/admin-reference.md:35
ops.health2 (CLI docs)yesdocs/modules/cli/guides/admin/admin-reference.md:35
orch:backfill1 (CLI source)yessrc/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:62
orch:operate1 (CLI source)yessrc/Cli/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs:55
packs.approve2 (CLI docs)yesdocs/modules/cli/guides/packs-profiles.md:47
packs.read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PackClient.cs:74
packs.run1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PackClient.cs:136
packs.write1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/PackClient.cs:314
platform.setup.admin1 (CLI source)no (pre-existing gap, not added)src/Cli/StellaOps.Cli/Services/PlatformEnvironmentSettingsClient.cs:30
platform.setup.read1 (CLI source)no (pre-existing gap, not added)src/Cli/StellaOps.Cli/Services/PlatformEnvironmentSettingsClient.cs:29
policy:activate2 (CLI docs)yesdocs/modules/cli/guides/policy.md:6
policy:audit3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Commands/Nis2SoaCommandGroup.cs:293 (api/v1/tenants/{t}/compliance-profile) -> Authority/Tenants/TenantComplianceProfileEndpointExtensions.cs:37
policy:edit2 (CLI docs)yesdocs/modules/cli/guides/policy.md:6
policy:promote2 (CLI docs)no (pre-existing gap, not added)docs/modules/cli/guides/cli-reference.md:495
policy:publish2 (CLI docs)yesdocs/modules/cli/guides/cli-reference.md:495
policy:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/ExceptionClient.cs:70
policy:run1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/CvssClient.cs:57
policy:simulate3 (CLI route to endpoint policy)yesCLI policy simulate (/api/v2/simulate, /api/vuln/simulate, /api/risk/simulate) -> Policy.Engine/Endpoints/ConsoleSimulationEndpoint.cs:19; JobEngine PolicySimulationEndpointExtensions.cs:20
profileOIDCyesstandard OpenID Connect scope
registry.admin3 (CLI route to endpoint policy)yesRegistryCommandGroup.cs:1158,1204 (PUT/DELETE /api/v1/registry/upstreams) -> ReleaseOrchestratorPolicies.cs (RegistryUpstreamAdmin)
registry:cache:admin3 (CLI route to endpoint policy)yesRegistryCommandGroup.cs:974,1022 (PUT/DELETE /api/v1/registry/cache/policies) -> ReleaseOrchestratorPolicies.cs (RegistryCacheAdmin)
registry:read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Commands/RegistryCommandGroup.cs (GET upstreams, cache/stats) -> ReleaseOrchestratorPolicies.cs (RegistryUpstreamRead/RegistryCacheRead)
release:publish3 (CLI route to endpoint policy)yesCLI /api/v1/release-orchestrator/approvals -> ReleaseOrchestratorPolicies.cs:100 (ReleaseApprove = ReleasePublish)
release:read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/OperatorDecisionHttpClientRegistration.cs (/api/v1/release-orchestrator/*) -> ReleaseOrchestrator.WebApi/ReleaseOrchestratorPolicies.cs (ReleaseRead)
release:write3 (CLI route to endpoint policy)yesCLI release approve/reject/promote/deploy/rollback (/api/v1/release-orchestrator/operator-decisions) -> ReleaseOrchestratorPolicies.cs:94 (ReleaseWrite)
replay:read3 (CLI route to endpoint policy)yesCLI /api/v1/replay/bundles (GET only) -> Replay.WebService/ReplayAuthorizationPolicies.cs:41
sbom:read3 (CLI route to endpoint policy)yesCLI /api/v1/sboms, /api/v1/sboms/{id}/export -> SbomService/Program.cs:98 (SbomPolicies.Read)
sbom:write3 (CLI route to endpoint policy)yesCLI /api/v1/sbom/upload -> SbomService/Program.cs:1518-1520 (SbomPolicies.Write)
scanner:export2 (CLI docs)yesdocs/modules/cli/architecture.md:719
scanner:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/ScmImportClient.cs:93
scanner:scan1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/ScmImportClient.cs:69
scanner:write3 (CLI route to endpoint policy)yesCLI /api/v1/scans, /api/v1/triage, /api/v1/sources -> Scanner.WebService/Program.cs (ScansWrite/SourcesWrite/TriageWrite)
score.evaluate3 (CLI route to endpoint policy)yesCLI api/v1/score/evaluate -> Signals/Score/Endpoints/ScoreEndpoints.cs:92 (ScorePolicies.ScoreEvaluate)
score.read1 (CLI source)yessrc/Cli/StellaOps.Cli/Program.cs:803
signer:read2 (CLI docs)yesdocs/modules/cli/architecture.md:522
signer:sign1 (CLI source)yessrc/Cli/StellaOps.Cli/Commands/DoraRoiSigningPipelineFactory.cs:196
symbols:read1 (CLI source)noExisting Symbols reader in src/Cli/StellaOps.Cli/Services/SymbolOwnerClientRegistration.cs; owner SymbolManagementEndpoints registers this read scope
symbols:write1 (CLI source)noExisting manifest writer requests this scope plus ops.admin; owner writes require Symbols write and admin policies
timeline:read3 (CLI route to endpoint policy)yesCLI /api/v1/timeline/events, /snapshots (GET only) -> Timeline.WebService/Program.cs (TimelinePolicies.Read)
trust:read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Commands/WatchlistCommandGroup.cs (GET /api/v1/watchlist) -> Attestor.Application/AttestorWebServiceComposition.cs:390 (watchlist:read)
trust:write3 (CLI route to endpoint policy)yesWatchlistCommandGroup.cs (POST/PUT/DELETE /api/v1/watchlist) -> AttestorWebServiceComposition.cs:405 (watchlist:write)
ui.admin1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs:3131
ui.read3 (CLI route to endpoint policy)yessrc/Cli/StellaOps.Cli/Services/AuthorityConsoleClient.cs (console/tenants, console/token/*) -> StellaOps.Authority/Console/ConsoleEndpointExtensions.cs:27 (/console group RequireStellaOpsScopes(UiRead))
vex.admin2 (CLI docs)yesdocs/modules/cli/architecture.md:722
vex:ingest2 (CLI docs)no (pre-existing gap, not added)docs/modules/cli/architecture.md:722
vex:read1 (CLI source)yessrc/Cli/StellaOps.Cli/Services/VexObservationsClient.cs:146
vuln:audit2 (CLI docs)yesdocs/modules/cli/guides/vuln-explorer-cli.md:19
vuln:investigate2 (CLI docs)yesdocs/modules/cli/guides/vuln-explorer-cli.md:17
vuln:operate2 (CLI docs)yesdocs/modules/cli/guides/vuln-explorer-cli.md:18
vuln:view1 (CLI source)yessrc/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.

ScopeConsumersNew homeNote
orch:read0Purpose-scoped operators keep itPCD-285: withdrawn CLI JobEngine commands have no current consumer; S064 and Startup 032 retire it from the CLI only
scheduler:read0Purpose-scoped operators keep itPCD-285: withdrawn CLI JobEngine client has no current consumer; S064 and Startup 032 retire it from the CLI only
analytics.read0none needed by the CLI; stella-ops-ui keeps it for the NIS2 effectiveness dashboardPCD-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.read0none; per incident on stellaops-break-glass-operatorconsole audit read; no CLI route
authority.revocation.read0none; per incident on stellaops-break-glass-operator/revocation/status read; the CLI reads revocation exports through the bootstrap key, not this scope
authority:clients.read2stellaops-estate-admin-operator, stellaops-onboarding-operatorconsole client administration; no CLI route
authority:clients.write2stellaops-estate-admin-operator, stellaops-onboarding-operatorconsole client administration; no CLI route
authority:signing-keys.admin0none; per incident on stellaops-break-glass-operatoroperator signing administration; no CLI verb
authority:signing-keys.enroll0none; per incident on stellaops-break-glass-operatoroperator signing enrollment; no CLI verb
authority:tokens.read0stellaops-estate-admin-operatorconsole token inventory; no CLI route
authority:tokens.revoke0stellaops-estate-admin-operatorPOST /console/admin/tokens/revoke, the break-glass revoke step
authority:users.read4stellaops-estate-admin-operator, stellaops-onboarding-operatorconsole user administration; no CLI route
authority:users.write4stellaops-estate-admin-operator, stellaops-onboarding-operatorconsole user administration; no CLI route
decision-signing0none; per incident on stellaops-break-glass-operatorno CLI verb
doctor:admin0none; per incident on stellaops-break-glass-operatorno endpoint requires it (S049 retired the doctor scopes)
doctor:run0none; per incident on stellaops-break-glass-operatorno endpoint requires it (S049 retired the doctor scopes)
evidence:create5stellaops-onboarding-operator, stellaops-compliance-operator, stellaops-release-ops-operatorthe CLI only reads evidence; compliance, onboarding and release-ops windows create it
evidence:hold1stellaops-release-ops-operatorevidence legal hold (VerdictPolicies.Admin); release-ops window
export.admin0none; per incident on stellaops-break-glass-operatorprofile archive / legacy DELETE; no CLI verb
issuer-directory:admin0none; per incident on stellaops-break-glass-operatorsee issuer-directory:read
issuer-directory:read0none; per incident on stellaops-break-glass-operatorthe CLI states no issuer-directory backend is wired
issuer-directory:write0none; per incident on stellaops-break-glass-operatorsee issuer-directory:read
notify.admin0none; per incident on stellaops-break-glass-operatorchannel create/test/send/retry are notify.operator; no CLI route needs admin
notify.escalate0none; per incident on stellaops-break-glass-operatorno CLI verb
operator.verify.admin0none; per incident on stellaops-break-glass-operatorno policy registration or endpoint requires it
operator.verify.read0none; per incident on stellaops-break-glass-operatorno policy registration or endpoint requires it
orch:quota0none; per incident on stellaops-break-glass-operatorclient_credentials quota-raise scope; the CLI never sends quota_reason (S001 decision)
platform.context.read5stellaops-onboarding-operator/api/v2/context/* (Platform); the CLI never calls it. QA harnesses requested it without using it
platform.context.write2stellaops-onboarding-operator/api/v2/context/* (Platform); the CLI never calls it
platform:federation:read0none; per incident on stellaops-break-glass-operatorReleaseOrchestrator federation; the CLI federation verbs target Concelier (Concelier.Advisories.*)
platform:federation:write0none; per incident on stellaops-break-glass-operatorsee platform:federation:read
platform:sar:erase1stellaops-estate-admin-operatorsubject-access erasure (privacy administration)
platform:sar:read1stellaops-estate-admin-operatorsubject-access requests (privacy administration)
policy:approve0none; per incident on stellaops-break-glass-operatorpromotion-gate override; not a CLI route
policy:author5stellaops-compliance-operatorexception/gate authoring endpoints the CLI does not call; the compliance golden path uses it
policy:operate0none; per incident on stellaops-break-glass-operatorbudget 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:review0none; per incident on stellaops-break-glass-operatorno serving endpoint; the CLI review verbs target routes no backend registers
registry.token.issue0none; per incident on stellaops-break-glass-operatorregistry token service (audience registry); no CLI verb
release.policy-bindings.read2stellaops-release-ops-operatorenvironment policy bindings; Invoke-Ef5SameSubjectProof.ps1 PUTs them
release.policy-bindings.write2stellaops-release-ops-operatorsee release.policy-bindings.read
release.policy.manage0none; per incident on stellaops-break-glass-operatorapproval-policy routes; no CLI verb
release.policy.read0none; per incident on stellaops-break-glass-operatorapproval-policy routes; no CLI verb
replay:write0none; per incident on stellaops-break-glass-operatorthe CLI only downloads replay bundles
router:routing:read0none; per incident on stellaops-break-glass-operatorno policy registration or endpoint requires it
router:routing:write0none; per incident on stellaops-break-glass-operatorno policy registration or endpoint requires it
sbom:attest0none; per incident on stellaops-break-glass-operatorPOST /api/v1/lineage/diff/export; the CLI export lineage verb is local
sbom:operate0none; per incident on stellaops-break-glass-operatorSbomPolicies.Internal (service-to-service)
scheduler:operate1stellaops-onboarding-operatorJobEngine run operations; the CLI JobEngine client only needs scheduler:read (JobEngineRead group)
signals:read0none; per incident on stellaops-break-glass-operatorDB-only survivor (S001), no consumer
signals:write0none; per incident on stellaops-break-glass-operatorDB-only survivor (S001), no consumer
signer:admin0none; per incident on stellaops-break-glass-operatorSigner ceremony approver; no CLI verb
signer:rotate0none; per incident on stellaops-break-glass-operatorSigner key management; no CLI verb
stellaops.admin0none; per incident on stellaops-break-glass-operatornot in the scope catalogue; S001 seed survivor
stellaops.api3none; per incident on stellaops-break-glass-operatornot in the scope catalogue; already refused invalid_scope before AUTH-15
timeline:admin0none; per incident on stellaops-break-glass-operatorno consumer
timeline:write0none; per incident on stellaops-break-glass-operatorthe CLI only reads the timeline
trust:admin0none; per incident on stellaops-break-glass-operatorno 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.read0none; per incident on stellaops-break-glass-operatorConsole-only
ui.preferences.write0none; per incident on stellaops-break-glass-operatorConsole-only
vexhub:read1none; per incident on stellaops-break-glass-operatorno endpoint enforces it; dropped from the stella-connectors.sh password default
vexlens.read2none; per incident on stellaops-break-glass-operatorno endpoint enforces it (/api/v1/vexlens is served by no service); two Web e2e specs still request it, see Limitations
vexlens.write1none; per incident on stellaops-break-glass-operatorno endpoint enforces it; see vexlens.read

Table C — every consumer that minted through stellaops-cli, and where it mints now

ConsumerProcedureScopes requestedClient after AUTH-15Change
.gitea/workflows/auto-sbom-readiness-e2e.ymlci-e2e6stellaops-cliunchanged (all kept)
.gitea/workflows/console-layout-audit.ymlci-e2e2stellaops-cliunchanged (OIDC only)
.gitea/workflows/e2e-analyzer-coverage.ymlci-e2e6stellaops-cliunchanged (all kept)
devops/agents-targets/scripts/customer-onboarding/01b-register-inline-integrations.shonboarding9stellaops-onboarding-operatorunchanged
devops/agents-targets/scripts/customer-onboarding/_common.ps1onboarding31stellaops-onboarding-operatorunchanged scope string
devops/compose/docker-compose.cli.ymlcli-default4stellaops-clidefault STELLAOPS_AUTHORITY_SCOPE loses stellaops.api (already refused)
devops/compose/env/stella-cli.env.examplecli-default4stellaops-clidefault STELLAOPS_AUTHORITY_SCOPE loses stellaops.api (already refused)
devops/compose/scripts/compliance-golden-path.ps1compliance18stellaops-compliance-operatordefault $ClientId; unchanged scopes
devops/compose/scripts/compliance-golden-path.shcompliance19stellaops-compliance-operatordefault CLIENT_ID; unchanged scopes
devops/compose/scripts/compliance-setup.ps1compliance5stellaops-compliance-operatordefault $ClientId; unchanged scopes
devops/compose/scripts/compliance-setup.shcompliance5stellaops-compliance-operatordefault CLIENT_ID; unchanged scopes
devops/compose/scripts/sprint005-cycle003-verify.shprobe3stellaops-cliunchanged (OIDC only)
devops/compose/scripts/stella-connectors.shconnectors-password-path11stellaops-cli (opt-in password path; default isstellaops-cli-automation)vexhub:read dropped from OIDC_SCOPE_PASSWORD
docs/runbooks/general/tenant-onboarding.mdrunbook6step 1stellaops-estate-admin-operator; step 4stellaops-cli``unchanged scopes
src/Web/StellaOps.Web/e2e/subject-access-request.e2e.spec.tsqa-harness5stellaops-estate-admin-operatorunchanged scopes
src/Web/StellaOps.Web/e2e/vexlens-token-scope-forcing-function.e2e.spec.tsqa-harness3stellaops-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.tsqa-harness6stellaops-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.tsqa-harness8stellaops-estate-admin-operatorunchanged scopes
src/__Tests/Fixtures/Setup-TwoTenantFixture.ps1qa-harness7stellaops-estate-admin-operatorunchanged scopes
src/__Tests/playwright/_shared/auth.fixture.tsqa-harness3stellaops-clidefault scope openid profile stellaops.api -> openid profile (the old default was already refused)
tools/scripts/deploy/customer/scan-reachability.ps1qa-scan8stellaops-cliunchanged (all kept)
tools/scripts/lab/seed-packsregistry-lab-data.shlab-seed4stellaops-cliunchanged (all kept)
tools/scripts/qa/Capture-Baseline.ps1qa-scan17stellaops-cliplatform.context.read dropped (requested, never used)
tools/scripts/qa/Invoke-AdvancedAssuranceRerun.ps1probe3stellaops-cliunchanged; requests roles, which is not a scope (pre-existing, see Limitations)
tools/scripts/qa/Invoke-Ef5SameSubjectProof.ps1qa-release17stellaops-release-ops-operatorunchanged scopes (PUTs environment policy bindings)
tools/scripts/qa/Invoke-OperatorGoldenRerun.ps1qa-release20stellaops-clievidence:create evidence:hold dropped (requested, never used)
tools/scripts/qa/Invoke-OperatorScenarios.ps1qa-scan17stellaops-cliplatform.context.read dropped (requested, never used)
tools/scripts/qa/Invoke-SowLiveCaptures.ps1estate-admin5stellaops-estate-admin-operatorunchanged scopes
tools/scripts/qa/Invoke-Wave2IsolateRetest.ps1estate-admin5stellaops-estate-admin-operatorunchanged scopes
tools/scripts/qa/Provision-Ef5SourceFixture.ps1qa-release28stellaops-clirelease.policy-bindings.read/write dropped (requested, never used)
tools/scripts/qa/rar5-route-reachability-probe.pycompliance19stellaops-compliance-operatordefault CLIENT_ID; unchanged scopes
tools/scripts/qa/seed-real-scan.ps1qa-scan12stellaops-cliunchanged (all kept)
tools/scripts/qa/verify-os-analyzer-scan.ps1qa-scan7stellaops-cliunchanged (all kept)
tools/scripts/qa/verify-rar7-native-route-window.mjsprobe4stellaops-cliunchanged (all kept)
tools/scripts/run-console-four-role-live.ps1estate-admin5stellaops-estate-admin-operatorunchanged 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).

ClientScopesGrant
stellaops-estate-admin-operator20authority: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-operator32authority: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-operator22authority: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-operator23attest: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-operator2openid profile

Which identity an operator reaches for:

ProcedureIdentity
tenant / user / client / role / token administration over the Authority console API; subject-access requests; console role harnessesstellaops-estate-admin-operator
customer onboarding (devops/agents-targets/scripts/customer-onboarding)stellaops-onboarding-operator
compliance golden path, operator-compliance setup, route-reachability probesstellaops-compliance-operator
release-orchestration windows: environment policy bindings, evidence holdstellaops-release-ops-operator
AdvisoryAI / OpsMemory forcing functionsstellaops-advisory-ai-operator (unchanged)
findings:recovery, and any scope no standing identity carries, appended for the duration of an incident and reset afterstellaops-break-glass-operator
anything a CLI verb doesstellaops-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.

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:

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:

  1. the ceiling is catalogued, distinct, sorted and non-vacuous (92 entries, every one in StellaOpsScopes.All or OIDC, the current 38 source constants present);
  2. stellaops-cli’s allowedScopes in standard.yaml ⊆ ceiling, and carries openid profile email offline_access and at least 50 scopes (a rewrite that drops half the grant fails too);
  3. the ARRAY[...] literal in the CURRENT ceiling migration (S064) equals the ceiling;
  4. 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;
  5. every scope a successor drops has a Startup-category companion that retires exactly that scope from stellaops-cli (031 for analytics.read, 032 for the two JobEngine reads), and the YAML grant does not declare it;
  6. the rollback file lists the 143-scope pre-change row plus the EA-5 scope (144) and is a superset of the current grant;
  7. the five operator identities have the D-AAI9 shape and only catalogued scopes; the break-glass identity is exactly openid profile;
  8. findings:recovery is 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