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.
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
hydratePolicy
version
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'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
parseMatrix
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'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:
- Cloning: It creates a non-frozen copy of the document (matching the cloning logic used in
hydratePolicy). - 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.
- Read Faults: If a node cannot be read (e.g., a getter that throws), an issue is recorded at that specific path.
- Structural Faults: If the document is not an object, or if the
permissionsproperty is not an array, no further checks run, as further validation is impossible. Only the clone faults, if any, come with it. - 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
validateAll
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.
({ : [] }); // -> []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: Thekeyof the permission containing the error (if applicable), such asquestion.update.
interface ValidationIssue {
: string;
: string;
: string;
?: string;
}
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;
}
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 parameter | What it is |
|---|---|
Sub | the subject type every subject.* path is checked against |
Objects | each object kind mapped to its row type |
Vocabs | each kind mapped to the actions it answers for |
Action | the vocabulary a kind Vocabs omits gets, carrying no wildcard member; docs/specs/2026-09-17-acl-wildcard-action.md records why |
VocabularyOf | what 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
policy
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'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 type | What it is |
|---|---|
for | takes no type arguments, reading the object type out of Objects, the vocabulary out of Vocabs and Act off the chain its block returns |
matrix | flattens the blocks without building one |
build | validates every block once and returns the Access |
PermissionKeys | the keys one block contributes; an Act the compiler reads as string contributes string, widening the document’s whole key union |
KeysOf | lifts those keys out of an Access type; a producer exports KeysOf<typeof access> for a consumer compiled against its source |
ActionOf | the 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): <, , >;
}
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'>;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.
| Option | What it is |
|---|---|
version | overrides the document’s own version |
closed | fails closed on an unknown key |
fetchedAt | when this holder last validated; a holder that supplies it obliges the document to state maxStale |
maxStale | a local ceiling, min-ed with the document’s |
interface AccessOptions {
?: string | number;
?: boolean;
?: ;
?: number;
}
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.
| Member | What it answers |
|---|---|
can | one decision for one action on one row |
canMany | one decision per row, settling one clock for the whole list |
canEach | one decision per subject, settling one clock for the list |
canFields | the field-level decision on the read or write axis |
explain | the decision with every condition behind it |
capabilities | every permission key, decided against one subject |
authorize | an Authorized handle carrying the subject |
object | a BoundKind handle carrying the object kind |
readsObject | whether the permission needs the row before it can decide |
matrix, version, schema | the 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'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;
}
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;
}
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;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;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"
];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
pickAllowedFields
nested paths
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?"}'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
serialize
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').; // -> trueWhich of the two documents an Access holds is wanted.
export type = 'full' | 'reduced';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 [];
}
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
the collision check
routing
the view
the members
the deny overlay
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')?...; // -> 1A 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;
}
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
applyDenyOverlay refuses at apply time, naming the key
the schema obligation is scoped to the vetoable keys
the structural gate runs over the contribution
a valid overlay narrows
an overlay deny that cannot be evaluated refuses
the result is still a document
the authoring party can run it against a published subset
applyDenyOverlay
the generated cases reach the behaviour they claim to cover
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'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'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[];
}
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'withsubject.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
undeterminedwith 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
diffMatrix
a widening
a narrowing
what it refuses to classify
the paths a decision reads
the monotonicity the report rests on
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']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;
};
}
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;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
findingsOf
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]?..; // -> []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[];
}
Why a permission’s access widened.
export type = 'permission-added' | 'allow-branch-added' | 'deny-branch-removed' | 'field-list-widened';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[];
}
Why a permission’s access narrowed.
export type = 'permission-removed' | 'allow-branch-removed' | 'deny-branch-added' | 'field-list-narrowed';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;
}
Why a change could not be classified as a widening or a narrowing.
rule-edited: a rule maintains its author-suppliedidbut 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 waydiffMatrixcannot 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';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[];
};
}
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 [];
}
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.
| Type | What it holds |
|---|---|
Permission | one `${object}.${action}` key, its allow rules, its deny rules and its field rules |
Rule | an id and a list of AND-ed conditions |
Condition | one comparison; The matrix: your rules as JSON has the operator table |
FieldRules | the fields list, then one key per field carrying a write-axis config, so a field named fields cannot be configured |
FieldConfig | targets allow-lists the proposed value and transitions is a state machine over the current one; see Fields |
MatrixSchema | optional for a producer, binding where present, per kind: a kind objects omits is unchecked |
ObjectSchema | a kind’s field types, and the relations naming the kinds it points at, one hop; no condition compares a relation |
FieldType | a flat string any language emits by reflection, where ? marks a field that may be absent |
Instant | an 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'One permission: allows and/or denies, plus optional field rules.
interface Permission {
: string;
: ;
: string;
?: readonly Rule[];
?: readonly Rule[];
?: FieldRules;
?: 'public' | 'internal';
}
One activation rule for a permission: an AND-ed set of conditions.
interface Rule {
?: string;
?: readonly [];
}
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]?..; // -> []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']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[];
}
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[];
}
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;
}
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[]>;
};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;One entry of a field list: *, a path of T, or ! and a path of T.
export 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[];
};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 ]: <[], >;
};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 ? [] : <, > : [];
};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>>;
}
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, >>;
}
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 = | `${}[]` | `${}?` | `${}[]?`;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';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;One action-level decision.
| Field | What it carries |
|---|---|
key | the permission key the query asked about |
allowed | the gate |
reason | output only; The Decision object every check returns says which reason carries which |
rule | output only; the id of the rule that decided |
missing | output 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')?...; // -> 1Why 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';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.
| Field | What it carries |
|---|---|
allowed | the action is allowed and every decided field is |
action | the action-level Decision alone |
fields | every field name mapped to a FieldState; compare against 'allowed', since comparing against 'denied' treats an unevaluable field as writable |
reasons | every field name mapped to the FieldReason that settled it |
interface FieldDecision {
: boolean;
: Decision;
: <string, >;
: <string, >;
}
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';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';The namespaced evaluation context.
The entry points assemble an EvaluationContext, and now settles to one epoch
per call.
interface EvaluationContext {
: <string, unknown>;
?: <string, unknown>;
?: ;
}
A subject for a single call: the actor, a plain object of attributes.
export type = <string, unknown>;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>>;The kind of object an action applies to. A string key in the matrix.
export type = string;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 type | What it does |
|---|---|
allow, deny | add one rule to the action named |
allowEach, denyEach | write one rule across several actions, one ordinary permission each |
id | name the rule the previous allow or deny wrote, for a decision’s rule |
fields, visibility | mark the action last declared, and raise AclConfigError after a batch |
Vocab | what this kind may declare |
Act | what 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>(: <, >): <, , , >;
(: ): <, , , >;
}
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'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 ? [] : : ;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']>;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 : `${}.${}`;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.
| Type | What it is |
|---|---|
Cond | an opaque condition tree, which build() flattens to a rule per or branch |
Operand | a comparand shaped like a namespaced path is checked as one, and any other string is a literal |
Paths | the subject. and object. paths one bound type offers |
Valid | a 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;
}
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;
}
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, , > : ;The namespaced paths one level deep into T, prefixed with P.
export type <, extends string> = {
[ in keyof & string]: `${}.${}`;
}[keyof & string];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';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
a contract that did not move
a producer that removed a published key
a producer that changed a decision
a producer that only added a key
the decisions a replay compares
the diffMatrix seam
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'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
a contract that did not move
a producer that removed a published key
a producer that changed a decision
a producer that only added a key
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'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
describeContractDrift
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'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 ?: <>;
}
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;
}
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 ?: ;
}
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;
}
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) => ;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
fixtureClock
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').; // -> falseWhat a holder reports about its own freshness, as a test states it.
interface FixtureClockOptions {
?: ;
?: number;
}
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;
}
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
assertAllowed
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'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
assertRefused
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').; // -> falseReturns 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
assertFieldState
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'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
explainDecision
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'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
explainFieldDecision
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'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'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'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.
| Error | Raised when |
|---|---|
InvalidMatrixError | the envelope carries no permissions array, or a version of the wrong type |
InvalidSchemaError | the schema’s own shape is wrong; where locates it inside the document |
InvalidPermissionError | a permission’s object, action, rules or fields is malformed |
InvalidRuleError | a rule is not an object, or its when is not an array |
InvalidConditionError | a condition’s shape, namespace, path depth or operator pairing is unusable |
KeyMismatchError | key is not exactly `${object}.${action}` |
DuplicatePermissionError | two permissions share a key |
DuplicateRuleIdError | two rules of one permission carry the same id, counting both sides |
AmbiguousRuleIdError | p.id() follows an allow or deny that flattened to other than one rule |
UnknownFieldError | a condition names a field the schema does not declare |
FieldTypeMismatchError | contains runs against a non-array, or a literal is of the wrong type |
DenyWithoutBaselineError | a !path entry has no * or listed ancestor to subtract from |
TargetsTransitionsConflictError | one field configures both targets and transitions |
UnknownObjectKeyError | a query on the open path names an unknown object kind; parseMatrix fails closed |
UnknownPermissionError | a query on the open path names an unknown key; parseMatrix answers unknown-action |
OriginCollisionError | two origins claim one key, which federatedPolicies raises while composing |
UnvetoablePermissionError | an overlay names a key the target does not list as vetoable |
MissingVetoSchemaError | a vetoable key’s object kind has no schema.objects entry |
UnpublishedVetoableError | serialize reduces away a vetoable key the contract would not ship |
MissingFreshnessBudgetError | a holder reports fetchedAt against a document stating no maxStale |
InvalidFreshnessError | fetchedAt 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 ; // -> trueA matrix envelope whose own shape is not the canonical one.
export declare class extends {
constructor(: string);
}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);
}A permission node whose own shape is not the canonical one.
export declare class extends {
readonly : string;
readonly : string;
constructor(: string, : string, : string);
}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);
}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);
}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);
}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);
}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'),
)
.(),
).();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'),
)
.(),
).();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);
}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);
}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);
}Both targets and transitions on one field.
export declare class extends {
constructor(: string);
}An unknown object kind at runtime on a typed (local) matrix.
export declare class extends {
readonly : ;
constructor(: );
}An unknown permission key at runtime on a typed (local) matrix.
export declare class extends {
readonly : string;
constructor(: string);
}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);
}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);
}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, : );
}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);
}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();
}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);
}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.