Skip to Content
AuthorizationWhy was this refused?

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:

reasonCarriesWhat the screen has
allowrulenothing to explain
no-rule-matchednonea plain no, for this subject on this row
deniedrulea plain no, and which rule to name in a log
unknown-actionnonethis document carries no such key; fail closed
unevaluablemissinga refetch; the engine never decided
unusable-clockrulethe now this call passed is not an instant
stale-contractnonefetch 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 ; // -> false

A 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 the allow or deny that 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.

The repair loop for one unevaluable. Both sides' unreadable paths arrive in one missing array, so a single query fetches all of them and the second ask decides. An unevaluable that survives the refetch is a mistyped field name in the document.

What to do with an acl unevaluable

Where you meet itWhat you do
a server, enforcingHandle it the way you handle a refusal. Fetch the named paths and ask again, or return a 403.
a client, renderingDraw the control as pending. Fetch missing and ask once more. A screen that draws it as permitted is broken.
authoring the documentAn 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 loadedEvery 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:

FieldReasonWhat it points at
allownothing; the field is writable
not-listedthe name, which the permission’s list of fields excludes
targets-failedthe proposed value, which targets does not list
transition-failedthe edge from the current value, which transitions does not list
missing-fieldthe current value, which the row lacked; the same refetch as the row
proposed-requiredthe 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.

Last updated on