A scope is a permission statement. It says: this credential holder is allowed to perform this action on this resource.
Every credential in AgentWrit carries a list of scopes. Every protected endpoint checks that the caller's scopes cover what the endpoint requires. If they don't, the request is denied with a 403.
Every scope has exactly three parts, separated by colons:
| Part | Example | Meaning |
|---|---|---|
| action | read |
What you're doing |
| resource | data |
What you're acting on |
| identifier | customers |
Which specific instance (or * for all) |
Examples:
read:data:customers
write:logs:*
admin:revoke:*
app:launch-tokens:*
- action — what you're doing:
read,write,admin,app - resource — what you're acting on:
data,logs,revoke,launch-tokens - identifier — which specific instance:
customers,project-42, or*(all)
The * wildcard in the identifier position means "any instance of this resource." So read:data:* covers read:data:customers, read:data:orders, read:data:anything.
The wildcard only works in the identifier position. *:*:* is technically valid but grants everything — that's the scope you'd see on a dev/testing launch token, never in production.
A scope string is valid if it has exactly three non-empty colon-separated parts. That's it. The broker doesn't maintain a registry of "known" scopes — any three-part string is accepted. This means:
read:data:customers— validwrite:logs:project-42— validcustom:anything:you-want— validread:data— invalid (only two parts)read::customers— invalid (empty resource)
The broker doesn't know what read:data:customers means to your application. It only knows whether one scope covers another. Your application decides what the scope actually grants access to.
This is the core operation. Everything in the permission model reduces to this question: does scope B cover scope A?
B covers A when:
- Same action (
read==read) - Same resource (
data==data) - Same identifier, OR B's identifier is
*
Does read:data:* cover read:data:customers?
action: read == read ✓
resource: data == data ✓
identifier: * covers customers ✓
→ YES
Does read:data:customers cover read:data:orders?
action: read == read ✓
resource: data == data ✓
identifier: customers ≠ orders, and customers is not * ✗
→ NO
Does admin:revoke:* cover read:data:customers?
action: admin ≠ read ✗
→ NO (fails on first check)
When we check a whole set of scopes (not just one), we ask: is every requested scope covered by at least one scope in the allowed set?
Allowed: [read:data:*, write:logs:*]
Requested: [read:data:customers, write:logs:app-1]
read:data:customers → covered by read:data:* ✓
write:logs:app-1 → covered by write:logs:* ✓
→ ALL COVERED → ALLOWED
Allowed: [read:data:*]
Requested: [read:data:customers, write:logs:app-1]
read:data:customers → covered by read:data:* ✓
write:logs:app-1 → not covered by anything ✗
→ NOT ALL COVERED → DENIED
This is authz.ScopeIsSubset(requested, allowed) — it runs at every trust boundary in the system.
Scopes in AgentWrit fall into three families. Each family belongs to a different role, and the families don't overlap.
Carried by: Admin JWT (issued when operator authenticates with admin secret)
| Scope | What it unlocks |
|---|---|
admin:launch-tokens:* |
Create launch tokens, register/list/update/deregister apps |
admin:revoke:* |
Revoke credentials at 4 levels (token, agent, task, chain) |
admin:audit:* |
Query the audit trail |
These are fixed — every Admin JWT gets all three. The admin is the operator; they get full system management.
Carried by: App JWT (issued when app authenticates with client_id + client_secret)
| Scope | What it unlocks |
|---|---|
app:launch-tokens:* |
Create launch tokens for agents (within scope ceiling) |
app:agents:* |
Manage agents under this app (future) |
app:audit:read |
Read audit events for own agents (future) |
These are also fixed — every App JWT gets all three. But what the app can put INTO a launch token is constrained by the scope ceiling (see below).
Carried by: Agent JWT and Delegated JWT
These are the actual business permissions — what the agent is allowed to do with the resources it accesses:
read:data:customers
write:logs:*
execute:pipeline:deploy-prod
query:database:analytics
Task scopes are not predefined by the broker. They're defined by the operator when they set up the app's scope ceiling, and they're meaningful to the application that the agent is working with. The broker only knows how to compare them — coverage checks, subset checks — not what they mean.
This is where the design gets concrete. Scopes are checked at four distinct points, and each point enforces the attenuation invariant: permissions can never expand (same or narrower, never wider).
Where: AdminHdl.handleCreateLaunchToken (only when caller is an app)
What's checked: The launch token's allowed_scope must be a subset of the app's scope ceiling.
App ceiling (set at registration): [read:data:*, write:logs:*]
Launch token requested scope: [read:data:customers]
ScopeIsSubset check: read:data:customers ⊆ read:data:* → ✓ ALLOWED
App ceiling: [read:data:*]
Launch token requested scope: [admin:revoke:*]
ScopeIsSubset check: admin:revoke:* ⊄ read:data:* → ✗ DENIED (403)
Audit event: scope_ceiling_exceeded
sequenceDiagram
participant App
participant Broker
participant Store
App->>Broker: POST /v1/app/launch-tokens<br/>{allowed_scope: ["read:data:customers"]}
Broker->>Store: Look up app record (by app: subject)
Store-->>Broker: App ceiling: ["read:data:*", "write:logs:*"]
Broker->>Broker: ScopeIsSubset(["read:data:customers"], ceiling)?
alt Scope within ceiling
Broker->>Broker: Create launch token
Broker-->>App: 201 Created + launch_token
else Scope exceeds ceiling
Broker->>Broker: Audit: scope_ceiling_exceeded
Broker-->>App: 403 Forbidden
end
What happens if the caller is admin, not an app? The ceiling check is skipped. Admin-created launch tokens have no scope constraint. This is the TD-013 question — we'll come back to it.
Where: IdSvc.Register
What's checked: The agent's requested_scope must be a subset of the launch token's allowed_scope.
Launch token allowed_scope: [read:data:customers]
Agent requested_scope: [read:data:customers]
ScopeIsSubset check: ✓ exact match → ALLOWED
Launch token allowed_scope: [read:data:customers]
Agent requested_scope: [read:data:customers, write:logs:*]
ScopeIsSubset check: write:logs:* not covered → ✗ DENIED
Audit event: registration_policy_violation
Critical ordering: The scope check happens BEFORE the launch token is consumed. This way, a scope violation doesn't waste a single-use launch token — the app can fix the scope and try again with the same token.
Where: DelegSvc.Delegate
What's checked: The delegated scope must be a subset of the delegator's scope.
Delegator scope: [read:data:*, write:logs:*]
Requested for delegate: [read:data:customers]
ScopeIsSubset check: ✓ → ALLOWED
Delegator scope: [read:data:customers]
Requested for delegate: [read:data:*, write:logs:*]
ScopeIsSubset check: read:data:* not covered by read:data:customers → ✗ DENIED
Audit event: delegation_attenuation_violation
Where: ValMw.RequireScope and ValMw.RequireAnyScope
What's checked: The token's scopes must cover the scope required by the endpoint.
Endpoint requires: admin:revoke:*
Token scopes: [admin:launch-tokens:*, admin:revoke:*, admin:audit:*]
ScopeIsSubset check: admin:revoke:* ⊆ token scopes → ✓ ACCESS GRANTED
Endpoint requires: admin:revoke:*
Token scopes: [read:data:customers]
ScopeIsSubset check: admin:revoke:* not covered → ✗ 403 FORBIDDEN
Audit event: scope_violation
Some endpoints accept multiple caller types. POST /v1/admin/launch-tokens and POST /v1/app/launch-tokens both call the same handler, but with different required scopes:
- Admin route requires
admin:launch-tokens:* - App route requires
app:launch-tokens:*
Here's the complete flow from operator to working agent, showing how scopes narrow at each step:
graph TD
subgraph "Step 1: Operator registers app"
OP["Operator decides:<br/>This app can read data and write logs"]
CEIL["App scope ceiling<br/>read:data:*, write:logs:*"]
end
subgraph "Step 2: App creates launch token"
APP["App decides:<br/>This agent only needs to read customers"]
LT["Launch token allowed_scope<br/>read:data:customers"]
end
subgraph "Step 3: Agent registers"
AG["Agent requests:<br/>read:data:customers"]
AGJWT["Agent JWT scope<br/>read:data:customers"]
end
subgraph "Step 4: Agent delegates"
DEL["Agent delegates to sub-agent:<br/>read:data:customers"]
DJWT["Delegated JWT scope<br/>read:data:customers"]
end
OP --> CEIL
CEIL -->|"EP1: ScopeIsSubset<br/>read:data:customers ⊆ read:data:* ✓"| LT
APP --> LT
LT -->|"EP2: ScopeIsSubset<br/>read:data:customers ⊆ read:data:customers ✓"| AGJWT
AG --> AGJWT
AGJWT -->|"EP3: ScopeIsSubset<br/>read:data:customers ⊆ read:data:customers ✓"| DJWT
DEL --> DJWT
style CEIL fill:#2e8b57,color:#fff
style LT fill:#4169e1,color:#fff
style AGJWT fill:#6a5acd,color:#fff
style DJWT fill:#9370db,color:#fff
At every arrow, ScopeIsSubset enforces that the new scope is covered by the previous scope. The chain can never expand — a delegate can receive its delegator's full scope, but never exceed it.
With the scope model understood, let's come back to the question: why would admin need to create launch tokens?
Operator → registers app with ceiling [read:data:*]
App → authenticates, gets App JWT
App → creates launch token with [read:data:customers]
EP1 fires: read:data:customers ⊆ read:data:* ✓
Launch token is constrained by ceiling
Agent → registers with launch token
EP2 fires: requested ⊆ allowed ✓
Agent JWT scope ≤ launch token ≤ app ceiling
Every scope in the chain is audited, constrained, and traceable back to the app (via app_id on the launch token) and ultimately to the operator (who set the ceiling).
Operator → authenticates, gets Admin JWT with admin:launch-tokens:*
Operator → creates launch token with [read:data:*, write:data:*, admin:revoke:*]
EP1 does NOT fire — caller is admin, not app
Launch token has NO scope ceiling check
Launch token has NO app_id (empty)
Agent → registers with launch token
EP2 fires: requested ⊆ allowed ✓
But "allowed" was never constrained
What's missing:
- No ceiling enforcement. Admin can put any scopes in the launch token — including admin scopes, app scopes, wildcard scopes. There's no check.
- No app traceability. The agent has no
app_id. In the audit trail, you can see who created the launch token (created_by: "admin") but not which application context the agent belongs to. - No scope attenuation at EP1. The first enforcement point is skipped entirely. The chain starts at EP2 (agent registration) instead of EP1.
The admin launch token path serves exactly two purposes:
-
Bootstrapping. Before any apps are registered, someone needs to test the system. The operator creates a launch token, registers a test agent, verifies the flow works. This is the "awrit init → create launch token → test" development workflow.
-
Emergency/debugging. If something is wrong with the app credential flow and you need to get an agent running immediately, the admin can bypass the app layer entirely.
Production agent credentialing. In production:
- Every agent should trace back to an app
- Every launch token should be constrained by a scope ceiling
- The audit trail should show the full chain: operator → app → launch token → agent
The admin path breaks all three of these properties.
Now the question becomes: what do we do about it?
| Option | What changes | Trade-off |
|---|---|---|
| A. Remove admin launch token creation | Delete POST /v1/admin/launch-tokens |
Clean, but breaks the bootstrap/dev workflow. Operator would need to register a dev app first, even for basic testing |
| B. Restrict to dev mode only | Admin launch tokens only work when MODE=development |
Production gets the clean model, dev keeps the convenience. Clear boundary |
| C. Require app_id parameter | Admin creating a launch token must specify which app it's for. Ceiling is enforced against that app's ceiling | Preserves admin convenience, adds traceability, enforces ceiling. More complex but most correct |
| D. Separate the scope | Split admin:launch-tokens:* into admin:launch-tokens:create and admin:apps:*. Admin gets app management but not launch token creation by default |
Fine-grained, but adds scope complexity |
The answer depends on what matters more: developer convenience during bootstrapping, or a clean security model with no exceptions. Option B is probably the pragmatic choice — you get both, with a clear boundary between dev and production behavior.
- Scopes are three-part permission strings:
action:resource:identifier *in the identifier position is a wildcard covering all instancesScopeIsSubsetis the single function that enforces all permission checks- Scopes are checked at four enforcement points: app→launch token, launch token→agent, agent→delegate, and every endpoint access
- At every step, scope can never expand (same or narrower) — the attenuation invariant
- Admin scopes (
admin:*:*), app scopes (app:*:*), and task scopes are three distinct families - The admin launch token path bypasses EP1 (no ceiling check) — this is a known design question (TD-013), not a bug
Scopes control what tokens can do. Next, see every credential type in detail:
The Credential Lifecycle → Every credential's claims, TTLs, and how they flow through the attenuation chain.
Or explore related topics:
| If you want to... | Read this |
|---|---|
| See who holds which token | The Three Actors |
| Try the registration flow hands-on | Your First Five Minutes |
| Look up a specific API endpoint | API Reference |
Previous: The Three Actors · Next: The Credential Lifecycle