Decisions
Not on npm yet
npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
Decision is what every entry point of @evanion/acl answers with. allowed
is the answer, and the other three fields explain it without a second call.
interface Decision {
key: string;
allowed: boolean;
reason: Reason;
rule?: string; // the rule that decided; see the table for which reasons carry it
missing?: string[]; // the paths that did not read, on 'unevaluable'
}What can hands back
can answers one Decision for one row.
const const decision: Decisiondecision = .({ : '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]>>;reason | Meaning |
|---|---|
allow | an allow rule matched; carries rule |
no-rule-matched | rules were present and none passed, or there were none |
denied | a deny rule matched; carries rule |
unknown-action | the key is not in the matrix; the fail-closed path only |
unevaluable | a rule read a path the object did not carry; carries missing, and rule when the deny side was the unreadable one |
unusable-clock | a rule read a now that is not an instant; carries rule |
stale-contract | the 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 readobject.*. 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
idandbody, and the rule readsobject.askedBy.
const decision = access.can(subject, 'question', 'update', { status: 'draft' });
// { key: 'question.update', allowed: false, reason: 'unevaluable',
// missing: ['object.askedBy'] }What to do with one depends on where the call is:
- Server, enforcing. Treat it as a refusal.
allowedis alreadyfalse, 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
unevaluablethat no refetch repairs is a bug in the matrix, usually a mistyped field name. Aschematurns that class into anUnknownFieldErrorat 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'); // -> truelisting.read reads only subject.roles. listing.update carries a deny on
object.locked, so a guard ahead of the fetch only ever holds unevaluable:
if (access.readsObject('listing', 'update')) {
// Never decidable here. Refuse loudly, or record that the handler still owes
// the real decision. Do not wave the request through.
}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 reads | What 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 scope | follows 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 data | decides 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, capabilities and authorize each answer more than one question per
call:
access.canMany(subject, 'question', 'update', rows); // one decision per row
access.capabilities(subject); // every permission, keyed
access.authorize(subject); // a handle with the subject bound| Call | What it answers |
|---|---|
can | one question, and a loop of it re-settles the clock each pass |
canMany | one decision per row, settling the clock once for the whole list |
capabilities | the whole matrix against one context, which a client hydrates a UI from |
authorize | a 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 passes | What the engine reads |
|---|---|
| nothing | the wall clock |
| an ISO 8601 string | that instant; the form survives JSON, so a client-supplied one passes straight through |
| epoch milliseconds | that instant; the form survives JSON, so a client-supplied one passes straight through |
a Date | that 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', , );
.; // -> falseThe 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.