Skip to content

Ward 3.1 Migration ​

Roles are declarative data ​

WardRule gains a first-class roles?: readonly string[] field, evaluated beside action, resource, and attributes. allow() and deny() store the role list as data instead of compiling it into a condition closure, so ward.rules stays serializable and loggable end-to-end.

ts
// Before — roles hidden inside a condition closure
{ action: 'read', resource: 'posts', effect: 'allow', condition: ({ principal }) => principal?.roles.includes('editor') ?? false }

// After — roles as data
{ action: 'read', resource: 'posts', effect: 'allow', roles: ['editor'] }

ANONYMOUS matches a null principal and WILDCARD matches any authenticated principal, exactly as before. predicate.hasRole() is removed — declare roles on the rule instead. condition remains for checks roles cannot express, such as predicate.owns().

allowedActions() derives its candidates ​

ward.knownActions lists the exact allow-side actions derived from the compiled rules, so call sites no longer pass a knownActions array that can go stale.

ts
// Before
ward.allowedActions({ knownActions: ['read', 'update', 'delete'], principal, resource: 'posts' });

// After
ward.allowedActions({ principal, resource: 'posts' });
// optionally narrow the candidates:
ward.allowedActions({ filter: ['read', 'update'], principal, resource: 'posts' });

WardAllowedActionsInput and BoundWardAllowedActionsInput are removed, and BoundWard is now Pick<Ward, 'allowedActions' | 'checkAll' | 'decide'>.

patternCovers() is removed ​

Ward does no conflict detection, so the coverage helper had no production callers. matchesPattern() remains.

Ward 3.0 Migration ​

Evaluation is ordered ​

Priority, specificity scoring, conflict detection, and inheritance are removed. Rules run in declaration order and the first match wins. Put explicit denies and exceptions before broad allows.

ts
// Before
allow('editor', 'posts', ['read'], { priority: 10 });

// After
createWard([
  deny('suspended', 'posts', ['read']),
  allow('editor', 'posts', ['read']),
]);

Replace explain() with decide() ​

ts
// Before
const decision = ward.explain({ action: 'read', principal, resource: 'posts' });
const allowed = decision.allowed;

// After
const decision = ward.decide({ action: 'read', principal, resource: 'posts' });
const allowed = decision.effect === 'allow';

decide() returns matched: true with an immutable matched rule, or matched: false for default deny. trace() and rulesInScope() are removed because declaration order and the returned rule provide the decision explanation.

Bind principals with forPrincipal() ​

ts
// Before
const permissions = ward.forUser(principal);
permissions.explain({ action: 'read', resource: 'posts' });

// After
const permissions = ward.forPrincipal(principal);
permissions.decide({ action: 'read', resource: 'posts' });

An omitted principal and null now both represent anonymous access. Bound principals are immutable snapshots.

Update batch checks ​

ts
// Before
ward.checkAll(principal, [{ action: 'read', resource: 'posts' }]);

// After
ward.checkAll([{ action: 'read', principal, resource: 'posts' }]);
// or
ward.forPrincipal(principal).checkAll([{ action: 'read', resource: 'posts' }]);

allowedActions() remains available with typed actions, resources, and attributes.

Type attributes explicitly ​

The second generic now types resources. The third generic types decision attributes and condition keys.

ts
type Attributes = { authorId: string };

const ward = createWard<'read' | 'update', 'posts', Attributes>([
  allow<'read' | 'update', 'posts', Attributes>('editor', 'posts', ['update'], {
    when: predicate.owns<Attributes>('authorId'),
  }),
]);

ward.decide({ action: 'update', attributes: { authorId: post.authorId }, principal, resource: 'posts' });

Rename prior data fields to attributes. Attribute values must be JSON-compatible, finite, and acyclic.

Update condition errors ​

WardPredicateError is replaced by WardConditionError. Conditions must return a boolean synchronously. Thrown errors are preserved as cause; Promises and non-boolean results throw the same typed error.

Update observation ​

tap() remains the decision-observation API, with a discriminated event object.

ts
ward.tap((event) => {
  if (event.type === 'decision') audit.write(event);
});

allowedActions() performs hypothetical checks and does not emit events.