Scopes

Scopes name operations, not credential holders

A credential that may read purchases cannot revoke an entitlement unless separately scoped. Anything unscoped is denied.

They are stored and enforced on your side, in the connection row. Enforced only by the caller, they would not be a limit at all.

Two checks, against two different things

The connection row records what you granted.

The token carries the single scope the current call needs — not the whole grant.

Both must agree. Which means one call is never as dangerous as the most dangerous one you allowed: a token minted to read the purchase log carries only that, and cannot be replayed against the grant route even though the same connection holds it.

Matching is exact

No prefix rule, no wildcard. payments:entitlements does not confer payments:entitlements:revoke.

Ping needs a verified caller and no scope

It is deliberately not modeled as a scope, because granting or withholding it would change nothing: ping has to work for a connection granted nothing at all, since it is how a new key is proven before the old one is expired.

Why grant and revoke are two scopes

They are two operations with different blast radii.

Grant mints paid product out of nothing. Revoke takes paid access from a live customer.

A refund tool needs revoke and never grant. One admin flag would make each of them the other.

The scopes capabilities define

Which of them a connection is offered by default, and how a person narrows that at the prompt, is on the dashboard’s side. Every name below is a constant of its capability — the reference is where they are enumerated.

Payments — payments:catalog:read · payments:purchases:read · payments:subscriptions:read · payments:entitlements:read · payments:entitlements:grant · payments:entitlements:revoke

payments:catalog:read is its own grant because reading what a project sells is not reading what anybody bought — it names no account and no transaction, and answers identically against an empty database. A tool that needs a list of comp-able entitlements can hold that and nothing else.

It is also what a grant is checked against: granting a key no product grants and the adopter did not declare manually is refused, naming the key. The read makes a good comp control possible; the refusal makes a bad one impossible.

Auth — auth:users:read · auth:devices:read · auth:sessions:revoke · auth:users:logout · auth:devices:revoke

No impersonation. The most dangerous administrative capability there is, and it gets its own design and security review rather than riding in on a batch.

Audit — audit:events:read · audit:events:read_detail

The detail route is separate because IP, user-agent and metadata one event at a time is a forensic read, and the same fields across a hundred rows is bulk harvesting. Read-only by construction.

Email — email:jobs:read · email:jobs:retry · email:suppressions:read · email:suppressions:write · email:suppressions:delete

Ledger — ledger:accounts:read · ledger:transactions:read. Read-only: writing to a balance ledger from an admin console needs the same care as any other movement.

Secrets — secrets:status:read · secrets:rotate

The secrets pair is the sharpest example

Metadata only, and structurally so. The response types have no field for a value, a ciphertext, an IV, a metadata snapshot, or a rotation’s error message — so widening one is a compile error rather than a review miss.

A failed rotation reports as a status, never as a message: an error message is free text written at a failure site, which is exactly where a value gets pasted by accident.

secrets:status:read is its own scope because the list of which credentials a project holds, which are stale, and which no automation will ever rotate is a map of where to push. An adopter must be able to grant a users pane without also granting that.

secrets:rotate is separate again, and folding it would have been an escalation with a name that hid it. Matching is exact, so a scope confers every route requiring it — a rotation behind secrets:status:read would have handed credential replacement to every adopter who ever wanted a status pane, retroactively and without being asked.

It never enters a default grant either, because default grants classify by route method and every route requiring it is a POST.

A capability that can write a resource must be able to read it

Payments once shipped two writes and no read, which is a subtler failure than a missing feature: a pane over a resource nothing declares computes absent and vanishes, so the surface reports that nothing is wrong.

Not blocked. Not refused. Absent — which no grant and no seed can repair, because there was no route to grant a scope to.

A refusal an adopter can act on requires a route to refuse — which is the invariant to hold onto if you are writing your own client. The invariant is now stated where the tables are defined, and a test enforces it.

ESC