Why was this refused?
@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.
Every decision access.can returns carries reason beside allowed, and
rule or missing where one applies, and a screen reads those three to tell a
customer why an action is off.
Turning an acl refusal into what the screen says
The Edit button under a question is off, and the customer wants to know why. A
button that says “Not allowed” for every allowed: false tells a customer whose
row is still loading that they may never edit their own question.
allowed is still the only field a handler gates on, as
Can this user do this? sets out. reason, rule and missing
decide the label. The policy below carries a deny rule, written with p.deny,
which refuses whenever it matches and outranks the allow, and .id(name) gives
that rule the name a decision reports:
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'),
)
.();
/** What the Edit button under a question says. */
function (: Decision): string {
if (.) return 'Edit';
if (. === 'denied') return `Locked (${.})`;
if (. === 'unevaluable') return 'Checking';
return 'Not your question';
}
const = { : 'customer-41' };
const = (: <>) =>
.(, 'question', 'update', );
(({ : 'customer-41', : 'open' })); // -> 'Edit'
(({ : 'customer-41', : 'locked' })); // -> 'Locked (locked-question)'
(({ : 'customer-92', : 'open' })); // -> 'Not your question'
// The list query did not select `status`, so the deny rule could not be read.
const = ({ : 'customer-41' });
(); // -> 'Checking'
.; // -> 'locked-question'
.; // -> ['object.status']editLabel reads allowed first and then reason. denied means a deny rule
matched, and rule names it, because .id('locked-question') gave the rule
that name. The last call passed a row without status, so the deny rule could
not be read: the decision is unevaluable, missing names object.status, and
the button says Checking while the screen fetches that one field.
The seven reasons an acl decision carries
reason takes one of seven values, and each one tells a screen something
different:
reason | Carries | What the screen has |
|---|---|---|
allow | rule | nothing to explain |
no-rule-matched | none | a plain no, for this subject on this row |
denied | rule | a plain no, and which rule to name in a log |
unknown-action | none | this document carries no such key; fail closed |
unevaluable | missing | a refetch; the engine never decided |
unusable-clock | rule | the now this call passed is not an instant |
stale-contract | none | fetch the document again; this copy is old |
reason, rule and missing are output only, and no part of the library reads
them back. A screen branches on reason to pick a label. A handler that
branches on it to allow something has written a rule of its own beside the
policy.
stale-contract comes from a document another service published. The service
holding a copy tells parseMatrix, the call that adopts a foreign document,
when it last fetched that copy, as fetchedAt. The document states how long a
copy stays good, as maxStale in milliseconds, and past that bound every key
answers stale-contract. A fresh fetch repairs it, and
How long a consumer may keep deciding on an acl contract
sets out the bound.
When the acl clock decides unusable-clock
can, canMany, canFields and capabilities take now as a trailing
argument: an ISO 8601 string, epoch milliseconds or a Date. Omit it and the
call reads the wall clock. A rule built with p.after or p.before compares
that instant against a boundary, so p.after('now', '2026-10-01T09:00:00Z')
holds once the call’s instant passes the boundary. The third type argument names
place as an action of preorder, because the default actions are create,
read, update and delete:
import { } from '@evanion/acl';
// Preorders for a new game open at one instant.
const = <
{ : string },
{ : { : string } },
{ : 'place' }
>()
.('preorder', () =>
.('place', .('now', '2026-10-01T09:00:00Z'))
.('preorders-open'),
)
.();
const = { : 'customer-41' };
const = (: string | number) =>
.(, 'preorder', 'place', , );
('2026-10-02T12:00:00Z').; // -> true
(.('2026-09-30T12:00:00Z')).; // -> 'no-rule-matched'
// A header that was never a date. The rule that compared the clock is named.
const = ('next tuesday');
.; // -> 'unusable-clock'
.; // -> 'preorders-open'
'missing' in ; // -> falseA now that does not parse never throws. The permission refuses with
unusable-clock, names the rule that compared the clock, and carries no
missing, because no refetch repairs it. Fix the value the call passes.
rule, the acl rule that decided
allow, denied and unusable-clock always carry rule. An unevaluable
carries it only when a deny rule was unreadable: one deny rule refuses on its
own, so the engine names it. When the unreadable rules were allow rules, any one
of them could have granted, so no single rule is named.
The name comes from one of two places:
- A rule the author named reports that name. A policy written with
policy()names a rule with.id(name)after theallowordenythat wrote it. - A rule nobody named reports a derived id, the side it sits on and a hash of
its conditions, such as
allow-53eebf50.
Store a name the author wrote, and never store a derived one. A release of this
library that changes how a condition is represented changes every derived id, so
an audit row holding allow-53eebf50 stops matching its rule with nothing to
report it.
Treat rule as a log line and a support answer, such as “denied by
locked-question”. Never branch on it: two rules can grant the same thing, and
which one matched is not a fact the app should depend on.
When the answer is unevaluable
unevaluable says the engine never reached an answer, because a rule read an
object.* path the row did not carry. allowed is already false, so nothing
leaks, and missing names exactly what to fetch. A caller meets it with no row
at all, as a create button does, or with a projection, a row loaded with only
some of its columns.
Can this user do this?
works the refetch through.
Both sides’ unreadable paths arrive in one missing, so one query settles the
permission however many rules read the row. Every operator treats an absent
object.* path as unevaluable, including ne (not equal) and not-in, because
“not equal to locked” states no fact about a field nobody read. An absent
subject.* path fails its condition instead, because the server resolves the
subject whole and never projects it.
What to do with an acl unevaluable
| Where you meet it | What you do |
|---|---|
| a server, enforcing | Handle it the way you handle a refusal. Fetch the named paths and ask again, or return a 403. |
| a client, rendering | Draw the control as pending. Fetch missing and ask once more. A screen that draws it as permitted is broken. |
| authoring the document | An unevaluable no refetch repairs is a mistyped field name. A schema, the optional list of fields each object kind carries, turns that into an UnknownFieldError when the evaluator is built. |
| before the row is loaded | Every permission that reads the row answers unevaluable. access.readsObject(kind, action) tells that case apart from a call that lacked one field. |
Deciding before the row is loaded works the last row through.
Explaining one field of an acl field decision
canFields decides each field of a write separately, as
Which fields may they write? covers, and gives every field a
state and a FieldReason. A read-only input names its reason the way a refused
button does:
FieldReason | What it points at |
|---|---|
allow | nothing; the field is writable |
not-listed | the name, which the permission’s list of fields excludes |
targets-failed | the proposed value, which targets does not list |
transition-failed | the edge from the current value, which transitions does not list |
missing-field | the current value, which the row lacked; the same refetch as the row |
proposed-required | the proposed value, which a field with a config needs and did not get |
Which fields may they write? builds a policy that produces
allow, not-listed, transition-failed and missing-field, and
Field permissions is the reference.