Guides
Scope-policy grammar
The structured DSL Matter uses for token scopes. Every token carries a policy object that the constant-time evaluator runs on every request.
Last updated
Scope-policy grammar
Every Matter token carries a structured scope policy — not a flat
list of permissions. The constant-time evaluator at
apps/api/lib/scope-policy.ts runs it on every request.
Why a DSL
A flat ["entities.create", "entities.update", ...] scope list scales
poorly:
- Operations multiply faster than the list can stay current.
- Conditional access (only this resource, only in this region, only under this dollar threshold) needs structure flat lists can't express.
- Tier-aware caps need a way to say "allow these operations up to tier 2."
The DSL solves all three. Pure logic; constant-time evaluation; tested against 200+ property-based runs.
Grammar
Policy = {
allow?: ScopeEntry[];
deny?: ScopeEntry[];
conditions?: Condition[];
}
ScopeEntry =
| string // shorthand: operation glob
| {
operation: string; // glob: entities.* | * | rounds.close_package
tierMax?: 1 | 2 | 3 | 4; // additional cap on the principal's tier
resources?: string[]; // resource-id patterns (ent_aaa*)
conditions?: Condition[]; // inline conditions for this entry
}
Condition =
| { kind: "ip_in"; cidrs: string[] }
| { kind: "mode_in"; modes: ("live" | "sandbox" | "test")[] }
| { kind: "region_in"; regions: ("us_east" | "eu_central" | "ap_southeast")[] }
| { kind: "portfolio_in"; portfolioIds: string[] }
| { kind: "time_window"; startUtc: number; endUtc: number }
| { kind: "amount_max"; field: string; maxCents: number }Evaluation rules
- Top-level conditions are AND-applied. Every one must pass
before any
allowentry is considered. - Deny wins. A matching
denyentry rejects the operation even ifallowwould have permitted it. - Empty
allowmeans "unrestricted within tier, subject to deny- conditions." Useful for narrowing-by-deny patterns.
- Wildcard operation
*matches every operation up to the entry'stierMaxcap. - Constant-time evaluation. The evaluator walks the full
allow+denylists for every request, accumulating booleans rather than short-circuiting. This defeats timing oracles on policy shape.
Examples
Tier-2 prepare-only agent
{
"allow": [
{ "operation": "intents.*", "tierMax": 2 },
{ "operation": "entities.list" },
{ "operation": "entities.retrieve" }
]
}Per-portfolio read-only
{
"conditions": [
{ "kind": "portfolio_in", "portfolioIds": ["pf_studio_a"] }
],
"allow": [
{ "operation": "*", "tierMax": 1 }
]
}Region-pinned admin
{
"conditions": [
{ "kind": "region_in", "regions": ["eu_central"] },
{ "kind": "mode_in", "modes": ["live"] }
],
"allow": [
{ "operation": "*", "tierMax": 4 }
],
"deny": [
{ "operation": "tokens.revoke" }
]
}Transfer cap
{
"allow": [
{ "operation": "transfers.create", "tierMax": 4,
"conditions": [
{ "kind": "amount_max", "field": "amount_cents", "maxCents": 100000 }
]
}
]
}The transfer is permitted up to $1,000; over that the request is
denied with scope_denied + the amount_max condition reason in
detail.
Wildcards
Operation globs use * to match anything within a segment:
entities.*matchesentities.create,entities.list,entities.retrieve— but NOTentities.grants.exercise(two segments deep).*matches anything regardless of segment count.- Deep globs (
**) are not supported — the operation namespace is intentionally flat.
Token-kind hierarchy interaction
The scope DSL is the second gate. The first gate is the token's
kind hierarchy (apps/api/lib/token-kind-policy.ts):
| Kind | Max tier | Publishable | Read-only default |
|---|---|---|---|
sk_* | 4 | no | no |
rk_* | 2 | no | no |
pk_* | 1 | yes | yes |
tok_* | 4 | no | no |
A pk_live_* token with a policy that grants entities.create is
still denied because the kind gate rejects non-publishable operations
before the scope evaluator runs. Express policies for the kind
contract first; the scope DSL narrows from there.
Verification
The scope-policy preview endpoint (POST /v1/tokens/preview_scope)
runs the same evaluator against the entire operation catalogue,
returning a per-operation verdict + per-lifecycle-phase summary.
Customers iterate on policies in the dashboard's scope debugger
without minting a real token.
Constant-time guarantee
The plan's threat model calls out timing-oracle attacks on policy shape. The evaluator's contract:
- Walks every entry in
allow+deny, even after a match. - Accumulates boolean decisions rather than short-circuiting.
- Uses constant-time comparison for opaque values (CIDR matching, resource-id glob).
A property-based test (apps/api/__tests__/properties/scope-policy.property.test.ts)
exercises 200+ random (policy, context) pairs against the
load-bearing invariants: deny-wins, empty-allow-permits-within-tier,
wildcard-matches-all, determinism.