Skip to Content
AuthorizationWhy was this refused?

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Why was this refused?

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
not-traversablethe value, which a rule looks into and the engine cannot walk

Which fields may they write? builds a policy that produces allow, not-listed, transition-failed and missing-field, and Field permissions is the reference.

Debugging Decisions with explain

When a request is denied, a simple allowed: false doesn’t tell you why. A user might be blocked because they have the wrong role, or because a specific “deny” rule overrode their permissions. For developers building admin dashboards or permission checkers, knowing the difference is critical.

While access.can() returns a Decision that names only the deciding rule, access.explain() provides a full audit trail of the logic used to reach that conclusion.

How Explanation Works

The access.explain(subject, key, action, object, now) function accepts the same arguments as can(), but it returns an Explanation object.

The most significant difference between the two is their execution: can() stops the moment it finds a deciding rule to optimize performance. explain(), however, evaluates every condition of every rule. This means if a “suspended owner” is denied access, the explanation will show you both the deny rule that blocked them and the allow rule that would have granted access if they weren’t suspended.

Understanding the Explanation Object

The Explanation object breaks down the decision process into three primary areas:

1. The Final Result

  • decision: The Decision can() returns for the same call.
  • step: Indicates which precedence step (1 through 7) ultimately decided the outcome.

2. Rule Analysis The allow and deny lists enumerate every rule within the permission. Each rule is labeled with its status:

  • matched: The rule’s conditions were met.
  • not-matched: The rule’s conditions were not met.
  • unevaluable: The row lacked a path a condition reads.
  • unusable-clock: Occurs if the now argument passed to the call is not a valid instant.

3. Condition Details Within each rule, every condition is tracked individually:

  • holds: The condition is true.
  • fails: The condition is false.
  • unevaluable: The row lacked a path the condition reads.
  • missing: The object.* paths the row lacked, present only on unevaluable.
  • unusable-clock: The now argument was not an instant.
import { } from '@evanion/acl'; type = { : string }; type = { : string; : string[] }; const = <, { : }>() .('question', () => .('update', .('object.ownerId', 'subject.id')) .('owner') .('update', .('subject.roles', 'suspended')) .('suspended'), ) .(); const = { : 'customer-41', : ['suspended'] }; const = .(, 'question', 'update', { : 'customer-41', }); ..; // -> 'denied' .; // -> 1 .[0]?.; // -> 'matched' // The allow rule is reported too: the deny decided, and the owner condition held. .[0]?.[0]?.; // -> 'holds' // Without the row, the owner condition names the path it could not read. const = .( { : 'customer-41', : [] }, 'question', 'update', ); ..; // -> 'unevaluable' .[0]?.[0]?.; // -> 'unevaluable' .[0]?.[0]?.; // -> ['object.ownerId']

Implementation Details

Safe for Logging The explanation object is designed to be portable. It stores states and paths, but it does not store actual values from the subject or the data row. This ensures you can log explanations to a client or external monitoring tool without leaking sensitive data.

Edge Cases and Scope

  • Empty Explanations: If a document has exceeded its maxStale budget, or if the document does not carry the requested action, explain() returns the can decision with empty allow/deny lists and no step.
  • Binding: You can use authorize(subject).explain(key, action, object) or object(key).explain(subject, action, object) to pre-bind the subject or the key.
  • Field-Level Permissions: Because field-specific decisions carry their own reasons via canFields, the explain() function focuses exclusively on the primary action.
Last updated on