Matter Docs
Scope DSL grammar
The pattern language Matter uses to constrain what a Token can do. Atoms, wildcards, deny-wins composition, resource pinning, condition stubs, evaluation order, common patterns, and anti-patterns. The canonical reference for restricted-key (rk_) and publishable-key (pk_) scope authoring.
Last updated
The Matter scope DSL is the pattern language stored inside every Token.scopes record. When a tok_*, rk_*, or pk_* token reaches the auth gateway, the runtime parses the policy, evaluates the requested action, and either lets the request through or returns an RFC 7807 problem-details response naming the rule that rejected it.
The canonical implementation lives at packages/auth-api-key/src/scope-policy.ts. This page documents the grammar it implements. Treat the code as authoritative; if the grammar and the code disagree, the code wins and this page is the bug.
Tokens
A policy is a JSON array of rules. Each rule is one strict object with up to four keys:
[
{ "allow": ["entities.*", "filings.read"] },
{ "deny": ["entities.dissolve"] },
{ "resources": ["ent_abc", "ent_def"] },
{ "conditions": { "ip_country_in": ["US"] } }
]The runtime rejects any policy that fails ScopePolicy.safeParse — unknown keys are forbidden by the strict schema, and a malformed policy fails closed with no_matching_allow. This is by design: a policy that cannot be parsed has no defensible interpretation.
Atoms
An atom is a dot-delimited string identifying an action.
entities.create— the exact action. Resource name, then verb.entities.read— read access on entities. Verbs are stable:create,read,update,dissolve,list,stream.*— single-segment wildcard.*matches an atom with no dots (an unsegmented action like a top-level verb).entities.*— single-segment trailing wildcard. Matchesentities.create,entities.read. Does not matchentities.cap_table.read— wildcards are segment-scoped, not greedy.entities.**— multi-segment trailing wildcard. Matchesentities.create,entities.cap_table.read,entities.shares.transfer. Matches one or more remaining segments.**— the global wildcard. Matches every action. Use sparingly; a policy with**inallowand nothing indenyis functionally ansk_*key with extra steps.
Wildcards do not span dots unless explicitly **
entities.* and entities.** are not interchangeable. The single-segment form is the safer default — it gives a token access to the top-level verbs of a resource without granting access to nested sub-resources that may carry sharper PII (cap tables, founder agreements, signing chains). Reach for ** only when the policy author has audited every current and future descendant of the prefix.
Resource scoping is a per-rule list
resources pins the rule to specific typed IDs. The runtime compares against EvalInput.resourceId. A request that hits a rule with resources: ["ent_abc"] and supplies a different ID is rejected with resource_not_in_set. Resource scoping composes naturally with allow — the rule is the AND of "action matches an allow pattern" and "resource is in the set".
Conditions are structural in this release
The conditions key accepts env-bound predicates — ip_country_in: ["US"], mfa_recent_seconds_lt: 300, time_of_day_in: ["09:00-17:00"]. The grammar parses them today; runtime evaluation lands with the SIEM ingestion. Conditions are forwards-compatible: a policy authored against today's grammar continues to validate once the evaluators land.
Composition
Deny wins
If any rule's deny pattern matches the action, the request is rejected with explicit_deny. The runtime evaluates deny rules first, before any allow rules are consulted. This holds even when the allow set is broad — { allow: ["**"] } followed by { deny: ["entities.dissolve"] } rejects every dissolve, regardless of order in the array.
Allow is a union
The allow union is computed across all rules. A request passes the allow phase if at least one allow pattern in any rule matches the action. If the policy contains no allow patterns anywhere, the policy rejects by default — a deny-only policy is a fully-closed door.
Resource sets compose per-rule
Each rule's resources list is independent. If rule A says { allow: ["entities.read"], resources: ["ent_abc"] } and rule B says { allow: ["filings.read"] } with no resource pin, then entities.read is restricted to ent_abc but filings.read has no resource constraint.
Evaluation order
1. Parse the policy. Malformed → reject with no_matching_allow.
2. Walk every deny pattern across every rule.
- If any match the action → reject with explicit_deny.
3. Walk every allow pattern across every rule.
- If no allow patterns exist anywhere → reject with no_matching_allow.
- If at least one matches → continue.
4. Walk every rule's resources list.
- If a rule has resources set, and the action targets a resourceId
not in that list → reject with resource_not_in_set.
5. Walk conditions (structural validation only in this release).
6. Accept.The order is fixed and not configurable. A policy author who wants conditional allow behaviour writes multiple rules, not nested conjunctions.
Precedence rules
Precedence is governed by three invariants:
- Deny is absolute. No allow pattern, no resource pin, and no condition can rescue an action that matches a deny pattern. The only way to lift a deny is to remove it from the policy.
- Allow is permissive. Allow patterns union. If two rules both allow
entities.read, the duplicate is harmless. If one rule allowsentities.*and another deniesentities.dissolve, the deny wins forentities.dissolveand the allow wins for the rest. - Resources tighten. A rule with
resourcesset narrows that rule's allow patterns. It does not constrain rules that do not declareresources.
A useful mental model: each rule defines a positive cone (its allow patterns, optionally pinned by resources). Deny patterns punch holes through every cone. The policy admits an action if and only if the action lands inside at least one cone and outside every hole.
Common patterns
Restricted key — read-only, no PII
[
{ "allow": ["*.read", "events.stream"] },
{ "deny": ["stakeholders.read"] }
]The token can read every public-facing surface and stream events, but the deny carves out PII-bearing reads. This is the rk_* baseline; see packages/auth-api-key/src/scope-policies/rk-restricted.ts.
Publishable key — entity + document reads only
[
{ "allow": ["entities.read", "documents.read"] }
]pk_* keys ship in browser bundles. The allow set is intentionally narrow: enough surface to render a public-facing page, nothing more. Audit, intent, event, and stakeholder reads are not in the allow set, so the default closed-door rule rejects them.
Resource-pinned read
[
{ "allow": ["entities.**"], "resources": ["ent_abc"] }
]A vendor needs deep read into one entity. The resource pin keeps the cone tight; future entities created under the same org are not implicitly included.
Anti-patterns
{ "allow": ["**"] }on a long-lived token. This is ansk_*key with atok_prefix and weaker rotation. If a caller needs every action, mint the right kind.- Conditions used as primary authorisation. Conditions narrow an already-permitted action. They are not a substitute for
deny; if an action must be rejected whenmfa_recent_seconds_lt > 300fails, write the deny rule, not a condition that the runtime ignores when the evaluator is absent. - Wildcards over verbs.
**.readto "match every read" is brittle — a future resource namedread.auditwould slip through. Use explicit<resource>.readallows instead. - Resource pins on broad policies. Pinning
resources: ["ent_abc"]while keepingallow: ["**"]is contradictory in spirit; the broad allow is the security ceiling, not the resource list.
Debugging tips
When a request returns 403 with a problem+json body, the detail string names the rule that rejected it: Action entities.dissolve is denied by policy pattern entities.dissolve. Walk the policy bottom-up — confirm the deny is intentional, then check the allow union, then the resource set.
The fastest local repro is the unit test suite. Add a fixture row to packages/auth-api-key/__tests__/scope-policy.test.ts that mirrors the production policy and the failing action. The test runner reports the rejection reason verbatim, which is the same string the runtime would emit in production. The token itself never appears in the test — only the policy and the action — so reproducing a customer issue does not require leaking a secret.
For coverage of the kind-to-policy mapping, see the drift gate at apps/api/__contracts__/scope-policy-coverage.test.ts. The gate guarantees every token kind that ships in the TokenKind enum has either a default scope policy or a documented allowlist entry — there is no kind that can be minted without a policy story.