Skip to Content
AuthorizationAPI Reference

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.

API reference

@evanion/acl exports the evaluator, typed builder, document types and construction errors, one heading each below. Node 20 or newer, ESM only. The second entry point, @evanion/acl/testing, is headed from contractDrift down. The React provider and hooks are @evanion/react-acl.

hydratePolicy

function

The evaluator over a matrix document.

Full documentation

Sub, R and Keys are the compile-time surface; the engine underneath takes plain bags and string keys, so the body is written against the erased form and cast once at the end. Naming none of them is the foreign path, where the defaults make every signature the untyped one.

A holder that reports fetchedAt gets a freshness budget out of the document’s maxStale. Past fetchedAt + min(maxStale, options.maxStale), can, canMany, canFields and capabilities answer stale-contract for every key. readsObject keeps answering, because whether a permission names an object.* path is a fact about the document and no claim about the present.

hydratePolicy copies the document, freezes and validates the copy, and throws on an unknown key. All three type parameters default to the open forms JSON carries.

function < = , = , extends string = string>(: Matrix, ?: AccessOptions): <, , >;

What the tests state

21 behaviours

can evaluates a single decision
canMany returns a parallel decision array
a foreign matrix fails closed on an unknown permission
a typed (local) matrix throws on an unknown object kind
a typed (local) matrix throws on an unknown permission
exposes the frozen matrix and version
the matrix round-trips through JSON losslessly
canFields returns a field-level decision
canFields is not allowed when the action is denied
canFields is allowed when the action and every field are allowed
canFields decides a proposed key the object does not carry
canFields on an unknown action names the unknown action
capabilities returns every action-level decision
authorize binds the subject
refuses a document that states a member a permission does not have
refuses two permissions under one key, in either order
is absent from the document when neither source states one
comes from the document
comes from the options when the document states none
takes the option over the document, and the document carries it
is refused when the document states something that is neither

can evaluates a single decision

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; const = ({ : 'orders@7', : [ { : 'question.read', : 'question', : 'read', : [] }, ], }); // The server sends `access.matrix`; the client rebuilds from it. const = .( .(.), ) as typeof .; ().; // -> 'orders@7'

parseMatrix

function

Adopts a matrix document from foreign or emitted JSON.

Full documentation

The foreign path is untrusted configuration, so the resulting access object fails closed on unknown permissions (never throws).

How far field names are checked depends on the document. Without a schema, shape and namespace are all that are validated, and a mistyped object.* field decides unevaluable forever — the engine has nothing to check the name against. With a schema, every condition over a declared kind is checked for a name the kind declares and for an operator that fits the declared type. A kind the schema does not declare stays unchecked.

Sub, R and Keys are the same three parameters hydratePolicy takes, at the same defaults, so an untyped adoption reads as it always has and a caller that knows the document names them here: parseMatrix<ShopSubject, ShopObjects, 'listing.update'>(document).

Keys is what the consumer expects to find, stated the way Sub and R are already stated. It buys the consumer the same autocomplete the author gets: capabilities() comes back keyed by that union, 'listing.updte' is a compile error, and the action of every query is checked against the keys its object kind carries.

The document is still foreign JSON and nothing checks it against any of the three. A key the union names and the document omits decides unknown-action at runtime, the same refusal it decided before the union was written, and a key the document carries and the union omits is unreachable from this handle rather than unreachable from the engine. Producer and consumer in one build keep the union honest with KeysOf; two repositories keep it honest the way they already keep Sub and R honest, by reading the contract.

parseMatrix is hydratePolicy with closed: true, for a document another service published.

An unknown object kind or action answers unknown-action and nothing throws. Keys is the union the consumer expects, unchecked against the document. See Adopting a foreign matrix.

function < = , = , extends string = string>(: Matrix, ?: AccessOptions): <, , >;

What the tests state

13 behaviours

adopts a foreign matrix and fails closed
validates the matrix shape on adoption
exposes the adopted version
round-trips through JSON
a valid permission evaluates locally
refuses a rule whose when did not survive its serializer
refuses a key that grants an action it was not written for
refuses two permissions that would collide into one key
adopts an object kind namespaced by origin
refuses a deny rule whose condition would never read
refuses a value nested deeper than the clone walks
freezes a matrix that refers back to itself
evaluates against an interface-typed subject the caller names

adopts a foreign matrix and fails closed

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; const = ({ : [ { : 'question.read', : 'question', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }); .({ : 's1' }, 'question', 'delete').; // -> 'unknown-action'

validateAll

function

Lists every fault hydratePolicy would refuse the document for, without throwing. An empty array means construction succeeds.

Full documentation

The document is copied the way construction copies it before it is checked, so a value the copy cannot clone is reported first, as it is the first thing construction throws. The caller’s document is not changed or frozen. A permission the copy refused is not checked again.

When building a policy editor or a debugging tool, stopping at the first error isn’t enough. You need a complete map of every failure in a document to give the user a comprehensive list of what to fix.

While hydratePolicy and parseMatrix are designed to fail fast (stopping at the first error), validateAll is designed for exhaustive reporting. It scans the entire matrix document and returns every validation issue as a list, ensuring the process never throws an exception.

If validateAll returns an empty list, hydratePolicy can successfully construct a policy from the document using the same options.

To ensure accuracy, validateAll follows a specific sequence:

  1. Cloning: It creates a non-frozen copy of the document (matching the cloning logic used in hydratePolicy).
  2. Cloning Faults: If a value cannot be cloned (e.g., a function), that is recorded as an issue before any other, the schema first and then each permission in order. A permission that failed to clone is not checked again.
  3. Read Faults: If a node cannot be read (e.g., a getter that throws), an issue is recorded at that specific path.
  4. Structural Faults: If the document is not an object, or if the permissions property is not an array, no further checks run, as further validation is impossible. Only the clone faults, if any, come with it.
  5. Ordering: After the clone faults come the document’s own faults: the envelope, the schema’s shape, each permission in order, then the conditions checked against the schema. Faults in the options come last.

The options argument accepts version, fetchedAt and maxStale (the same parameters used by hydratePolicy). version replaces the document’s version before it is checked, and fetchedAt and maxStale let validateAll report freshness faults that would otherwise cause construction to fail. If an option cannot be read, the system reports it as a fault of that option. Because an options fault does not occur at a specific location within the document, its path is set to '' (the pointer to the document root).

Note that runtime faults, such as asking for an unknown object kind, are calls and not document faults, so they are not reported here.

function (: unknown, ?: ValidateOptions): ValidationIssue[];

What the tests state

17 behaviours

returns [] for a valid document
lists every fault, in document order
returns plain objects that survive a JSON round trip
omits permissionKey on an issue outside a permission
reports a fetchedAt with no budget as MissingFreshnessBudgetError
reports a local maxStale that is not a duration
reports an unreadable permission once when a schema is present
reports a value the copy cannot clone, then the rest of the document
keeps the clone refusals when a later permission cannot be read
reports a permission with a throwing getter once
names the permission in a clone refusal
lets the version option override the document version
reports an option that cannot be read as a fault of that option
reports a fetchedAt that cannot become an instant as a fault of fetchedAt
leaves the caller document unfrozen
does not throw on a document that cannot be read
is empty exactly when hydratePolicy constructs, over 2,000 junk documents

returns [] for a valid document

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; // A draft from a policy editor with two faults, in two permissions. const = { : [ { : 'question.read', : 'question', : 'read', : 'everyone', }, { : 'question.update', : 'question', : 'update', : [{ : [{ : 'subject.id', : 'equals', : 'a' }] }], }, ], } as never; // validateAll never throws, and it reports both faults in one call. const = (); .; // -> 2 [0]?.; // -> 'InvalidPermissionError' [0]?.; // -> '/permissions/0/visibility' [0]?.; // -> 'question.read' [1]?.; // -> 'InvalidConditionError' [1]?.; // -> '/permissions/1/rules/0/when/0' // A valid document returns an empty list. ({ : [] }); // -> []

ValidationIssue

interface

One fault of a matrix document, as a plain object that crosses a worker or the network.

A ValidationIssue is a plain object designed to be serializable. Because it survives JSON.stringify, it can be passed across workers or sent over a network without losing data.

Each issue contains:

  • code: The name of the error class.
  • message: The error text raised by the throwing path.
  • path: A JSON pointer indicating exactly where in the document the error occurred.
  • permissionKey: The key of the permission containing the error (if applicable), such as question.update.
interface ValidationIssue { : string; : string; : string; ?: string; }

ValidateOptions

interface

What a holder knows about the document that the document does not: when it last validated, and how long it will trust that.

ValidateOptions holds the version, fetchedAt and maxStale that change what construction checks. Providing them makes validateAll report the same freshness faults hydratePolicy would refuse, such as a fetchedAt that is not an instant or a local maxStale that is negative.

For example, if you provide a fetchedAt timestamp but the document lacks a maxStale value, validateAll will report a MissingFreshnessBudgetError.

interface ValidateOptions { ?: string | number; ?: ; ?: number; }

policy

function

The typed authoring path. Names the subject and the object kinds once, writes one block per kind, and flattens to the canonical matrix.

Full documentation

policy<Sub, Objects, Vocabs>() is a call returning a builder rather than policy<Sub, Objects, Vocabs>(config) taking the config, because TypeScript has no partial type-argument inference: naming a parameter in a single call forces every other one to be named too. The extra call is what lets the map parameters be explicit while each .for() names nothing and reads its object type out of Objects, its vocabulary out of Vocabs, and the actions it declared off the chain the block returns.

Vocabs is optional, and a kind it does not name may declare the four Action verbs. A domain verb is a compile error until the kind names it, which is what catches p.allow('updte') at the call that wrote it.

Flattening is deferred to matrix and build(), so validation runs once over every block.

policy returns the typed builder, one .for() per object kind. See Typed authoring.

Type parameterWhat it is
Subthe subject type every subject.* path is checked against
Objectseach object kind mapped to its row type
Vocabseach kind mapped to the actions it answers for
Actionthe vocabulary a kind Vocabs omits gets, carrying no wildcard member; docs/specs/2026-09-17-acl-wildcard-action.md records why
VocabularyOfwhat one kind’s block may declare from; string there opens the kind to any action
function <, , extends <<keyof , string>> = <never, never>>(?: ): <, , , <never, never>, never>;

What the tests state

32 behaviours

flattens the chain to a canonical matrix
a document with no version and no schema omits both keys
a version and a schema go into the document, not around it
a schema in the document binds the conditions the builder emitted
every permission key is its object and action joined
or produces one rule per branch and and produces one rule
a nested or distributes into one rule per branch
several conditions on one allow are one AND-ed rule
always flattens to one rule with an empty when
every op has a helper
a path operand becomes a path and a literal becomes a value
deny authoring produces denyRules
fields attaches to the action most recently declared
visibility attaches to the action most recently declared
an undeclared visibility emits no key
an internal marking is emitted as the document states it
visibility before any action is refused
fields before any action is refused
id names the rule the previous allow wrote
id names a deny rule, and a decision reports it
id refuses an allow that flattened to several rules
id before any action, and after a batch, is refused
two rules of one permission may not share an id
an allow rule and a deny rule may not share an id either
allowEach writes one ordinary permission per action
denyEach writes a deny rule per action
an action allowEach repeats is the one draft that action has
an empty action list declares nothing
fields and visibility are refused after a batch
an action named after a batch takes the mark
chained for calls accumulate every kind
a bound kind decides what the key form decides

flattens the chain to a canonical matrix

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; const = <{ : string }, { : { : string } }>() .('listing', () => .('read', .).(['*', '!status'])) .(); const = .( { : 's1' }, 'listing', 'read', { : 'draft' }, 'read', ); .['status']; // -> 'denied'

Policy

interface

The typed builder: for, matrix and build, and nothing else.

Full documentation

Objects is the kind -> row-type map the author named at policy(). Each .for() looks its object type up there, so a block names its kind once, as a value, and an unknown kind is a compile error against the map.

Vocabs is the kind -> action-union map beside it. It sits in its own parameter rather than inside Objects, because Objects is the shape a consumer restates by hand as the R of parseMatrix, and a wrapper carrying both halves would put the producer’s authoring concern into the type every consumer writes.

R accumulates the kinds that actually carry a block, one .for() at a time, and build() hands it to the evaluator. A kind the map declares and no block writes is therefore unknown to every query, which is what the document says too.

Keys accumulates the permission keys the same way. Each .for() reads the actions off what its block returns and joins them to the kind, so capabilities() answers under the keys the blocks wrote and nothing states an action twice.

Nothing here forwards a query. build() calls the shared constructor once and returns its result, which is the same Access a document arriving at runtime produces.

matrix stays alongside build() because a caller sometimes wants the document without an evaluator: applyDenyOverlay takes a Matrix, so an owner overlaying a typed-authored policy needs the document first.

Member or typeWhat it is
fortakes no type arguments, reading the object type out of Objects, the vocabulary out of Vocabs and Act off the chain its block returns
matrixflattens the blocks without building one
buildvalidates every block once and returns the Access
PermissionKeysthe keys one block contributes; an Act the compiler reads as string contributes string, widening the document’s whole key union
KeysOflifts those keys out of an Access type; a producer exports KeysOf<typeof access> for a consumer compiled against its source
ActionOfthe action half of the keys one object kind holds, checked against every query’s action parameter; open, empty and unmatched key unions answer string
interface <, , extends <<keyof , string>>, , extends string = string> { < extends keyof & string, extends string = string>(: , : (: <, [], never, <, >>) => <, [], , <, >> | void): <, , , & <, []>, | <, >>; readonly : <Matrix>; (?: AccessOptions): <, , >; }

PolicyOptions

type

The document-level facts the builder emits alongside the permissions.

Full documentation

They are document fields rather than construction options, so JSON.stringify(access.matrix) from the typed path emits everything a foreign producer emits. They sit on policy() rather than on a terminal method because neither is a per-kind fact: .for() exists to write one kind’s rules, and a version and a schema are known before the first block is written.

A schema is passed by hand. The builder holds the object types at the type level only, and a MatrixSchema is runtime JSON, so no schema can be derived from policy<Subject, Objects>(). A typed author already has the field-existence guarantee from TypeScript; passing a schema is what carries that guarantee across the wire to a consumer that adopts the emitted JSON with parseMatrix.

Both are document fields, so they land in the flattened JSON.

export type = <Matrix, 'version' | 'schema'>;

AccessOptions

interface

What a holder states about the document it is constructing over.

Full documentation

Every member describes the holder rather than the document: which version the construction site is really running, whether an unknown key throws or refuses, and when this holder last confirmed the document was current. The document states what its producer shipped, and these state what the consumer is doing with it.

OptionWhat it is
versionoverrides the document’s own version
closedfails closed on an unknown key
fetchedAtwhen this holder last validated; a holder that supplies it obliges the document to state maxStale
maxStalea local ceiling, min-ed with the document’s
interface AccessOptions { ?: string | number; ?: boolean; ?: ; ?: number; }

Access

interface

The evaluator over one frozen document.

Full documentation

Sub is the subject the matrix was written against, R is the key -> object-type map, and Keys is the permission keys the document declares. All three default to the open forms a JSON document carries, and at the defaults keyof R & string is string, Partial<R[K]> is a bag of unknowns and capabilities() answers under any key, so a foreign document accepts any key, any action and any object. A typed author names them through the builder, and every query checks its key, its action and its object against them.

Access is the evaluator over one frozen document.

MemberWhat it answers
canone decision for one action on one row
canManyone decision per row, settling one clock for the whole list
canEachone decision per subject, settling one clock for the list
canFieldsthe field-level decision on the read or write axis
explainthe decision with every condition behind it
capabilitiesevery permission key, decided against one subject
authorizean Authorized handle carrying the subject
objecta BoundKind handle carrying the object kind
readsObjectwhether the permission needs the row before it can decide
matrix, version, schemathe frozen document, and the two envelope fields off it

Every object parameter takes a partial: a projection is the unevaluable case. An absent subject.* path is a definite miss.

interface < = , = , extends string = string> { readonly : <Matrix>; readonly : string | number | undefined; readonly : MatrixSchema | undefined; < extends keyof & string>(: , : , : <, >, ?: <[]>, ?: ): Decision; < extends keyof & string>(: , : , : <, >, : readonly <[]>[], ?: ): Decision[]; < extends keyof & string>(: readonly [], : , : <, >, ?: <[]>, ?: ): Decision[]; < extends keyof & string>(: , : , : <, >, : <[]>, : 'read' | 'write', ?: <[]>, ?: ): FieldDecision; < extends keyof & string>(: , : , : <, >, ?: <[]>, ?: ): Explanation; (: , ?: ): <, Decision>; (: , ?: { ?: ; }): <, >; < extends keyof & string>(: ): <, [], <, >>; < extends keyof & string>(: , : <, >): boolean; }
import { } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; /** Rebuild when the served document moved; otherwise keep the one in hand. */ function (: , : Matrix): { // `parseMatrix`, so a key the new version dropped refuses and never throws. return . === . ? : (); } const = { : 'question.read', : 'question', : 'read' }; const : Matrix = { : 'orders@7', : [ { ..., : [{ : [] }] }, { : 'question.update', : 'question', : 'update', : [ { : [{ : 'object.askedBy', : 'eq', : 'subject.id' }] }, ], }, ], }; // `orders` withdrew `question.update` and published a new version. const : Matrix = { : 'orders@8', : [{ ..., : [{ : [] }] }], }; const = { : 'jo' }; const = { : 'jo' }; let = (); .(, 'question', 'update', ).; // -> true = (, ); .; // -> 'orders@8' .(, 'question', 'update', ).; // -> 'unknown-action'

Authorized

interface

One subject’s decisions, bound to that subject and one clock instant.

Full documentation

R is the key -> object-type map the Access it came from carries, so the binding survives the member that hands it back. Keys is the permission-key union from the same place, and capabilities() answers under those keys. The subject is absent from both, because authorize already took it and checked it there.

Decisions only. It carries no matrix, version or schema, and a caller that needs the document alongside the handle holds the Access it came from — which every such caller already imports, since that is where authorize comes from. The matrix is one frozen document evaluated against many subjects, and hanging it off a subject-bound handle would offer it as though it were this subject’s matrix, which is the per-subject snapshot this library exists to avoid shipping.

readsObject is absent for the same reason in reverse: it is a fact about the document, not about this subject.

Authorized is what authorize(subject) returns.

Authorized omits matrix, version, schema and readsObject, which are not facts about one subject.

interface < = , extends string = string> { < extends keyof & string>(: , : <, >, ?: <[]>): Decision; < extends keyof & string>(: , : <, >, : readonly <[]>[]): Decision[]; < extends keyof & string>(: , : <, >, : <[]>, : 'read' | 'write', ?: <[]>): FieldDecision; (): <, Decision>; < extends keyof & string>(: , : <, >, ?: <[]>): Explanation; }

BoundKind

interface

One object kind bound to a handle, so the key is named once.

Full documentation

Every object parameter takes Partial<Obj>. A caller holding a projection — a list row carrying { id, ownerId } — is the case unevaluable and missing answer, and the engine reads every object field through an own-key guard. The subject stays complete: an absent subject.* path fails its condition and names nothing to fetch. An allow rule that reads it does not grant, and a deny rule that reads it does not refuse, so a projected subject can pass a deny its full record would have matched.

BoundKind is what object(key) returns.

authorize binds the other axis; the two compose.

interface <, , extends string = string> { (: , : , ?: <>, ?: ): Decision; (: , : , : readonly <>[], ?: ): Decision[]; (: readonly [], : , ?: <>, ?: ): Decision[]; (: , : , : <>, : 'read' | 'write', ?: <>, ?: ): FieldDecision; (: , : , ?: <>, ?: ): Explanation; (: ): boolean; }

KeysOf

type

The permission keys an Access carries, lifted back out of its type.

Full documentation

A producer that authored its policy with the builder publishes the union its blocks accumulated: export type ShopPermissions = KeysOf<typeof access>. A consumer in the same build imports that and hands it to parseMatrix, so the two sides agree by construction and a key the producer renames breaks the consumer’s compile.

The boundary: this reads a TypeScript type, so it reaches only a consumer that compiles against the producer’s source. A consumer in another repository, which is the topology the published-contract pages teach, holds the document and no types at all, and states the union it expects by hand as the third argument of parseMatrix. Both are assertions about a document neither side validated at the type level; one is kept honest by the compiler and the other by the reader.

export type <> = extends <never, never, infer > ? : never;

ActionOf

type

The action half of the keys Keys holds for one object kind.

Full documentation

A document whose keys are open answers string, which is the action parameter every query took before a builder declared a vocabulary. A document that declares 'comment.update' | 'comment.read' answers 'update' | 'read' for 'comment', so the action a query names is checked the same way its key is.

An empty Keys is a document that declares nothing, which a policy with no blocks produces, and it answers string for the same reason an open one does.

A kind the union holds no key for answers string as well. The builder cannot reach that branch, since every block contributes a kind and its keys together, and a caller naming R and Keys by hand over an adopted document can: parseMatrix<Sub, { device: Device }, 'telemetry.read'> states a row type for a kind it named no key for. string leaves that kind answerable and never would make every query on it uncallable, which is a refusal the document never asked for.

export type < extends string, extends string> = [ ] extends [ never ] ? string : string extends ? string : [ <, `${}.${string}`> ] extends [ never ] ? string : extends `${}.${infer }` ? : never;

CRUD_ACTIONS

constant

The action vocabulary a block gets when it declares none of its own.

Full documentation

Four verbs, domain-neutral, and a string-literal union rather than a TypeScript enum: an enum is nominal and a runtime artifact, and the actions in a matrix are plain strings that survive JSON.parse, so a member of an enum would compare unequal to the string the document carries.

A domain that needs more names them per kind, in the vocabulary map policy() takes: { listing: Action | 'publish' }. A kind that answers for actions no vocabulary can list opens itself with string.

No wildcard member sits here. The engine resolves ${kind}.${action} as an exact lookup, so an 'all' in this union would mint a permission that a query for 'update' never consults. Wildcard semantics are specified separately, and this union stays four verbs until that spec lands.

CRUD_ACTIONS declares the four default verbs, and Action reads off it. Pass it to allowEach.

export declare const : readonly [ "create", "read", "update", "delete" ];

pickAllowedFields

function

The part of a proposed write the decision allows: every leaf whose state is allowed, and nothing else. This is the value to write.

Full documentation

A key the decision does not carry is not written. Only allowed passes, so denied and unevaluable are both withheld, and so is a key that reached the decision under a different proposed object.

Where the decision carries paths below a key, the key is rebuilt as a new object or array holding the allowed part. An object appears only when one of its children does. An array keeps every element and its index, so an element whose children were all withheld is {}. A value that cannot be walked where the decision descends is withheld whole. A subtree the decision does not descend into is copied by reference.

Throws ActionNotAllowedError when the action is refused, because no field of a refused action is writable and an empty object would read as a lawful write of nothing. A field the action allows but the rules deny is a partial write, which is what filtering is for, so a false top-level allowed from the field maps alone returns the allowed subset.

pickAllowedFields returns the subset of a proposed write the decision marked allowed, and throws ActionNotAllowedError on a refused action. See the write path.

function < extends <string, unknown>>(: FieldDecision, : ): <>;

What the tests state

16 behaviours

keeps the allowed keys and drops every other state
drops a key the decision never decided
narrows a real decision to the writable half
throws when the action is refused
returns the allowed subset when only a field is denied
returns only keys the decision marks allowed, over generated input
rebuilds the parent without the excluded child
keeps every element of an array and its indexes
an element whose children are all withheld becomes an empty object
builds no empty parent object
copies a subtree no rule descends into by reference
never calls a getter on the value it rebuilds
withholds a value it cannot descend into where the decision descends
withholds an array holding a non-object element whole
builds every level on Object.prototype without an own __proto__
terminates on a self-referencing value

keeps the allowed keys and drops every other state

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl'; const = < { : string }, { : { : string; : string; : string } } >() .('question', () => .('update', .('object.askedBy', 'subject.id')) .(['*', '!status']), ) .(); const = { : 'customer-41', : 'In stock?', : 'open' }; const = { : 'Wingspan in stock?', : 'locked' }; const = .( { : 'customer-41' }, 'question', 'update', , 'write', , ); ..; // -> true .['status']; // -> 'denied' .((, )); // -> '{"body":"Wingspan in stock?"}'

serialize

function

The document an Access holds, whole.

Full documentation

Every permission, marked as it stands, so a round trip through parseMatrix comes back the same document. This is the internal matrix and not a contract: it carries every rule, every condition and every literal a condition compares against. Hand it to a party that may see all of them. The contract: the permissions marked public, byte for byte.

A kept permission is copied whole with its marking dropped, and nothing else about it is edited. Removing a permission changes nothing for the rest, because a decision reads one permission and the settled context, which is what lets a reduced document decide as the full one for every key it kept.

full hands back access.matrix. reduced keeps each permission marked visibility: 'public' whole, strips the marking, and adds the schema entries for the kinds they name. See Many services.

function <, , extends string = string>(: <, , >, : 'full'): Matrix;

What the tests state

17 behaviours

hands back the authored document in full, marking included
keeps the public permissions and drops every other one
strips the marking from every permission it keeps
keeps everything else on a kept permission
carries the envelope version and freshness budget
keeps the schema entry of a published kind whole
keeps the subject schema, which every published rule is checked against
omits the schema when no published permission names a kind
reads an unmarked permission as internal
publishes nothing out of a contract, so a consumer cannot re-publish
round-trips a full serialization through parseMatrix unchanged
refuses a vetoable key the reduction would drop
names the key and both remedies in that refusal
refuses a vetoable key the document does not define at all
accepts a vetoable key that is public
publishes a builder-authored document under its own keys
gives the overlay author a contract its own check runs against

hands back the authored document in full, marking included

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , , } from '@evanion/acl'; type = { : string; : string[]; : string }; type = { : string }; type = { : string }; const = < , { 'orders:order': ; 'orders:ledger': }, { 'orders:order': 'refund'; 'orders:ledger': 'reconcile' } >({ : 'orders@7' }) .('orders:order', () => .('refund', .('subject.roles', 'bookseller')) .('refund', .('subject.tier', 'probation')) .('public'), ) .('orders:ledger', () => .('reconcile', .('subject.roles', 'accountant')), ) .(); const = (, 'reduced'); ..(() => .); // -> ['orders:order.refund'] const = <, { 'orders:order': }>(); const = { : 'u1', : ['bookseller'], : 'permanent' }; .(, 'orders:order', 'refund').; // -> true

SerializeMode

type

Which of the two documents an Access holds is wanted.

export type = 'full' | 'reduced';

SerializeOptions

interface

What a reduced serialization needs beyond the document itself.

Full documentation

Keys is the permission-key union the Access being serialized carries, so a document authored with the builder checks its own list and an adopted one, whose keys are string, takes any key it likes.

A listed key the reduction would drop raises UnpublishedVetoableError. The compiler refuses a key the document never held, where the builder authored the policy.

interface < extends string = string> { readonly ?: readonly []; }

federatedPolicies

function

Composes one Access per origin into a set that routes by permission key.

Full documentation

Construction refuses a key two origins claim, with OriginCollisionError naming the key and both origins. The routing table and the check are one pass: the map from canonical key to member is what the collision is found in and what can dispatches on.

Nothing is merged and nothing is evaluated here. Each member keeps its own document, its own version, its own schema, and its own answer to an unknown key, so a set may mix parseMatrix members with an open-mode one and each behaves as its provenance calls for.

A deny overlay applies to a document, so it applies per origin before the members are built. This function takes no overlay and no vetoable list.

federatedPolicies composes one Access per origin and merges no document. See Many services.

function (: <<string, <, , never>>>): FederatedAccess;

What the tests state

16 behaviours

refuses two origins claiming one key, naming both
constructs over disjoint key sets
reads permission keys, so a deny rule collides with nothing
answers with the decision the holding origin reaches
forwards an unevaluable decision with its missing paths
answers a key no origin holds without reaching a member
constructs over an empty record and holds no key
equals the fold over the same origins at one instant
settles one instant, so a boundary falls on one side for every origin
reads the wall clock once for an omitted instant
hands back the reference the caller passed
answers nothing for an origin name off the prototype
takes a member the typed builder produced
mixes provenances and each member keeps its unknown-key behaviour
narrows the federated answer when the member was overlaid
refuses another origin key at the overlay call

refuses two origins claiming one key, naming both

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl'; import type { Decision, Matrix } from '@evanion/acl'; const : Matrix = { : [ { : 'storefront:listing.read', : 'storefront:listing', : 'read', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; const : Matrix = { : [ { : 'stock:listing.read', : 'stock:listing', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }; // One Access per origin. Each document belongs to the service that emitted it, // so each arrives through `parseMatrix` and fails closed. Two origins claiming // one key throw `OriginCollisionError` on this line, naming both. const = ({ : (), : (), }); const = { : 'u1', : ['bookseller'] }; // One advisory view for a UI, over one instant every origin reads. const : <string, Decision> = .(); .().(); // -> ['stock:listing.read', 'storefront:listing.read'] ['stock:listing.read']?.; // -> true ['storefront:listing.read']?.; // -> false // The origin holding the key answers it. A key nobody holds reaches no origin. .(, 'stock:listing', 'read').; // -> true .(, 'shipping:parcel', 'read').; // -> 'unknown-action' // A request touching both origins asks both, and the caller writes the AND. const = .(, 'storefront:listing', 'read').; const = .(, 'stock:listing', 'read').; && ; // -> false // The member itself, with `canMany`, `canFields`, `readsObject` and `authorize` // on it, plus the document that origin published. .('storefront')?...; // -> 1

FederatedAccess

interface

A set of policies held side by side, one per origin.

Full documentation

Three members, and no matrix, version or schema: each of those three is a fact about one document, and a set of N documents has N of them. A caller that wants the document reaches the member through get, which hands back the Access that was passed in, with everything on it.

canMany, canEach, canFields, explain, readsObject and authorize are absent for the same reason in reverse. Each one answers about a single permission or a single object kind, so the caller that asks already knows which origin holds it, and get(origin) is the call that reaches it. Forwarding them would rebuild the facade over an evaluator that already carries them.

can routes to the origin holding `${object}.${action}`; a key no member holds answers unknown-action. capabilities settles one instant for all.

interface FederatedAccess { (: , : string, : string, ?: <string, unknown>, ?: ): Decision; (: , ?: ): <string, Decision>; (: string): | undefined; }

applyDenyOverlay

function

Appends each overlay key’s deny rules to that permission and returns a new matrix. Pure Matrix -> Matrix, so it composes by ordinary function composition and the input is left untouched.

Full documentation

It can only subtract. sideOutcome in evaluate.ts is monotone in its rule array — appending deny rules moves the deny side along fails -> unevaluable -> unusable-clock -> matched and never back — and decideResolved reaches allowed: true only where the deny side is fails. So a decision the overlaid matrix allows was allowed by the authored one, for every subject, object and instant.

Three refusals, all at apply time, each naming the key:

  • a key the target does not define (UnknownPermissionError);
  • a key the target does not list as vetoable (UnvetoablePermissionError);
  • a condition that does not fit the target’s schema for that key’s object kind (UnknownFieldError, FieldTypeMismatchError).

The same structural gate every matrix passes runs over the contribution too, so a malformed rule is an InvalidRuleError here rather than at the owner’s next deploy.

The obligation behind the third refusal is scoped: the target declares a schema for the object kinds of its vetoable keys and owes nothing for the rest, so a service that opens no extension point owes no schema at all.

The intended second caller is the authoring party. A compliance team runs this against the owner’s published contract in its own CI and finds its own mistake before the owner’s deploy does. Nothing here reads private state or needs the full authored matrix: a published subset carrying the vetoable keys and their schema entries is enough.

version is carried through unchanged. A veto changes what the document decides, so the party that publishes the result composes the version that says so; this function knows no name for the overlay and invents none.

applyDenyOverlay appends another team’s deny rules to the keys this document opens for veto. See Many services.

function (: Matrix, : , : DenyOverlayOptions): Matrix;

What the tests state

28 behaviours

refuses a key the target does not define
refuses a vetoable key the target does not define
refuses a key the target does not open for veto
refuses every key when the target opens nothing
refuses a condition naming a field the target does not declare
refuses a condition whose operator does not fit the declared type
checks a contribution against the kind the key names, not another
refuses a vetoable key whose object kind is not declared
refuses a vetoable key when the target carries no schema at all
owes nothing for a kind no vetoable key names
owes no schema when it opens nothing
refuses a rule with no when
a subject who could, now cannot
leaves a subject the overlay does not name alone
leaves the permissions it does not name alone
appends rather than replaces the target own deny rules
does not touch the matrix it was handed
carries the version through unchanged
reports unevaluable rather than granting
turns an object-independent permission into one that reads the object
round-trips through JSON unchanged
constructs, and the access behaves
needs neither the full matrix nor any private state
an overlay that matches nothing changes no decision
an overlay only ever subtracts
an overlaid matrix is still a document
narrows real decisions, so the monotonicity property is not vacuous
opens real extension points, so the inert-overlay property is not vacuous

refuses a key the target does not define

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; // The owner's document. `schema.objects.refund` is what opening `refund.issue` // to a veto obliges it to declare. const : Matrix = { : 'refunds@7', : { : { : { : { : 'string', : 'number' } } }, }, : [ { : 'refund.issue', : 'refund', : 'issue', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; // What compliance publishes. A contribution is a `Rule[]`, so it can state a // deny and nothing else -- no allow, no field rule. const : = { 'refund.issue': [ { : 'sanctions-hold', : [{ : 'object.region', : 'eq', : 'XX' }], }, ], }; const = ( (, , { : ['refund.issue'] }), { : 'refunds@7+veto@41' }, ); const = { : 'u1', : ['owner'] }; .(, 'refund', 'issue', { : 'SE', : 10 }).; // -> true .(, 'refund', 'issue', { : 'XX', : 10 }).; // -> 'denied'

DenyOverlay

type

Deny rules contributed per permission key.

Full documentation

A contribution is a readonly Rule[], and that is the whole soundness argument. Rule is { id?, when? }: fields lives on Permission, and an allow rule is a member of a different array. So a contribution cannot express an allow, a dependency or a field rule. There is no prohibition to enforce because the shape states none of them.

A Rule spells no allow and no field rule, so an overlay only subtracts.

export type = <<string, readonly Rule[]>>;
import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; // The owner's document. `schema.objects.refund` is what opening `refund.issue` // to a veto obliges it to declare. const : Matrix = { : 'refunds@7', : { : { : { : { : 'string', : 'number' } } }, }, : [ { : 'refund.issue', : 'refund', : 'issue', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; // What compliance publishes. A contribution is a `Rule[]`, so it can state a // deny and nothing else -- no allow, no field rule. const : = { 'refund.issue': [ { : 'sanctions-hold', : [{ : 'object.region', : 'eq', : 'XX' }], }, ], }; const = ( (, , { : ['refund.issue'] }), { : 'refunds@7+veto@41' }, ); const = { : 'u1', : ['owner'] }; .(, 'refund', 'issue', { : 'SE', : 10 }).; // -> true .(, 'refund', 'issue', { : 'XX', : 10 }).; // -> 'denied'

DenyOverlayOptions

interface

What the target opens to an overlay.

A key listed in vetoable obliges the matrix to declare that key’s object kind in schema.objects.

interface DenyOverlayOptions { readonly : readonly string[]; }

diffMatrix

function

What changed between two matrix documents, reported as access rather than as text.

Full documentation

A line-by-line document diff answers the wrong question. A reviewer wants a sentence of the form “this deploy lets any subject whose roles contains operator reprice a listing”, and an added allow branch is that sentence: the branch’s conditions are what an author wrote, so the report renders them and infers nothing.

Soundness rests on sideOutcome being monotone in its rule array. A side that matched goes on matching whatever is appended, so an added allow branch can only widen and an added deny branch can only narrow. applyDenyOverlay derives its subtract-only property from the same argument and serialize derives the contract’s equality property from it.

Four things the report cannot derive, each of which it states rather than guesses at:

  • How many people a grant describes. The report names a condition and never a headcount, so findings cannot be ranked by blast radius and the reviewer supplies that judgement.
  • Whether a grant overlaps one already in force. The report names the added branch whole, so a reviewer may read a grant that was already granted.
  • Whether an added branch grants anything at all. A branch ANDing subject.role eq 'admin' with subject.role eq 'operator' is unsatisfiable and is reported as a grant.
  • What an edit did. A rule that kept its id and changed its conditions, and a permission whose two sides both moved, are undetermined with both versions attached.

Every one of those fails toward reporting a grant, which is the direction a reviewer can check.

diffMatrix compares two matrix documents and reports access rather than text. See What did editing my rules change about access?.

The evaluator ORs the rules on a side, so a side that matched goes on matching whatever is appended. An added allow branch can therefore only widen and an added deny branch only narrow. A finding is a direction and an upper bound, never an exact set.

function (: Matrix, : Matrix): MatrixDiff;

What the tests state

32 behaviours

reports nothing for two documents stating one policy
reports nothing when the rules are reordered
reports nothing when a condition arrives with its keys in another order
carries both versions, so a report names what it compared
reports an added allow branch as granted, naming the branch
splits the branch into who, which rows and when
reports a removed deny branch as granted
reports an added permission as granted once per allow branch
reports an added deny branch as withdrawn
reports a removed allow branch as withdrawn
gives a removed permission both holders a sentence
names the object kind when the last permission of a kind goes
reports a rule that kept its id and changed its conditions as undetermined
refuses a direction when both sides of one permission moved
decides two literal name lists by set comparison
nested field paths · grants the parent when a child list becomes the parent
nested field paths · withdraws the parent when the parent becomes a child list
nested field paths · grants the path a dropped exclusion released
nested field paths · withdraws the path a new exclusion removed
nested field paths · grants a key whose value an entry below it stopped the walk from keeping
nested field paths · withdraws a key once an entry below it makes the walk descend
nested field paths · grants both the key and the path a dropped exclusion released
nested field paths · reports a change outside the name list beside the list findings
nested field paths · compares escaped segments, not string prefixes
nested field paths · keeps a list with a baseline undetermined
refuses a comparison once a list carries a baseline or a subtraction
reports a permission that started reading the object
is the finding an admin who supplied no object needs
reports a changed path even when the permission read the object before
holds: an appended allow rule never removes an allow
holds: an appended deny rule never adds an allow
reports the granted branch a new subject is actually granted by

reports nothing for two documents stating one policy

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const = { : 'staff', : [{ : 'subject.roles', : 'contains', : 'bookseller' }], } as ; const = { : 'supervisor', : [{ : 'subject.roles', : 'contains', : 'supervisor' }], } as ; const = (: string, : readonly unknown[]): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', , }, ], }) as Matrix; const = ({ : ('orders@7', []), : ('orders@8', [, ]), : , }); .; // -> [] .?.; // -> false .?..(() => .); // -> ['granted']

MatrixDiff

interface

What diffMatrix answers.

unchanged is true when the two documents state one policy. Rule order and condition key order are not policy, so a reordered document reports nothing.

interface MatrixDiff { readonly : readonly []; readonly : boolean; readonly : { readonly ?: string | number; readonly ?: string | number; }; }

DiffFinding

type

One thing a diff found, discriminated on kind.

Full documentation

Every member carries key, the permission it is about. The three access members say which direction the change moved, and reads-object says the access did not move while what a decision needs in order to answer did.

Discriminated on kind. Every member carries key, the permission key the finding is about.

export type = GrantedFinding | WithdrawnFinding | UndeterminedFinding | ReadsObjectFinding;

findingsOf

function

The findings of one kind, narrowed.

Full documentation

findings is one list because a report is read in order and a permission’s findings belong together. A caller that wants a section of a report wants one kind, and reaching it means a filter plus a narrowing that every call site would write the same way.

for (const grant of findingsOf(diff, 'granted')) { console.log(grant.key, grant.groups.subject); }

The order the report was built in is kept, so a caller that prints two kinds separately still prints each permission’s findings in the order diffMatrix reached them.

findingsOf returns the findings of one kind, narrowed to that member.

MatrixDiff.findings is one list because a report is read in order. A caller rendering a section of that report wants one kind, and reaching it otherwise means a filter and a narrowing written the same way at every call site. The order diffMatrix built the report in is kept.

function < extends ['kind']>(: MatrixDiff, : ): readonly <, { : ; }>[];

What the tests state

4 behaviours

returns only the kind asked for
narrows the member, so a caller reads the kind's own fields
answers empty for a kind the report has none of
keeps the order the report was built in

returns only the kind asked for

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; const : = { : 'subject.roles', : 'contains', : 'owner', }; const : = { : 'subject.roles', : 'contains', : 'bookseller', }; const : = { : 'subject.id', : 'eq', : 'object.sellerId', }; const = { : 'listing.reprice', : 'listing', : 'reprice', } as ; const : Matrix = { : 'listings@7', : [{ ..., : [{ : [] }] }], }; // One branch appended: a bookseller may reprice a listing they sell. const : Matrix = { : 'listings@8', : [ { ..., : [{ : [] }, { : [, ] }], }, ], }; const = (, ); .; // -> false .; // -> { before: 'listings@7', after: 'listings@8' } const = (, 'granted'); [0]?.; // -> 'listing.reprice' [0]?.; // -> 'allow-branch-added' [0]?..; // -> [isBookseller] [0]?..; // -> [ownListing] [0]?..; // -> []

GrantedFinding

interface

Access this document grants that the previous one did not.

Full documentation

when is the added branch whole, and groups is it split for rendering. The branch is an over-approximation in the widening direction: some of what it describes may already have been granted by another branch, and the diff does not subtract, because the exactly-new set means negating a disjunctive normal form and the result is unreadable even when it is small.

when is the added branch whole and groups is it split for rendering. fields carries the field names added, where cause is field-list-widened.

interface GrantedFinding { readonly : 'granted'; readonly : string; readonly : ; readonly ?: string; readonly : readonly []; readonly : ConditionGroups; readonly ?: readonly string[]; }

GrantedCause

type

Why a permission’s access widened.

export type = 'permission-added' | 'allow-branch-added' | 'deny-branch-removed' | 'field-list-widened';

WithdrawnFinding

interface

Access the previous document granted that this one does not.

Full documentation

holders carries the two sentences a removed permission needs, because a parseMatrix holder and a hydratePolicy holder meet one edit as two different events.

holders accompanies permission-removed and carries a sentence each for a parseMatrix holder and a hydratePolicy holder, because one removal answers unknown-action for the first and throws for the second.

interface WithdrawnFinding { readonly : 'withdrawn'; readonly : string; readonly : ; readonly ?: string; readonly : readonly []; readonly : ConditionGroups; readonly ?: { readonly : string; readonly : string; }; readonly ?: readonly string[]; }

WithdrawnCause

type

Why a permission’s access narrowed.

export type = 'permission-removed' | 'allow-branch-removed' | 'deny-branch-added' | 'field-list-narrowed';

UndeterminedFinding

interface

A change the diff refuses to classify, with both versions shown.

Full documentation

A report that quietly called an edited condition unchanged would be worse than no report, because a reviewer would stop reading it. So an edit in place is reported and classified as neither.

A change diffMatrix declines to call a widening or a narrowing, with both versions shown.

interface UndeterminedFinding { readonly : 'undetermined'; readonly : string; readonly : ; readonly ?: string; readonly : unknown; readonly : unknown; }

UndeterminedCause

type

Why a change could not be classified as a widening or a narrowing.

  • rule-edited: a rule maintains its author-supplied id but has had its conditions modified.
  • both-sides-changed: a permission’s allow and deny sides have both moved.
  • fields-changed: the field rules changed in a way diffMatrix cannot call a widening or a narrowing. That is any change with a * in either name list, a name list added or removed, a list with a malformed entry, any change outside the name list, and two *-free lists that differ only in how they are written. A change outside the name list also appears beside the list findings.

When comparing two field lists without *, diffMatrix compares paths. An entry allows the named path and everything below it, while a ! entry removes the named path and everything below it. A key with an entry below it is walked into, so a value there that cannot be walked, such as a primitive, a date or a mixed array, is denied. A key with nothing below it keeps any value. diffMatrix reports both kinds of change.

Field findings describe name-based permissions only; they do not guarantee that a write will go through. A write refused because a stored sibling is denied depends on the data, so the diff does not report it.

The fields property of a finding holds the shortest paths that one list allows and the other does not, with . and \ in a key name escaped as \. and \\. Moving from address.city to address is a widening that reports address. Dropping !address.street from a list that allows address is also a widening, and it reports address and address.street.

export type = 'rule-edited' | 'both-sides-changed' | 'fields-changed';

ReadsObjectFinding

interface

A change in which object.* paths a decision reads.

Full documentation

The edit behind this usually appears in the granted or the withdrawn list as well, and reading only that list gets the blast radius wrong. Adding a deny rule over object.locked is reported as a narrowing, which a reviewer reads as “locked rows lose the access”. Every caller that decides without loading the row is affected too, whatever the row holds: the permission answered allow and now answers unevaluable naming the path it needs.

A gateway deciding before it reads the row goes from passing the request to refusing it, and a UI holds a decision it must refetch to settle.

A change in which object.* paths a decision reads. No access moved, and a caller that decides in front of the fetch gets unevaluable where it got allow.

interface ReadsObjectFinding { readonly : 'reads-object'; readonly : string; readonly : boolean; readonly : boolean; readonly : { readonly : readonly string[]; readonly : readonly string[]; }; }

ConditionGroups

interface

A branch’s conditions split by what a reviewer reads them as.

Full documentation

subject describes who gained or lost the access, object which rows, and window when. A condition comparing a subject path against an object path lands in object, because it restricts which rows rather than which people.

subject is who gained or lost the access, object is which rows, and window is the now conditions. A condition comparing a subject path against an object path lands in object.

interface ConditionGroups { readonly : readonly []; readonly : readonly []; readonly : readonly []; }

Matrix

interface

The canonical matrix document: an envelope over a flat list of permissions.

Full documentation

There is no bare-array form. version and schema belong to the document, so a foreign producer emitting JSON states both, and one access.matrix crosses an SSR boundary without a wrapper assembled at the call site.

version is what the fetch-and-revalidate contract compares with !==, so a string carries a content digest or a composite (orders@7+veto@41) where a number cannot.

Matrix is a JSON envelope over a flat list of permissions. See The matrix: your rules as JSON.

TypeWhat it holds
Permissionone `${object}.${action}` key, its allow rules, its deny rules and its field rules
Rulean id and a list of AND-ed conditions
Conditionone comparison; The matrix: your rules as JSON has the operator table
FieldRulesthe fields list, then one key per field carrying a write-axis config, so a field named fields cannot be configured
FieldConfigtargets allow-lists the proposed value and transitions is a state machine over the current one; see Fields
MatrixSchemaoptional for a producer, binding where present, per kind: a kind objects omits is unchecked
ObjectSchemaa kind’s field types, and the relations naming the kinds it points at, one hop; no condition compares a relation
FieldTypea flat string any language emits by reflection, where ? marks a field that may be absent
Instantan ISO 8601 string, epoch milliseconds, or a Date; the string and number forms survive JSON
interface Matrix { readonly ?: string | number; readonly ?: MatrixSchema; readonly ?: number; readonly : readonly Permission[]; }
import { } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : { : { : { : 'string' } }, : { : { : { : 'string', : 'string' } }, }, }, : [ { : 'question.update', : 'question', : 'update', // `status` is a string, and contains tests an array. : [ { : [{ : 'object.status', : 'contains', : 'draft' }] }, ], }, ], }; let = ''; try { (); } catch () { = ( as Error).; } ; // -> 'FieldTypeMismatchError'

Permission

interface

One permission: allows and/or denies, plus optional field rules.

interface Permission { : string; : ; : string; ?: readonly Rule[]; ?: readonly Rule[]; ?: FieldRules; ?: 'public' | 'internal'; }

Rule

interface

One activation rule for a permission: an AND-ed set of conditions.

interface Rule { ?: string; ?: readonly []; }

Condition

type

A condition over the namespaced context.

Full documentation

now is a time window. Any other field is subject.* or object.*, compared against a literal value or — for eq/ne — against another path.

export type = { : 'now'; : 'before' | 'after'; : ; } | { : string; : 'eq' | 'ne' | 'in' | 'not-in' | 'contains'; ?: string; ?: unknown; };
import { , } from '@evanion/acl'; import type { , Matrix } from '@evanion/acl'; const : = { : 'subject.roles', : 'contains', : 'owner', }; const : = { : 'subject.roles', : 'contains', : 'bookseller', }; const : = { : 'subject.id', : 'eq', : 'object.sellerId', }; const = { : 'listing.reprice', : 'listing', : 'reprice', } as ; const : Matrix = { : 'listings@7', : [{ ..., : [{ : [] }] }], }; // One branch appended: a bookseller may reprice a listing they sell. const : Matrix = { : 'listings@8', : [ { ..., : [{ : [] }, { : [, ] }], }, ], }; const = (, ); .; // -> false .; // -> { before: 'listings@7', after: 'listings@8' } const = (, 'granted'); [0]?.; // -> 'listing.reprice' [0]?.; // -> 'allow-branch-added' [0]?..; // -> [isBookseller] [0]?..; // -> [ownListing] [0]?..; // -> []

Explanation

interface

What explain returns: the Decision can returns for the same call, and every rule behind it.

Full documentation

step is the precedence step, 1 to 7, that decided. deny and allow list the permission’s rules in document order with every condition evaluated. An unknown action and a stale document answer their can decision with empty lists and no step.

Explanation is what explain returns. decision equals what can returns for the same call. deny and allow list the permission’s rules in document order, each a RuleExplanation. step is the precedence step, 1 to 7, that decided. An unknown action and a stale document return their can decision with empty lists and no step.

interface Explanation { : Decision; ?: 1 | 2 | 3 | 4 | 5 | 6 | 7; : RuleExplanation[]; : RuleExplanation[]; }
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']

RuleExplanation

interface

One rule of a permission, as explain reports it.

Full documentation

rule is the id a Decision.rule would carry. state is the rule over all its conditions: not-matched when any one fails, then unusable-clock, then unevaluable, then matched.

RuleExplanation is one rule of an Explanation. rule is the id a Decision.rule carries. state is matched, not-matched, unusable-clock or unevaluable, taken over all its conditions. conditions lists each one as a ConditionExplanation.

interface RuleExplanation { : string; : 'matched' | 'not-matched' | 'unusable-clock' | 'unevaluable'; : ConditionExplanation[]; }

ConditionExplanation

interface

One condition of a rule, as explain reports it.

Full documentation

condition is the condition as the document stores it. state is what the engine found: holds, fails, unusable-clock when the call’s instant does not parse, or unevaluable when the object lacks a path it reads. missing names those object.* paths and appears only on unevaluable. No value from the subject or the object appears here.

ConditionExplanation is one condition of a rule. condition is the condition as the document stores it. state is holds, fails, unusable-clock or unevaluable. missing names the object.* paths an unevaluable condition could not read. It carries no value from the subject or the row.

interface ConditionExplanation { : ; : 'holds' | 'fails' | 'unusable-clock' | 'unevaluable'; ?: readonly string[]; }

FieldRules

interface

The field rules attached to an action’s permission.

Full documentation

fields holds the name allow-list / bang entries (['*', '!status'] or ['body', 'title']). Every other key is a field name mapped to a FieldConfig for the write axis.

A field named fields therefore carries no config: the key is taken. It can still be allowed or denied by name through the allow-list, which is what a read-axis field needs in any case. A document that gives fields a config is refused at construction with an InvalidPermissionError naming the clash.

interface FieldRules { ?: readonly string[]; [: string]: readonly string[] | | undefined; }

FieldConfig

type

A field value rule: either an allow-list of proposed values, or a state machine over the current value. Never both.

export type = { : readonly unknown[]; } | { : <string, readonly unknown[]>; };

FieldPath

type

The escaped dotted paths of T through plain-object properties and array elements, as a union. An array contributes the paths of its element type with no index segment. Date, Map, Set, functions and primitives are leaves. A key containing a dot or a backslash appears escaped, and a number key as its string form. Past Depth segments, and under any, unknown, object, {} or an index signature, the type is string.

Full documentation

A policy over a generic object type should use the untyped builder, because the typed fields() cannot walk a type parameter.

The typed fields() does not enumerate this union. It validates each entry by walking its own segments, so its cost does not grow with the size of T.

export type <, extends number = 5> = [ ] extends [ 0 ] ? string : unknown extends ? string : extends readonly (infer )[] ? <, > : extends ? never : extends object ? [ keyof ] extends [ never ] ? string : { [ in keyof & (string | number)]: <> | `${<>}.${<<[]>, []>}`; }[keyof & (string | number)] : never;

FieldEntry

type

One entry of a field list: *, a path of T, or ! and a path of T.

export type <> = '*' | <> | `!${<>}`;

FieldRulesOf

type

The field rules of an action on Obj: the entry list under fields, and a targets or transitions config under each path of Obj.

export type <> = { ?: readonly <>[]; } & { [ in <>]?: | readonly string[]; };

ValidEntries

type

The field list fields() takes on the typed builder: each entry is *, a path of Obj, or ! and a path of Obj. An entry that names no path of Obj becomes an error message naming it, so the compiler reports the bad entry itself.

export type < extends readonly string[], > = { [ in keyof ]: <[], >; };

ValidRules

type

The field rules object fields() takes on the typed builder: its fields list is checked as ValidEntries, and each targets/transitions key must be a path of Obj. A key that names no path of Obj becomes an error message naming it.

export type <, > = { [ in keyof ]: extends 'fields' ? [] extends readonly string[] ? <[], > : [] : extends string ? <, > extends ? [] : <, > : []; };

MatrixSchema

interface

The shapes a matrix’s conditions are checked against.

Full documentation

Optional for a producer and binding when present. Granularity is per object kind: a kind objects does not declare is unchecked, and subject.* paths are unchecked unless subject is declared.

What a present schema checks, exactly:

  • a condition naming a field the declared kind does not declare is a construction error (UnknownFieldError);
  • a condition whose operator does not fit the declared type is a construction error (FieldTypeMismatchError).

What it does not check: the field names in FieldRules (the fields allow-list, and the targets/transitions keys), and whether a permission can decide unevaluable for a complete instance. Both are unchecked whether or not a schema is present.

interface MatrixSchema { readonly ?: ObjectSchema; readonly ?: <<, ObjectSchema>>; }

ObjectSchema

interface

One object kind’s declared shape.

Full documentation

relations names the kinds this one points at, one hop, without nesting. A condition reads one field of one scope, so a relation is not a value a condition compares; it is declared for the consumers that resolve it.

interface ObjectSchema { readonly ?: <<string, >>; readonly ?: <<string, >>; }

FieldType

type

One field’s declared type.

Full documentation

A flat string, so the whole schema is JSON a producer in any language emits by reflection. [] is an array of the base type. A trailing ? marks a field that may be absent from a complete instance; nullability is not an optionality axis, so a declared field that is present and null is present.

export type = | `${}[]` | `${}?` | `${}[]?`;

BaseFieldType

type

The base of a declared field type.

Full documentation

instant is a point in time carried as an ISO 8601 string or epoch milliseconds, the same two forms an Instant survives JSON in.

export type = 'string' | 'number' | 'boolean' | 'instant';

Instant

type

An instant, for a now condition or a context clock. A string is parsed as ISO 8601, a number as epoch milliseconds. The string and number forms survive a JSON round trip, so an SSR payload carries one unchanged.

Full documentation

A condition boundary must parse: validateMatrix refuses a before/after value that does not. A context clock that does not parse is not an error either — it refuses. Every permission whose decision reads it lands on unusable-clock.

export type = string | number | Date;

Decision

interface

One action-level decision.

FieldWhat it carries
keythe permission key the query asked about
allowedthe gate
reasonoutput only; The Decision object every check returns says which reason carries which
ruleoutput only; the id of the rule that decided
missingoutput only; the paths that did not read
interface Decision { : string; : boolean; : ; ?: string; ?: readonly string[]; }
import { , } from '@evanion/acl'; import type { Decision, Matrix } from '@evanion/acl'; const : Matrix = { : [ { : 'storefront:listing.read', : 'storefront:listing', : 'read', : [ { : [{ : 'subject.roles', : 'contains', : 'owner' }] }, ], }, ], }; const : Matrix = { : [ { : 'stock:listing.read', : 'stock:listing', : 'read', : [ { : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, ], }; // One Access per origin. Each document belongs to the service that emitted it, // so each arrives through `parseMatrix` and fails closed. Two origins claiming // one key throw `OriginCollisionError` on this line, naming both. const = ({ : (), : (), }); const = { : 'u1', : ['bookseller'] }; // One advisory view for a UI, over one instant every origin reads. const : <string, Decision> = .(); .().(); // -> ['stock:listing.read', 'storefront:listing.read'] ['stock:listing.read']?.; // -> true ['storefront:listing.read']?.; // -> false // The origin holding the key answers it. A key nobody holds reaches no origin. .(, 'stock:listing', 'read').; // -> true .(, 'shipping:parcel', 'read').; // -> 'unknown-action' // A request touching both origins asks both, and the caller writes the AND. const = .(, 'storefront:listing', 'read').; const = .(, 'stock:listing', 'read').; && ; // -> false // The member itself, with `canMany`, `canFields`, `readsObject` and `authorize` // on it, plus the document that origin published. .('storefront')?...; // -> 1

Reason

type

Why a decision landed where it did. Output only.

Full documentation

unevaluable and unusable-clock both answer allowed: false and both mean “the engine could not reach an answer”, so neither is a refusal. They are two reasons because the caller’s move differs: unevaluable names paths in missing and one refetch settles it, unusable-clock says the instant the call supplied does not parse and only a different argument settles it.

stale-contract is the third reason with a caller’s move attached, and the move is a fetch of the document itself. The holder is past the freshness budget Matrix.maxStale states, so the document carries no claim about the present and every key answers this, including a key it does not hold.

export type = 'allow' | 'no-rule-matched' | 'denied' | 'unknown-action' | 'unevaluable' | 'unusable-clock' | 'stale-contract';

FieldDecision

interface

A decision over every field of an action.

Full documentation

The field maps are computed whatever action says, so a caller blocked at the action level still learns which fields would be editable once it is unblocked.

FieldWhat it carries
allowedthe action is allowed and every decided field is
actionthe action-level Decision alone
fieldsevery field name mapped to a FieldState; compare against 'allowed', since comparing against 'denied' treats an unevaluable field as writable
reasonsevery field name mapped to the FieldReason that settled it
interface FieldDecision { : boolean; : Decision; : <string, >; : <string, >; }

FieldState

type

The result of a field-level decision: allowed, denied, or not evaluable.

Full documentation

unevaluable is a write-axis state. Read is projection only — the name allow-list is the whole of it, and a name either is on the list or is not — so a read decision’s map holds allowed and denied and nothing else. The type stays three-valued because axis is a runtime argument: narrowing the read path would mean a type parameter on FieldDecision and an overload pair on every entry point, and it would still widen back wherever a caller passes an axis it computed.

See Pitfalls.

export type = 'allowed' | 'denied' | 'unevaluable';

FieldReason

type

Why a field decision landed where it did. Output only.

Full documentation

Every member is emitted, so an exhaustive switch over this union has no unreachable arm. A refusal is not-listed, targets-failed or transition-failed; the reason names which rule refused, and FieldState carries the plain denied.

not-traversable marks a key the field rules need to look into and the engine cannot: a value that is not a plain object or an array (a Date, a Map, a class instance, a primitive, null), an array with a hole or a non-object element, an object that holds a getter or an empty-string key, a repeat in a cycle, or, on the write axis, an array or a replaced subtree. The key is denied whole and none of its children are decided.

export type = 'allow' | 'not-listed' | 'targets-failed' | 'transition-failed' | 'missing-field' | 'proposed-required' | 'not-traversable';

EvaluationContext

interface

The namespaced evaluation context.

The entry points assemble an EvaluationContext, and now settles to one epoch per call.

interface EvaluationContext { : <string, unknown>; ?: <string, unknown>; ?: ; }

Subject

type

A subject for a single call: the actor, a plain object of attributes.

export type = <string, unknown>;

AnyObjects

type

The default object map: any key, any bag. What a document whose shapes nothing declared at the type level answers for.

AnyObjects is the open key map Access defaults to.

export type = <string, <string, unknown>>;

ObjectKey

type

The kind of object an action applies to. A string key in the matrix.

export type = string;

Actions

interface

The block parameter: the ops, plus the chained action declarations.

Full documentation

Act is the actions declared so far in this chain. allow and deny widen it by the literal they were handed, and .for() reads the final union off what the block returns, so the permission keys come from the calls that declare them and an author writes each action once.

Vocab is what those two calls may name, which is the kind’s entry in the vocabulary map or Action. The two parameters answer different questions: Vocab is what this kind may do, Act is what this block said it does, and only Act reaches the permission keys. A vocabulary listing 'reprice' that no block allows puts no listing.reprice in the keys.

What a discovered Act gives up: a misspelled p.allow('updte') inside a kind whose vocabulary is string is a key rather than an error, and it surfaces at every site that names the key, where caps['comment.update'] and access.can(s, 'comment', 'update') both fail against a union carrying 'comment.updte'. A kind whose vocabulary is a union refuses the misspelling here instead, at the call that wrote it.

fields and visibility carry both parameters through untouched: each one marks the action most recently declared and declares none of its own.

Actions is the block parameter .for() hands you.

Member or typeWhat it does
allow, denyadd one rule to the action named
allowEach, denyEachwrite one rule across several actions, one ordinary permission each
idname the rule the previous allow or deny wrote, for a decision’s rule
fields, visibilitymark the action last declared, and raise AclConfigError after a batch
Vocabwhat this kind may declare
Actwhat this chain declared; only Act reaches the permission keys
interface <, , extends string = string, extends string = string> extends <, > { < extends >(: , ...: Cond[]): <, , | , >; < extends >(: , ...: Cond[]): <, , | , >; < extends >(: readonly [], ...: Cond[]): <, , | , >; < extends >(: readonly [], ...: Cond[]): <, , | , >; (: string): <, , , >; <const extends readonly string[]>(: <, >): <, , , >; <const extends FieldRules>(: <, >): <, , , >; (: ): <, , , >; }

Action

type

The default action vocabulary, read off CRUD_ACTIONS.

Full documentation

The list is the one declaration. A caller that wants the four at runtime passes CRUD_ACTIONS to allowEach, and the type follows it, so the union and the array cannot drift.

export type = (typeof )[number];
import { } from '@evanion/acl'; import type { } from '@evanion/acl'; type = { : string; : string[] }; // One policy, every object kind the app has. A `.for()` per kind, and the // permissions they flatten to live in the same flat list. const = < , { : { : string }; : { : string } }, { : | 'export' } >() .('report', () => .('read', .('subject.roles', 'bookseller')) .('export', .('subject.roles', 'owner')), ) .('listing', () => .('read', .('subject.roles', 'owner'))) .(); const = .({ : 'u1', : ['bookseller'] }); .(); // -> ['report.read', 'report.export', 'listing.read'] ['report.read']?.; // -> true ['report.export']?.; // -> 'no-rule-matched' ['listing.read']?.; // -> 'no-rule-matched'

VocabularyOf

type

The vocabulary one kind’s block may declare from.

Full documentation

A kind the map names gets that entry; string there is a kind whose actions no union can list. A kind the map leaves out gets Action, so a policy that declares no vocabulary at all still refuses a verb outside the four.

export type <, extends string> = extends keyof ? [] extends string ? [] : : ;

Visibility

type

The publication marking a permission carries, named for reuse in a block.

Visibility is the marking p.visibility() writes and serialize(access, 'reduced') reads.

export type = <Permission['visibility']>;

PermissionKeys

type

The permission keys one block contributes, joined from the object kind and the actions the block declared.

Full documentation

An open vocabulary contributes string, which absorbs every other member of the union it joins. A block whose body is a statement with no return, or one that hands allow a value the compiler reads as string, therefore widens the whole document’s keys to string, which is the shape a document arriving as JSON answers with: a key half the document cannot name is a key nothing can be checked against.

export type < extends string, extends string> = string extends ? string : `${}.${}`;

Ops

interface

The condition helpers, bound to one subject type and one object type.

Ops holds the condition helpers, bound to the subject and object types.

not-in is not an identifier, so the helper is notIn.

TypeWhat it is
Condan opaque condition tree, which build() flattens to a rule per or branch
Operanda comparand shaped like a namespaced path is checked as one, and any other string is a literal
Pathsthe subject. and object. paths one bound type offers
Valida path those types carry; the failure branch is the parameter type, so the compiler names the bad path
interface <, > { < extends string, >(: <, , >, : <, , >): Cond; < extends string, >(: <, , >, : <, , >): Cond; < extends string>(: <, , >, : readonly unknown[]): Cond; < extends string>(: <, , >, : readonly unknown[]): Cond; < extends string>(: <, , >, : unknown): Cond; (: 'now', : ): Cond; (: 'now', : ): Cond; (...: Cond[]): Cond; (...: Cond[]): Cond; readonly : Cond; }

Cond

interface

An opaque condition tree. Built by the helpers, flattened by the builder.

Full documentation

node is typed unknown so the type is opaque in fact and not only in this sentence. A caller holds a Cond, passes it to allow or deny, and never reads it; typing the member as the tree would publish the tree’s shape as API, and typing it as a name this package does not export would leave a member no consumer can write down.

The second of those is what shipped. Node is module-private, so a declaration emitting readonly node: Node referenced a name that resolves nowhere for a consumer, and in a context that re-declares it the name binds to the DOM’s Node with no error at all.

interface Cond { readonly : unknown; }

Operand

type

An operand shaped like a namespaced path is validated as one; anything else is a literal value and passes through untouched. The shape is the only discriminator there is, and it is load-bearing: without it a mistyped 'subject.idd' is indistinguishable from a deliberate string literal and the condition compares a field against that text.

export type <, , > = extends `subject.${string}` | `object.${string}` | 'now' ? < & string, , > : ;

Paths

type

The namespaced paths one level deep into T, prefixed with P.

export type <, extends string> = { [ in keyof & string]: `${}.${}`; }[keyof & string];

Valid

type

S when it names a path on this resource, and an error message otherwise.

Full documentation

The failure branch is the parameter type, so the compiler’s message names the offending path instead of reporting an opaque assignability mismatch.

export type < extends string, , > = extends <, 'subject'> | <, 'object'> | 'now' ? : `unknown path '${}' on this resource`;

@evanion/acl/testing

@evanion/acl/testing is the second entry point, holding the assertions a consumer of a published contract writes. @evanion/acl re-exports none of it, so an application importing the engine pulls none of it into its bundle, and nothing here imports a test framework. See Testing a contract I consume.

import { , , , } from '@evanion/acl/testing';

contractDrift

function

What changed between the contract a consumer pinned and the one its producer publishes now.

Full documentation

The assertion runs in the direction the published-contract design requires: the consumer holds the producer’s document and replays its own questions against it. A fixture matrix written by the consumer to stand in for the contract would be a second copy of somebody else’s rules, which is the failure the contract exists to remove, and a test file is no better a place for that copy than production code is.

Two findings, kept apart because the consumer’s move differs. A key the producer removed breaks a published contract and the producer has to hear about it. A decision that changed may be exactly what the producer intended, and the consumer decides whether its UI still reads correctly.

Both documents are adopted with parseMatrix, which fails closed, so a removed key answers unknown-action and is reported beside the others rather than throwing partway through the run. Neither adoption reports a fetchedAt, so no freshness budget applies and no case answers stale-contract for a reason that has nothing to do with the two versions.

contractDrift replays a consumer’s own decisions against two versions of one contract and reports what moved.

Both documents are adopted with parseMatrix, which fails closed, so a removed key answers unknown-action and is reported in changed. Nothing throws partway through the run.

function < = never>(: <>): <>;

What the tests state

20 behaviours

reports no removed key
reports no changed decision
carries both versions
names the key it dropped
reports the decision that key carried as changed as well
answers rather than throwing, because both documents fail closed
spells the removal as a broken contract
removes no key
names the case whose decision moved
carries both decisions
spells the change as a change and not as a break
names the added key
reports a refusal whose missing paths grew
settles one instant for every case that names none
lets a case name its own instant
names a case after its permission key when the case names nothing
applies no freshness budget of its own
carries whatever the diff returned
is handed the two documents in pinned-then-fetched order
reports undefined where no diff was passed

reports no removed key

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // The contract this consumer pinned, checked in beside the test. const : Matrix = { : 'orders@7', : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'bookseller', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], }, { : 'orders:order.cancel', : 'orders:order', : 'cancel', : [{ : 'anyone', : [] }], }, ], }; // What the producer publishes now, fetched in CI. The refund key is gone. const : Matrix = { : 'orders@8', : [ { : 'orders:order.cancel', : 'orders:order', : 'cancel', : [{ : 'anyone', : [] }], }, ], }; const = { : 'u1', : ['bookseller'] }; const = ({ , , // The decisions this application renders, named the way a reader reads them. : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, { : 'the cancel button', : , : 'orders:order', : 'cancel', }, ], }); .; // -> ['orders:order.refund'] ..(() => .); // -> ['the refund button'] .[0]?..; // -> 'unknown-action'

assertNoContractDrift

function

Throws when the producer removed a published key or a replayed decision changed, and returns the report otherwise.

Full documentation

An added key passes. A consumer pinned to a contract version gets a red build when the producer publishes a change its own questions can see, and a green one when the producer only widened the surface.

assertNoContractDrift throws a ContractDriftError when the producer removed a published key or a replayed decision changed, and returns the report otherwise.

An added key passes, so a producer that only widened its surface leaves the consumer’s build green.

function < = never>(: <>): <>;

What the tests state

4 behaviours

passes the assertion
throws a ContractDriftError carrying the report
fails the assertion
passes the assertion, because a wider surface breaks nothing

passes the assertion

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // One refund permission, held by whoever carries `role`. const = (: string, : string): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'staff', : [{ : 'subject.roles', : 'contains', : }], }, ], }, ], }); // The producer kept the key and narrowed the role that holds it. const = { : 'u1', : ['bookseller'] }; let : unknown; try { ({ : ('orders@7', 'bookseller'), : ('orders@8', 'supervisor'), : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, ], }); } catch () { = ; } const = as ; instanceof ; // -> true ..; // -> [] ..[0]?..; // -> 'no-rule-matched' (.).('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'

describeContractDrift

function

The report as a reader sees it, removals first.

Full documentation

A removal is stated as a broken contract and a changed decision is stated as a change, because a report that spelled them the same way would leave the consumer to work out which of the two it is looking at.

describeContractDrift renders a report as a reader sees it, removals first.

A removal is spelled as a broken contract and a changed decision is spelled as a change, because one report that spelled them alike would leave the consumer to work out which of the two it is looking at.

function (: <unknown>): string;

What the tests state

3 behaviours

says so when nothing moved
leads with the versions it compared
names an added key

says so when nothing moved

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // One refund permission, held by whoever carries `role`. const = (: string, : string): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'staff', : [{ : 'subject.roles', : 'contains', : }], }, ], }, ], }); // The producer kept the key and narrowed the role that holds it. const = { : 'u1', : ['bookseller'] }; let : unknown; try { ({ : ('orders@7', 'bookseller'), : ('orders@8', 'supervisor'), : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, ], }); } catch () { = ; } const = as ; instanceof ; // -> true ..; // -> [] ..[0]?..; // -> 'no-rule-matched' (.).('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'

ContractDriftOptions

interface

What contractDrift compares, and what it replays against both documents.

Full documentation

pinned and fetched are the two versions of one producer’s contract. The rest narrows the comparison to what this consumer actually depends on: the decisions its screens render, the instant to settle them at, and a differ when the caller wants the whole document compared as well.

pinned is the contract this consumer compiled against and fetched is the one the producer publishes now. contractDrift settles now once, so it decides a time-windowed permission at one epoch in both documents and never at two readings of the wall clock.

interface < = never> { readonly : Matrix; readonly : Matrix; readonly ?: readonly DecisionCase[]; readonly ?: ; readonly ?: <>; }

ContractDriftReport

interface

What changed between the pinned contract and the one the producer publishes.

Full documentation

The three findings are deliberately separate, because a consumer answers them differently. A removed key is the producer breaking a published contract. A changed decision may be exactly what the producer intended, and the consumer decides whether its screens still read correctly. An added key is neither, and assertNoContractDrift passes on one.

removed holds the permission keys the pinned contract carries and the fetched one does not, which is a producer breaking a published contract. added is not a break. diff is whatever options.diff returned.

interface < = never> { readonly : string | number | undefined; readonly : string | number | undefined; readonly : readonly string[]; readonly : readonly string[]; readonly : readonly DecisionChange[]; readonly : | undefined; }

DecisionCase

interface

One question a consumer’s UI asks, stated so it can be replayed against two versions of the same contract.

Full documentation

The three positional arguments of can in a bag, plus a name for the report. key is the object kind and action the action, the way can takes them; the permission key the document carries is ${key}.${action}.

The three positional arguments of can in a bag, plus a name for the report. key is the object kind and action the action, so the permission key the document carries is `${key}.${action}`.

interface DecisionCase { readonly ?: string; readonly : ; readonly : string; readonly : string; readonly ?: <string, unknown>; readonly ?: ; }

DecisionChange

interface

A case the two versions answer differently.

Two decisions are the same when key, allowed, reason, rule and every element of missing match, so a refusal that started naming a second unreadable path arrives as a change.

interface DecisionChange { readonly : string; readonly : DecisionCase; readonly : Decision; readonly : Decision; }

MatrixDiffer

type

The seam diffMatrix drops into.

Full documentation

contractDrift decides nothing from the result and only carries it onto the report, so the diff’s own shape stays the diff’s business and lands here as D. A caller that passes diffMatrix gets report.diff typed as whatever diffMatrix returns; a caller that passes nothing gets undefined.

The type of options.diff, which diffMatrix satisfies. contractDrift reads nothing out of the result and carries it onto report.diff, so the diff’s own shape stays the diff’s business and arrives here as D.

export type <> = (: Matrix, : Matrix) => ;

fixtureClock

function

The freshness boundary of a document, computed the way the engine computes it.

Full documentation

stale-contract is the refusal a consumer’s UI meets in production and never in development, because a document authored in-process reports no fetchedAt and runs under no bound. Reaching it in a test means passing an instant past fetchedAt + min(matrix.maxStale, options.maxStale), which every entry point already accepts as its last argument. This states that instant by name.

The two bounds are exact. hydratePolicy refuses on settled > expiresAt, so fresh is the last instant that decides and stale is the first that does not, and a test naming both covers the boundary rather than a point near it.

Every argument is validated as hydratePolicy validates it, so a document stating no maxStale raises MissingFreshnessBudgetError here instead of at the construction the test was about to write.

fixtureClock computes a document’s freshness budget the way the engine computes it and names the two instants that bracket it.

Every argument is validated as hydratePolicy validates it, so a document stating no maxStale raises MissingFreshnessBudgetError from fixtureClock, before the test reaches the construction it was about to write.

function (: Matrix, ?: FixtureClockOptions): FixtureClock;

What the tests state

7 behaviours

settles the validation instant
takes the budget from the document
takes the holder's shorter ceiling
refuses to extend the owner's ceiling
accepts an instant in any of the three spellings
reads the wall clock when the test names no instant
counts an arbitrary offset from the validation instant

settles the validation instant

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@7', : 300_000, : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [{ : 'anyone', : [] }], }, ], }; const = (, { : '2026-01-01T00:00:00Z' }); const = (, .); const = { : 'u1' }; const = .( , 'orders:order', 'refund', , ., ); .; // -> 'allow' // `assertRefused` names the key, the reason and the rule when it throws, so a // failure says which refusal arrived instead of `false !== true`. const = .( , 'orders:order', 'refund', , ., ); (, 'stale-contract').; // -> false

FixtureClockOptions

interface

What a holder reports about its own freshness, as a test states it.

interface FixtureClockOptions { ?: ; ?: number; }

FixtureClock

interface

The two instants that bracket a document’s freshness budget, and the options that produced them.

options carries the two numbers the instants were derived from, so the holder and the test cannot disagree about the budget.

interface FixtureClock { readonly : number; readonly : number; readonly : number; readonly : number; readonly : number; readonly : AccessOptions; (: number): number; }

assertAllowed

function

Returns the decision when it allows, and throws naming why it did not.

Full documentation

The decision is returned so a caller chains on it: assertAllowed(access.can(...)).rule.

assertAllowed returns the decision when it allows, and throws naming why it did not.

The decision is returned so a caller chains on it, and context is a label prefixed onto the message.

function (: Decision, ?: string): Decision;

What the tests state

5 behaviours

returns the decision it was given
throws an AclAssertionError on a refusal
names why the refusal happened
names the missing paths, which is what repairs an unevaluable
prefixes the caller's label

returns the decision it was given

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@8', : [ { : 'orders:order.update', : 'orders:order', : 'update', : [ { : 'staff', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : { : ['note'] }, }, ], }; const = (); const = { : 'u1', : ['bookseller'] }; const = .(, 'orders:order', 'update'); (, 'the order editor').; // -> 'staff' (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"' // A shopper holds no role the rule names, so no allow rule matched. const = { : 'u2', : [] }; const = .(, 'orders:order', 'update'); (, 'no-rule-matched', 'the order editor').; // -> false // No proposed write, so the decision covers the row's fields and the listed names. const = .( , 'orders:order', 'update', { : 'held for pickup' }, 'write', ); (, 'note', 'allowed').; // -> true (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)' let : unknown; try { (, 'total', 'allowed'); } catch () { = ; } const = as ; instanceof ; // -> true .; // -> '"orders:order.update" has no field "total": it carries note'

assertRefused

function

Returns the decision when it refuses, and throws naming what it answered.

Full documentation

Naming reason asserts which refusal, and the three that a consumer’s UI treats differently are the point: unevaluable asks for a refetch of the row, stale-contract for a refetch of the document, and denied for neither.

assertRefused returns the decision when it refuses, and throws naming what it answered.

Naming reason asserts which refusal arrived, and a UI treats three of them differently: unevaluable asks for a refetch of the row, stale-contract for a refetch of the document, and denied for neither.

function (: Decision, ?: , ?: string): Decision;

What the tests state

5 behaviours

returns the decision when it refuses for any reason
returns the decision when it refuses for the named reason
throws when the decision allows
throws when the refusal is a different one
prefixes the caller's label

returns the decision when it refuses for any reason

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@7', : 300_000, : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [{ : 'anyone', : [] }], }, ], }; const = (, { : '2026-01-01T00:00:00Z' }); const = (, .); const = { : 'u1' }; const = .( , 'orders:order', 'refund', , ., ); .; // -> 'allow' // `assertRefused` names the key, the reason and the rule when it throws, so a // failure says which refusal arrived instead of `false !== true`. const = .( , 'orders:order', 'refund', , ., ); (, 'stale-contract').; // -> false

assertFieldState

function

Returns the field decision when the named field is in state, and throws naming the whole decision when it is not.

Full documentation

A field the decision carries no entry for is a separate failure: the maps are keyed by the names the document declares, so an absent name is a test naming a field the permission never had.

assertFieldState returns the field decision when the named field is in state, and throws naming the whole decision when it is not.

A field the decision carries no entry for is reported apart, because decision.fields and decision.reasons are keyed by the names the document declares and an absent name means the test named a field the permission never had.

function (: FieldDecision, : string, : , ?: string): FieldDecision;

What the tests state

5 behaviours

returns the decision when the field is in the named state
names the state it found and the reason for it
reports a field the decision carries no entry for apart
says so when the decision carries no fields at all
prefixes the caller's label

returns the decision when the field is in the named state

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@8', : [ { : 'orders:order.update', : 'orders:order', : 'update', : [ { : 'staff', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : { : ['note'] }, }, ], }; const = (); const = { : 'u1', : ['bookseller'] }; const = .(, 'orders:order', 'update'); (, 'the order editor').; // -> 'staff' (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"' // A shopper holds no role the rule names, so no allow rule matched. const = { : 'u2', : [] }; const = .(, 'orders:order', 'update'); (, 'no-rule-matched', 'the order editor').; // -> false // No proposed write, so the decision covers the row's fields and the listed names. const = .( , 'orders:order', 'update', { : 'held for pickup' }, 'write', ); (, 'note', 'allowed').; // -> true (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)' let : unknown; try { (, 'total', 'allowed'); } catch () { = ; } const = as ; instanceof ; // -> true .; // -> '"orders:order.update" has no field "total": it carries note'

explainDecision

function

One line naming everything a Decision carries.

Full documentation

allowed is a boolean, so a failed expect(decision.allowed).toBe(true) reports true !== false and names neither the key, the reason, the rule that decided, nor the paths a refusal is waiting on. Those four are what tells a reader whether the test or the document is wrong, and unevaluable in particular is repaired by reading missing.

explainDecision renders one line naming everything a Decision carries.

A failed expect(decision.allowed).toBe(true) reports true !== false and names neither the key, the reason, the rule that decided, nor the paths a refusal is waiting on.

function (: Decision): string;

What the tests state

3 behaviours

names the key, the outcome, the reason and the rule
names every missing path
names no rule where none decided

names the key, the outcome, the reason and the rule

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@8', : [ { : 'orders:order.update', : 'orders:order', : 'update', : [ { : 'staff', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : { : ['note'] }, }, ], }; const = (); const = { : 'u1', : ['bookseller'] }; const = .(, 'orders:order', 'update'); (, 'the order editor').; // -> 'staff' (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"' // A shopper holds no role the rule names, so no allow rule matched. const = { : 'u2', : [] }; const = .(, 'orders:order', 'update'); (, 'no-rule-matched', 'the order editor').; // -> false // No proposed write, so the decision covers the row's fields and the listed names. const = .( , 'orders:order', 'update', { : 'held for pickup' }, 'write', ); (, 'note', 'allowed').; // -> true (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)' let : unknown; try { (, 'total', 'allowed'); } catch () { = ; } const = as ; instanceof ; // -> true .; // -> '"orders:order.update" has no field "total": it carries note'

explainFieldDecision

function

One line naming the action decision a field decision hangs off, and every field beside its state and its reason.

Full documentation

FieldDecision.allowed is true only when the action is allowed and every field is, so a false one has at least two places to look and the maps are both of them.

explainFieldDecision renders the action decision a field decision hangs off, then every field beside its state and its reason.

FieldDecision.allowed is true only when the action is allowed and every field is, so a false one has at least two places to look: decision.action and decision.fields.

function (: FieldDecision): string;

What the tests state

2 behaviours

names the action decision and every field beside its reason
says so when the permission configures no fields

names the action decision and every field beside its reason

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { } from '@evanion/acl'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import { , } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; const : Matrix = { : 'orders@8', : [ { : 'orders:order.update', : 'orders:order', : 'update', : [ { : 'staff', : [ { : 'subject.roles', : 'contains', : 'bookseller' }, ], }, ], : { : ['note'] }, }, ], }; const = (); const = { : 'u1', : ['bookseller'] }; const = .(, 'orders:order', 'update'); (, 'the order editor').; // -> 'staff' (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"' // A shopper holds no role the rule names, so no allow rule matched. const = { : 'u2', : [] }; const = .(, 'orders:order', 'update'); (, 'no-rule-matched', 'the order editor').; // -> false // No proposed write, so the decision covers the row's fields and the listed names. const = .( , 'orders:order', 'update', { : 'held for pickup' }, 'write', ); (, 'note', 'allowed').; // -> true (); // -> '"orders:order.update" allowed with reason "allow", from rule "staff"; note: allowed (allow)' let : unknown; try { (, 'total', 'allowed'); } catch () { = ; } const = as ; instanceof ; // -> true .; // -> '"orders:order.update" has no field "total": it carries note'

AclAssertionError

error

Base class for every refusal this kit raises.

Full documentation

A plain Error subclass and no matcher: the kit runs under vitest, node:test and jest, and each of those reports a thrown error from a test body the same way. Nothing here imports a test framework, so the package keeps the zero runtime dependencies its security posture rests on.

Every assertion in @evanion/acl/testing throws this class or a subclass of it. A plain Error subclass and no matcher, so vitest, node:test and jest each report a throw from a test body the same way.

export declare class extends { constructor(: string); }
import { , } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // One refund permission, held by whoever carries `role`. const = (: string, : string): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'staff', : [{ : 'subject.roles', : 'contains', : }], }, ], }, ], }); // The producer kept the key and narrowed the role that holds it. const = { : 'u1', : ['bookseller'] }; let : unknown; try { ({ : ('orders@7', 'bookseller'), : ('orders@8', 'supervisor'), : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, ], }); } catch () { = ; } const = as ; instanceof ; // -> true ..; // -> [] ..[0]?..; // -> 'no-rule-matched' (.).('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'

ContractDriftError

error

A removed key or a changed decision, raised with the whole report attached.

Raised by assertNoContractDrift for a removed key or a changed decision, with the whole report attached and describeContractDrift of that report as the message.

export declare class extends { readonly : <unknown>; constructor(: <unknown>); }
import { , } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import { } from '@evanion/acl/testing'; import type { Matrix } from '@evanion/acl'; // One refund permission, held by whoever carries `role`. const = (: string, : string): Matrix => ({ , : [ { : 'orders:order.refund', : 'orders:order', : 'refund', : [ { : 'staff', : [{ : 'subject.roles', : 'contains', : }], }, ], }, ], }); // The producer kept the key and narrowed the role that holds it. const = { : 'u1', : ['bookseller'] }; let : unknown; try { ({ : ('orders@7', 'bookseller'), : ('orders@8', 'supervisor'), : [ { : 'the refund button', : , : 'orders:order', : 'refund', }, ], }); } catch () { = ; } const = as ; instanceof ; // -> true ..; // -> [] ..[0]?..; // -> 'no-rule-matched' (.).('\n')[0]; // -> 'contract drift, orders@7 -> orders@8'

AclConfigError

error

Base class for the configuration errors. All raised at construction.

Full documentation

code is the class name, so a consumer that holds only the data of an error can still tell which one it was. path is a JSON pointer to the node of the document at fault, '' for the root. permissionKey names the permission the node sits in. The validator sets both after construction.

AclConfigError is the base of every class below. Typed authoring tables the same set by authoring step.

ErrorRaised when
InvalidMatrixErrorthe envelope carries no permissions array, or a version of the wrong type
InvalidSchemaErrorthe schema’s own shape is wrong; where locates it inside the document
InvalidPermissionErrora permission’s object, action, rules or fields is malformed
InvalidRuleErrora rule is not an object, or its when is not an array
InvalidConditionErrora condition’s shape, namespace, path depth or operator pairing is unusable
KeyMismatchErrorkey is not exactly `${object}.${action}`
DuplicatePermissionErrortwo permissions share a key
DuplicateRuleIdErrortwo rules of one permission carry the same id, counting both sides
AmbiguousRuleIdErrorp.id() follows an allow or deny that flattened to other than one rule
UnknownFieldErrora condition names a field the schema does not declare
FieldTypeMismatchErrorcontains runs against a non-array, or a literal is of the wrong type
DenyWithoutBaselineErrora !path entry has no * or listed ancestor to subtract from
TargetsTransitionsConflictErrorone field configures both targets and transitions
UnknownObjectKeyErrora query on the open path names an unknown object kind; parseMatrix fails closed
UnknownPermissionErrora query on the open path names an unknown key; parseMatrix answers unknown-action
OriginCollisionErrortwo origins claim one key, which federatedPolicies raises while composing
UnvetoablePermissionErroran overlay names a key the target does not list as vetoable
MissingVetoSchemaErrora vetoable key’s object kind has no schema.objects entry
UnpublishedVetoableErrorserialize reduces away a vetoable key the contract would not ship
MissingFreshnessBudgetErrora holder reports fetchedAt against a document stating no maxStale
InvalidFreshnessErrorfetchedAt is not an instant, or maxStale is not a finite, non-negative number of milliseconds
export declare class extends { : string; ?: string; readonly : string; constructor(: string); }
import { , } from '@evanion/acl'; const = { : 'v1' } as never; let : Error | undefined; try { (); } catch () { = as Error; } // Every class the package raises at construction extends this one, so a // startup guard catches the set without naming each member. instanceof ; // -> true instanceof ; // -> true

InvalidMatrixError

error

A matrix envelope whose own shape is not the canonical one.

export declare class extends { constructor(: string); }

InvalidSchemaError

error

A schema whose own shape is not the canonical one.

Full documentation

where locates the fault inside the document (schema.objects.comment.fields.status), so a producer emitting a schema by reflection finds it without a line number.

export declare class extends { readonly : string; constructor(: string, : string); }

InvalidPermissionError

error

A permission node whose own shape is not the canonical one.

export declare class extends { readonly : string; readonly : string; constructor(: string, : string, : string); }

InvalidRuleError

error

A rule node that is not an object, or whose when is absent or not an array.

Full documentation

field locates the rule inside the permission (rules[2]), so an author of a foreign matrix can find it without a line number.

export declare class extends { readonly : string; readonly : string; constructor(: string, : string, : string); }

InvalidConditionError

error

A condition whose shape, namespace, path depth, or operator/value pairing leaves it unevaluable.

Full documentation

field is the condition’s own field where that is readable, and otherwise the condition’s position inside the permission.

export declare class extends { readonly : string; readonly : string; readonly : string; constructor(: string, : string, : string, : string); }

KeyMismatchError

error

A key that is not `$\{object\}.$\{action\}`.

Full documentation

Lookup is by key alone, so a permission whose key disagrees with its object and action answers for a pair it was never written for.

export declare class extends { readonly : string; readonly : string; readonly : string; constructor(: string, : string, : string); }

DuplicatePermissionError

error

Two permissions with the same key.

Full documentation

Lookup is by key into one map, so a second entry under a key already taken decides for every call the first was written to answer. Which of the two wins is a fact about array order, and neither author stated it.

export declare class extends { readonly : string; constructor(: string); }

DuplicateRuleIdError

error

Two rules of one permission carrying the same id.

Full documentation

diffMatrix indexes each side by rule id and takes a set difference, so the second rule under a taken id replaces the first in that index and a branch added beside it is reported as no change. A reviewer reads the report as the whole of what the deploy altered, so an id that names two rules withholds a grant rather than mis-labelling one.

The two sides of a permission share one id space, and an allow rule may not take the id of a deny rule beside it. A derived id carries the side it sits on, as allow-53eebf50, so derivation puts no name on both sides. A Decision reports rule as that one string and states no side next to it, so "blocked-on-probation" appearing on both sides leaves a support answer naming two rules that decide opposite ways.

Two rules of one permission carrying the same id. diffMatrix indexes each side by rule id, so the second rule under a taken id replaces the first there and a branch added beside it is reported as no change. The allow side and the deny side of one permission share one id space. Naming a rule has the example.

export declare class extends { readonly : string; readonly : string; constructor(: string, : string); }
import { } from 'vitest'; import { } from '@evanion/acl'; import { , } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : string }; type = { : }; const = <, >() .('listing', () => .('update', .('object.sellerId', 'subject.id')) .('seller-edits-own') .('update', .('object.status', 'withdrawn')) .('withdrawn-is-final'), ) .(); const = { : 's1', : [] }; const = { : 's1', : 'withdrawn' }; .(, 'listing', 'update', ).; // -> 'withdrawn-is-final' // An or() inside one allow() emits one rule per branch, and a name cannot // name two of them. (() => <, >().('listing', () => .( 'update', .( .('object.sellerId', 'subject.id'), .('subject.roles', 'bookseller'), ), ) .('two-branches'), ), ).(); // One name, one rule, across both sides of the permission. (() => <, >() .('listing', () => .('update', .('object.sellerId', 'subject.id')) .('seller-edits-own') .('update', .('object.status', 'withdrawn')) .('seller-edits-own'), ) .(), ).();

AmbiguousRuleIdError

error

An .id() attached to an allow() or deny() that did not flatten to exactly one rule.

Full documentation

p.or(a, b) inside one call emits disjunctive normal form, one rule per branch, and an id names one rule. Writing the name onto each branch would hand diffMatrix two rules under one key, which is the state DuplicateRuleIdError refuses, and hand a decision a rule string that does not say which branch matched.

A p.id() following an allow or a deny that flattened to other than one rule. rules is how many that call wrote, and an or() inside it wrote one per branch.

export declare class extends { readonly : string; readonly : string; readonly : number; constructor(: string, : 'allow' | 'deny', : string, : number); }
import { } from 'vitest'; import { } from '@evanion/acl'; import { , } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : string }; type = { : }; const = <, >() .('listing', () => .('update', .('object.sellerId', 'subject.id')) .('seller-edits-own') .('update', .('object.status', 'withdrawn')) .('withdrawn-is-final'), ) .(); const = { : 's1', : [] }; const = { : 's1', : 'withdrawn' }; .(, 'listing', 'update', ).; // -> 'withdrawn-is-final' // An or() inside one allow() emits one rule per branch, and a name cannot // name two of them. (() => <, >().('listing', () => .( 'update', .( .('object.sellerId', 'subject.id'), .('subject.roles', 'bookseller'), ), ) .('two-branches'), ), ).(); // One name, one rule, across both sides of the permission. (() => <, >() .('listing', () => .('update', .('object.sellerId', 'subject.id')) .('seller-edits-own') .('update', .('object.status', 'withdrawn')) .('seller-edits-own'), ) .(), ).();

UnknownFieldError

error

A condition naming a field the schema does not declare.

Full documentation

Without a schema this is the typo class that evaluates to unevaluable forever on the foreign path, because an absent object.* path is a shortfall the caller is told to fill in rather than a miss.

export declare class extends { readonly : string; readonly : string; readonly : string; constructor(: string, : string, : string, : string); }

FieldTypeMismatchError

error

A condition whose operator or comparand does not fit the declared type.

export declare class extends { readonly : string; readonly : string; readonly : string; constructor(: string, : string, : string, : string); }

DenyWithoutBaselineError

error

A ! entry in a permission’s field rules has no baseline: it needs * or a listed ancestor of its path to subtract from.

export declare class extends { readonly : string; constructor(: string, : string); }

TargetsTransitionsConflictError

error

Both targets and transitions on one field.

export declare class extends { constructor(: string); }

UnknownObjectKeyError

error

An unknown object kind at runtime on a typed (local) matrix.

export declare class extends { readonly : ; constructor(: ); }

UnknownPermissionError

error

An unknown permission key at runtime on a typed (local) matrix.

export declare class extends { readonly : string; constructor(: string); }

OriginCollisionError

error

Two origins of a federated set claiming one permission key.

Full documentation

A federated set routes a question by its canonical key, so one key held by two members has two answers and the set would have to pick one of them. Each document is valid on its own and the two sit in two repositories, so the key alone leaves an operator short of the fact that decides which one to fix. Both origin names are carried for that reason.

export declare class extends { readonly : string; readonly : readonly [ string, string ]; constructor(: string, : string, : string); }

UnvetoablePermissionError

error

A deny overlay contributing to a key the target does not open for veto.

Full documentation

vetoable is the target’s whole statement of where another team may append a deny. A key outside it is a permission whose owner has said nothing, and an overlay that reached it would be a team editing rules it does not own.

export declare class extends { readonly : string; constructor(: string); }

MissingVetoSchemaError

error

A vetoable key whose object kind the target’s schema does not declare.

Full documentation

The obligation is scoped to the kinds behind vetoable, so a matrix that opens nothing owes no schema. Opening a key without declaring its kind leaves every contribution to it unchecked, which is the one state an extension point may not be in.

export declare class extends { readonly : string; readonly : ; constructor(: string, : ); }

UnpublishedVetoableError

error

A vetoable key a reduced serialization would drop.

Full documentation

The party that authors an overlay checks its contribution by running applyDenyOverlay against the target’s published contract. An internal vetoable key is absent from that contract, and the schema entry for its object kind goes with it, so the check refuses every contribution it was written to accept and the veto lands for the first time in the target’s deploy.

export declare class extends { readonly : string; constructor(: string); }

MissingFreshnessBudgetError

error

A holder reporting fetchedAt against a document that states no maxStale.

Full documentation

Reporting the instant is a request for a freshness budget, and the budget is the owner’s to state. Reading the absent field as zero would expire every key for the life of the process, which is indistinguishable from the origin being down and is arrived at through a field nobody set.

export declare class extends { constructor(); }

InvalidFreshnessError

error

A freshness bound that is not a span of milliseconds a decision can compare.

Full documentation

Both halves settle once at construction and are read on every call, so a value the comparison cannot use silently answers “fresh” forever.

export declare class extends { readonly : string; constructor(: string, : string); }

ActionNotAllowedError

error

A write was narrowed against a decision whose action is refused.

Full documentation

The only error this library raises outside construction. A refused action has no writable field, so the narrowing has no answer to return: an empty object would be indistinguishable from a lawful write of nothing.

ActionNotAllowedError is the only error the library raises outside construction. pickAllowedFields throws it when the action is refused.

export declare class extends { readonly : string; readonly : ; constructor(: string, : ); }

What is not exported

@evanion/acl exports no decide, decideFields or evaluateCondition. Each answers a fragment and trusts its caller for the rest.

Last updated on