rules
Rule builder API — effect, actions, resources, priority, scopes, and metadata.
Rule structure
A rule has four core pieces:
- Effect —
allowordeny - Match set — actions and resources the rule applies to
- Priority — used by the
highest-prioritycombining algorithm - Conditions — contextual checks via the
Whenbuilder (see conditions)
Builder methods
| Method | Description |
|---|---|
allow() | Set effect to allow (default) |
deny() | Set effect to deny |
on(...actions) | Actions this rule applies to. Defaults to ['*'] |
of(...resources) | Resources this rule applies to. Defaults to ['*'] |
priority(p) | Numeric priority. Higher wins with highest-priority algorithm. Defaults to 10 |
desc(d) | Optional description |
when(fn) | Conditions that must ALL be true (AND logic) |
whenAny(fn) | Conditions where ANY can be true (OR logic) |
forScope(...scopes) | Restrict this rule to specific scopes. Composes with when() |
meta(m) | Arbitrary metadata |
build() | Produce the final Rule object |
Effect
Every rule is either an allow or a deny:
defineRule('owner-allow').allow().on('update').of('post').when((w) => w.isOwner()).build()
defineRule('banned-deny').deny().on('*').of('*').when((w) => w.attr('status', 'eq', 'banned')).build()defineRule('owner-allow').allow().on('update').of('post').when((w) => w.isOwner()).build()
defineRule('banned-deny').deny().on('*').of('*').when((w) => w.attr('status', 'eq', 'banned')).build()allow() is the default effect — calling it explicitly is for clarity.
Actions and resources
Pass one or many. Wildcards ('*') match all:
.on('create', 'update', 'delete') // CRUD subset
.on('*') // all actions
.of('post', 'comment') // multiple resource types
.of('*') // all resources.on('create', 'update', 'delete') // CRUD subset
.on('*') // all actions
.of('post', 'comment') // multiple resource types
.of('*') // all resourcesSee building policies for hierarchical resource matching (dashboard matches dashboard.users).
Priority
Used by the highest-priority combining algorithm. Default is 10:
defineRule('emergency-override')
.allow()
.on('*')
.of('*')
.when((w) => w.role('super-admin'))
.priority(100) // beats everything
.build()defineRule('emergency-override')
.allow()
.on('*')
.of('*')
.when((w) => w.role('super-admin'))
.priority(100) // beats everything
.build()Other algorithms (deny-overrides, allow-overrides, first-match) ignore priority.
Scopes (forScope)
Restrict a rule to specific scopes. Composes with when():
defineRule('org-admin-allow')
.allow()
.on('manage')
.of('billing')
.forScope('org-1', 'org-2')
.when((w) => w.role('billing-admin'))
.build()defineRule('org-admin-allow')
.allow()
.on('manage')
.of('billing')
.forScope('org-1', 'org-2')
.when((w) => w.role('billing-admin'))
.build()This rule fires only when the request's scope is org-1 or org-2 AND the subject has the billing-admin role.
Metadata
Attach arbitrary metadata to rules. Useful for audit logging, debugging, or admin UIs:
defineRule('gdpr-consent-required')
.allow()
.on('read')
.of('user-profile')
.when((w) => w.attr('gdprConsent', 'eq', true))
.meta({
compliance: 'GDPR',
reviewedBy: 'legal-team',
addedAt: '2026-01-15',
})
.build()defineRule('gdpr-consent-required')
.allow()
.on('read')
.of('user-profile')
.when((w) => w.attr('gdprConsent', 'eq', true))
.meta({
compliance: 'GDPR',
reviewedBy: 'legal-team',
addedAt: '2026-01-15',
})
.build()Metadata is preserved through serialization and surfaces in engine.explain() traces.
Empty rules vs unconditional rules
| Shape | Behavior |
|---|---|
r.allow().on('read').of('post') (no when) | Always matches — fast path in the engine |
r.allow().on('read').of('post').when((w) => w) (empty when) | Same — vacuously true |
r.allow().on('read').of('post').when((w) => w.eq('x', 'y')) | Conditional |
The fast-path engine cache (evaluatePolicyFast) pre-computes results for unconditional rules at policy-load time. This is the main reason r.on('read').of('post') (no when) outperforms r.on('read').of('post').when((w) => w) even though they're semantically identical.