Skip to Content
AuthorizationThe Decision object every check returns

The Decision object every check returns

@evanion/acl 0.1.0 and its patch releases up to 0.1.2, the version on npm. Values on this page were produced by running the source at 40b8f84, a later commit than the 0.1.0 tag, and are not re-executed.

Decision is what every entry point of @evanion/acl answers with. allowed is the answer, and reason, rule and missing explain it without a second call. A decision whose deny rule could not read status carries all five fields:

import { } from '@evanion/acl'; import type { Decision } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')) .('update', .('object.status', 'locked')) .('locked-question'), ) .(); // The list query selected `askedBy` and not `status`. const : Decision = .({ : 's1' }, 'question', 'update', { : 's1', }); ; // -> { key: 'question.update', allowed: false, reason: 'unevaluable', rule: 'locked-question', missing: ['object.status'] }

What can hands back

can answers one Decision for one row.

const
const decision: Decision
decision
= .({ : 's1' }, 'question', 'update', );

Chain a .for() per kind and question, listing and report share one document.

The reasons a Decision carries

reason is output only. Nothing in the library reads a reason back to decide anything, and gating on the reason string grants what the engine refused.

Reason holds the seven values in the table below, and this build checks that. A reason the package adds and the table omits leaves NotInTheTable with a member, which a type constrained to never cannot have:

import type { } from '@evanion/acl'; const = [ 'allow', 'no-rule-matched', 'denied', 'unknown-action', 'unevaluable', 'unusable-clock', 'stale-contract', ] as satisfies readonly []; type < extends never> = ; type = <<, (typeof )[number]>>;
reasonMeaning
allowan allow rule matched; carries rule
no-rule-matchedrules were present and none passed, or there were none
denieda deny rule matched; carries rule
unknown-actionthe key is not in the matrix; the fail-closed path only
unevaluablea rule read a path the object did not carry; carries missing, and rule when the deny side was the unreadable one
unusable-clocka rule read a now that is not an instant; carries rule
stale-contractthe document is past the freshness budget it states

The deny side settles first, so a rule on an unevaluable names the deny rule that could not be read. An allow side left unevaluable carries missing alone.

Default deny, express allow, express deny wins

@evanion/acl allows nothing without an allow rule that matched. No rules, an empty rule list and an unknown action each refuse. Above that, a deny that matched outranks an allow that also matched, and a deny the engine could not read refuses too.

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')) .('update', .('object.status', 'locked')), ) .(); const = { : 's1' }; // An allow rule matched, and no deny did. const = .(, 'question', 'update', { : 's1', : 'draft', }); .; // -> 'allow' // Somebody else's question: no allow rule matched. const = .(, 'question', 'update', { : 's2', : 'draft', }); .; // -> 'no-rule-matched' // A matched deny outranks the allow that also matched. const = .(, 'question', 'update', { : 's1', : 'locked', }); .; // -> 'denied' // A projection carrying neither field. The deny side could not be read, so the // permission is not answerable yet — and the answer names what to fetch. const = .(, 'question', 'update', {}); .; // -> false .; // -> 'unevaluable' .; // -> ['object.status', 'object.askedBy']

Seven steps decide one permission, in a fixed order, and Why deny, then allow, then deny? has it.

unevaluable is a third state

unevaluable is neither an error nor a no. The engine could not reach a decision with the data it was handed, and missing names exactly what to fetch.

Absence is unevaluable for every operator, negative ones included. ne, not-in and contains over an absent path are as unevaluable as eq, because a field nobody read says nothing either way.

A caller sees it in two situations:

  • No instance at all. access.can(subject, 'question', 'create') against a permission whose rules read object.*. That is the create toggle, and the call carries no instance to compare against yet.
  • A projection that does not carry the field. A list query selected id and body, and the rule reads object.askedBy.
import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.askedBy', 'subject.id')), ) .(); // The list query selected `id` and `body`; the rule reads `askedBy`. const = { : 'q1', : 'Does this ship sleeved?' }; const = .({ : 's1' }, 'question', 'update', ); .; // -> 'unevaluable' .; // -> ['object.askedBy'] // Fetch exactly what `missing` names, then ask once more. const = { ..., : 's1' }; .({ : 's1' }, 'question', 'update', ).; // -> true

What to do with one depends on where the call is:

  • Server, enforcing. Treat it as a refusal. allowed is already false, so fetch the named paths and re-ask, or return a 403. It is never a grant.
  • Client, rendering. Render the control as pending, fetch the paths in missing, re-ask once. Both sides’ unreadable paths come back together, so one refetch settles the permission.
  • Authoring. An unevaluable that no refetch repairs is a bug in the matrix, usually a mistyped field name. A schema turns that class into an UnknownFieldError at construction.

Deciding before the row is loaded

An HTTP guard ahead of the handler that loads the row gets unevaluable from every object-dependent permission, every time, and cannot tell that apart from a call that happened to lack data.

access.readsObject(key, action) says which is which, from the document:

import { } from '@evanion/acl'; type = { : string; : boolean }; const = <{ : string; : string[] }, { : }>() .('listing', () => .('read', .('subject.roles', 'owner')) .('update', .) .('update', .('object.locked', true)), ) .(); .('listing', 'read'); // -> false .('listing', 'update'); // -> true

listing.read reads only subject.roles. listing.update carries a deny on object.locked, so a guard ahead of the fetch only ever holds unevaluable. The guard decides what the subject settles and records that the handler owes the rest:

import { } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : boolean }; const = <, { : }>() .('listing', () => .('read', .('subject.roles', 'owner')) .('update', .('subject.roles', 'owner')) .('update', .('object.locked', true)), ) .(); /** What an HTTP guard does before the handler loads the listing. */ function (: , : 'read' | 'update') { // Never decidable here: the handler owes the decision on the row it loads. if (.('listing', )) return 'handler-decides'; return .(, 'listing', ). ? 'pass' : 'refuse'; } const = { : 'u1', : ['owner'] }; const = { : 'u2', : ['customer'] }; (, 'read'); // -> 'pass' (, 'read'); // -> 'refuse' (, 'update'); // -> 'handler-decides' .(, 'listing', 'update').; // -> 'unevaluable'

readsObject is true when any allow or deny rule of this permission names an object.* path, on either operand. It takes no subject, reporting a fact about the matrix.

Field rules do not count. A transitions config reads the object only on the canFields write axis, where the caller holds the row already.

Why only the object

An absent object.* path is unevaluable, and an absent subject.* path is a plain miss.

What the rule readsWhat an absent path does
object.*unevaluable: the object is a projection the caller chose, so a field it lacks says nothing about the row
subject.*fails the condition: the app resolves the subject whole before the call, so a subject carrying no roles has none
one operand in each scopefollows the operand that failed, so an absent subject comparand fails the condition and an absent object comparand leaves it unevaluable
a branch that needs no datadecides a rule that mixes both: p.or(p.eq('object.askedBy', 'subject.id'), p.always) with no instance is allow, because always answers without the object

Asking more than one question

canMany and capabilities answer more than one question per call, and authorize binds the subject so every later call leaves it out:

import { } from '@evanion/acl'; type = { : string }; const = <{ : string }, { : }>() .('question', () => .('read', .) .('update', .('object.askedBy', 'subject.id')), ) .(); const = { : 's1' }; const = { : 's1' }; const = [, { : 's2' }]; // One decision per row. .(, 'question', 'update', ).(() => .); // -> [true, false] // Every permission, keyed. .(.()); // -> ['question.read', 'question.update'] // A handle with the subject bound. const = .(); .('question', 'update', ).; // -> true
CallWhat it answers
canone question, and a loop of it re-settles the clock each pass
canManyone decision per row, settling the clock once for the whole list
capabilitiesthe whole matrix against one context, which a client hydrates a UI from
authorizea handle with the subject bound, and one instant with it: the now the caller passed, or the moment of the call

Prefer canMany or capabilities over a loop of can.

import { , type } from '@evanion/acl'; type = { : string; : string[] }; // One policy, every object kind the app has. A `.for()` per kind, and one // bound handle answers for all of them. type = { : { : string }; : { : string } }; // `report` grants a verb outside the default CRUD set, so it names its own // vocabulary. `listing` omits one and takes `Action`. const = <, , { : | 'export' }>() .('report', () => .('read', .('subject.roles', 'bookseller')) .('export', .('subject.roles', 'owner')), ) .('listing', () => .('read', .('subject.roles', 'owner'))) .(); const = .({ : 'u1', : ['bookseller'] }); .('report', 'read').; // -> true .('report', 'export').; // -> 'no-rule-matched'

authorize binds the subject, so a middleware, loader, action or React server component evaluates without restating it.

The clock

now is a parameter of every entry point, and the caller supplies it in any Instant form.

What the caller passesWhat the engine reads
nothingthe wall clock
an ISO 8601 stringthat instant; the form survives JSON, so a client-supplied one passes straight through
epoch millisecondsthat instant; the form survives JSON, so a client-supplied one passes straight through
a Datethat instant
an instant that does not parse{ allowed: false, reason: 'unusable-clock' } from every permission whose decision reads it, allow side and deny side alike
import { } from '@evanion/acl'; const = < { : string }, { : { : string } }, { : 'buy' } >() .('sale', () => .('buy', .('now', '2026-01-01T00:00:00Z'))) .(); const = .( .({ : new ('2026-06-01T00:00:00Z') }), ) as { : string }; const = .({ : 's1' }, 'sale', 'buy', , .); .; // -> true const = .('2025-06-01T00:00:00Z'); const = .({ : 's1' }, 'sale', 'buy', , ); .; // -> false

The engine settles now to one epoch per call, so a matrix with many time windows parses the clock once. An unparseable instant never throws and never grants. Decide deliberately what a caller may supply; the security contract has it.

Last updated on