Rules
Rule anatomy and the RuleBuilder API - effect, actions, resources, priority, forScope, when, whenAny, metadata, and the built IRule shape
A rule is the atomic unit of a policy: one effect, the actions and resources it covers, a priority, and a condition tree. This page covers the RuleBuilder returned by defineRule() and by PolicyBuilder.rule(), and the AccessControl.IRule object it produces.
Anatomy
Every rule has the same six parts. The diagram shows which builder call sets each and its default.
EFF,ACT,RESdecide whether the rule is a candidate for a request (see rule matching).CONdecides whether a candidate matches.PRIranks matched rules underfirst-matchandhighest-priority.METAnever affects evaluation.
Built shape (copied from src/core/types/access-control.ts):
interface AccessControl.IRule<TAction extends string = string, TResource extends string = string> {
readonly id: string
readonly effect: AccessControl.Effect // 'allow' | 'deny'
readonly description?: string
readonly priority: number
readonly actions: readonly (TAction | '*')[]
readonly resources: readonly (TResource | '*')[]
readonly conditions: AccessControl.IConditionGroup
readonly metadata?: Readonly<IamPrimitives.Attributes>
}
Effect
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, but a rule that configures nothing at all is refused by build() - the defaults are the broadest possible grant, allow * on * unconditional, so returning one silently is not an option. Any of allow(), deny(), on(), of(), forScope(), whenAny(), or a when() that actually added a condition counts as configuring the rule. allow() is also how you override an earlier deny() on the same builder. How a deny interacts with an allow depends on the policy's combining algorithm.
[@gentleduck/iam:builder] RuleBuilder.build("x") was never configured - no effect, action,
resource, scope or condition was set. The defaults are the broadest possible grant
(allow * on *, unconditional), so this is refused rather than returned.
Call `.allow()` if a broad grant is intended.
Actions and resources
on() and of() are variadic and replace the previous value on each call. Wildcards and suffix patterns:
.on('create', 'update', 'delete') // a set of actions
.on('*') // every action
.of('post', 'comment') // several resource types
.of('*') // every resource
.of('dashboard.*') // subtree under dashboard (not dashboard itself)
.on('posts:*') // colon-prefixed actions
A bare literal only matches itself: .of('dashboard') does not match dashboard.users. The full pattern table is on building policies; the matcher is explained on rule matching.
of() also narrows the builder's TActiveResource type parameter so a following .when((w) => w.resourceAttr(...)) autocompletes only that resource's attributes when a typed context declares resourceAttributes. See type-safe roles.
Calling on() or of() with no arguments produces an empty array, which RuleBuilder.build() rejects with MISSING_FIELD.
Priority
Default 10. Higher wins. Priority is consulted by two algorithms:
highest-priority- the matched rule with the largest priority wins.first-match- also priority-aware: the matched rule with the largest priority wins, and only a tie falls back to source order.
deny-overrides and allow-overrides ignore priority entirely.
defineRule('emergency-override')
.allow()
.on('*')
.of('*')
.when((w) => w.role('super-admin'))
.priority(100)
.build()
priority must be a finite number. NaN or Infinity is rejected at build() (INVALID_TYPE). A row that bypassed validation with a non-finite priority ranks as 0 at evaluation time rather than losing every comparison.
Scopes with forScope()
forScope(...scopes) restricts the rule to one or more scopes by adding a condition on the request's scope. It composes with when() / whenAny() in any call order. The diagram shows how build() merges it.
- One scope emits
{ field: 'scope', operator: 'eq', value }; two or more emitoperator: 'in'(SC). '*'entries are removed;forScope('*')alone is a no-op andforScope('*', 'org-1')keepsorg-1.- With
when(), the scope condition is prepended to thealllist (A). WithwhenAny(), the wholeanygroup becomes the second element of a newallgroup (B), so the rule reads "scope matches AND (any of ...)". - Order independence is pinned by the test "forScope is order-independent relative to when()".
defineRule('org-admin-allow')
.allow()
.on('manage')
.of('billing')
.forScope('org-1', 'org-2')
.when((w) => w.role('billing-admin'))
.build()
// conditions: { all: [ { field: 'scope', operator: 'in', value: ['org-1', 'org-2'] },
// { field: 'subject.roles', operator: 'contains', value: 'billing-admin' } ] }
This rule fires only when the request's scope is org-1 or org-2 AND the subject holds billing-admin. When.scope() / When.scopes() produce the same condition inside the when() callback; forScope() is the rule-level spelling.
Conditions with when() and whenAny()
when(fn) stores fn(new When()).buildAll() - every condition must hold. whenAny(fn) stores buildAny() - at least one must hold. A second call ANDs onto the first as { all: [previous, next] } rather than replacing it, which costs one level of nesting depth. Everything you can put inside the callback is on conditions and nesting.
Description and metadata
desc() sets description; it appears in the explain trace (IRuleTrace.description). meta() sets metadata, an IamPrimitives.Attributes bag that is stored on the rule, round-trips through every adapter, and is never read during evaluation.
defineRule('gdpr-consent-required')
.allow()
.on('read')
.of('user-profile')
.desc('Profile reads require recorded consent')
.when((w) => w.attr('gdprConsent', 'eq', true))
.meta({ compliance: 'GDPR', reviewedBy: 'legal-team', addedAt: '2026-01-15' })
.build()
In development mode the deciding rule object is returned whole as decision.rule, and engine.explain() exposes it as decidingRule on each policy trace, so metadata is reachable there. See explain and debug.
Empty versus unconditional rules
| Shape | Built conditions |
|---|---|
r.allow().on('read').of('post') (no when) | { all: [] } |
r.allow().on('read').of('post').when((w) => w) | { all: [] } |
r.allow().on('read').of('post').whenAny((w) => w) | { any: [] } - matches nothing |
r.allow().on('read').of('post').when((w) => w.eq('x', 'y')) | conditional |
The first two forms build identical objects, so there is no performance difference between them. An empty all is vacuously true; an empty any is false. Note that when((w) => w) alone does not configure the rule - the allow() in each row above does. Full empty-group semantics on nesting.
API reference
RuleBuilder
| Method | Signature | Default / notes |
|---|---|---|
allow | allow(): this | Default effect |
deny | deny(): this | - |
desc | desc(d: string): this | Optional |
priority | priority(p: number): this | 10; must be finite |
on | on(...actions: (TAction | '*')[]): this | ['*'] |
of | of<R extends TResource | '*'>(...resources: R[]): RuleBuilder<..., R> | ['*']; narrows TActiveResource |
forScope | forScope(...scopes: (TScope | '*')[]): this | No scope condition |
when | when(fn: (w: When) => When): this | { all: [] } |
whenAny | whenAny(fn: (w: When) => When): this | - |
meta | meta(m: IamPrimitives.Attributes): this | Optional |
build | build(): AccessControl.IRule<TAction, TResource> | Merges forScope, then validates the rule shape; throws on any error |
RuleBuilder is exported from @gentleduck/iam, @gentleduck/iam/core, and @gentleduck/iam/core/builder. Factory: defineRule.
Gotchas
RuleBuilder.build()validates the rule shape and throws, so a rule handed straight to an adapter fails where the bug was introduced rather than at the enclosing policy. Cross-rule and policy-level checks (DUPLICATE_RULE_ID,UNREACHABLE_TARGET) still only run inPolicyBuilder.build().when()andwhenAny()accumulate with AND. Use onewhen()with a nestedor()when you need both a requirement and an alternation.metadatavalues must beIamPrimitives.AttributeValue(scalars, scalar arrays, or a flat record of scalars).
See also
- Building policies -
definePolicy,defineRule,when - Conditions - the
Whenbuilder - Combining algorithms - how
priorityand effect resolve - Rule matching - action and resource patterns