## Rule structure

A rule has four core pieces:

* **Effect** - `allow` or `deny`
* **Match set** - actions and resources the rule applies to
* **Priority** - used by the `highest-priority` combining algorithm
* **Conditions** - contextual checks via the `When` builder (see [conditions](/duck-iam/core/policies/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 `AccessControl.IRule` object |

***

## Effect

Every rule is either an allow or a deny:

```typescript
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:

```typescript
.on('create', 'update', 'delete')   // CRUD subset
.on('*')                            // all actions
.of('post', 'comment')              // multiple resource types
.of('*')                            // all resources
```

See [building policies](/duck-iam/core/policies/building) for hierarchical resource matching (`dashboard` matches `dashboard.users`).

***

## Priority

Used by the `highest-priority` combining algorithm. Default is `10`:

```typescript
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()`:

```typescript
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:

```typescript
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()`](/duck-iam/advanced/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.