Three related findings from the nervous-item audit: 1. TTL contract pinned (ttl-contract.test.js + README): TTL is a VALUE-FRESHNESS gate, not an access-expiry mechanism. Direct grants are timeless; expired values deny comparators and drop from collected values. The direct fast path collected values WITHOUT the TTL gate (comparators skipped expired relations, the direct path did not) — now gated identically. 2. ChainRule cache served pinned-clock callers (ChainRule.js): a chain result captured at one time (with then-fresh values) was served to callers asking about another time. The chain cache now bypasses reads AND writes when options.now is pinned, matching the rule-result cache contract. 3. Decision caches bundled stale values (AuthorizationChecker.js): the direct-check cache stored collectedValues alongside the timeless decision; an unpinned caller past wall-clock expiry got the stale value. Value-carrying results are now never cached (the decision is timeless, the values are not). The rule-result cache is unchanged — it serves snapshots under explicit write-invalidation (its own contract, asserted by cache-invalidation tests). Rigor 250/250, full suite 852/790/0.
@arbiter/core
Possibilistic authorization engine: graph indices, relation/reachability queries, rule evaluation over a DSL, and lossless condensed snapshots.
Why
Authorization policies live on a graph: users hold relations to objects, groups, and roles, and rules derive decisions from those relations. @arbiter/core answers one question — may user U perform relation R on object O? — with a possibility ranking, not a boolean. Callers supply evidence with strengths; the engine fuses it through rule operators (union, intersection, exclusion, defeasible, chain, multi-hop, relational comparator) and returns the strongest derivable possibility, the reliability of the decision, and the validity provenance of the ranking.
The engine does not police caller-supplied evidence: you supply validated relation strengths and proofs; the engine derives and fuses. It is a library, not a service — no storage, no transport, no policy source of truth.
Install
npm install @arbiter/core
The package is ESM-only and requires Node 22 or newer.
Quick Start
import { Arbiter } from '@arbiter/core';
const arbiter = new Arbiter();
// Nodes: an id and a type.
arbiter.addNode('user:1', 'user');
arbiter.addNode('doc:9', 'doc');
// Relations: a config says how decisions for that relation are derived.
arbiter.setRelationConfig('owner', { type: 'direct' });
arbiter.addRelation('user:1', 'owner', 'doc:9', { possibility: 0.9 });
// The core question.
const result = arbiter.check('user:1', 'owner', 'doc:9');
// { possibility: 0.9, reliability: 1,
// validity: { label: 'heuristic', operator: 'identity', regime: 'arbitrary' },
// reason: 'direct_match' }
Concepts
Possibility, not probability
Every check returns a possibility in [0, 1] — a maxitive ranking supplied by the caller through relation strengths. 1 means derivable, 0 means not derivable. Fusions take the maximum under union, and enforce thresholds and conflicts under intersection, exclusion, and defeasible operators.
Result shape
Every check result carries the same four fields:
| Field | Type | Meaning |
|---|---|---|
possibility |
number in [0,1] |
Derived possibility of the decision |
reliability |
number in [0,1] |
Reliability of the decision; always 0 for denials |
validity |
object |
Validity provenance: label, operator, regime (minimal form) |
reason |
string |
Outcome class: direct_match, no_relation, threshold_not_met, missing_node, no_config, cycle, ... |
Denied decisions never leak a source's reliability. includeMeta: true adds meta with the full provenance (allow/deny blocks, rule traces, thresholds) and the full validity block (sources, conflictMass, validifiedPossibility, nonMaxitive).
The caller owns evidence and time
- Evidence: relation strengths and validity labels come from the caller. The engine derives and fuses but never judges.
- Time: TTL-gated evidence uses the caller's clock. Pass
{ now }(orpartialGraph.now) to pin the temporal context; a rerun with the same context reproduces the decision.
TTL is a value-freshness gate, not an access-expiry mechanism. valueManager.setTTL(relation, ms) controls how long a relation's value stays fresh for value-consuming paths (relational comparators, chain/multi-hop value collection): once age > TTL the value is treated as absent, which denies the comparator and drops the value from collected values. Possibility-based grants — a direct relation's allow/deny, union disjunction, chain traversal — are timeless: an edge grants regardless of its age. If you need access to expire, express it in the policy (e.g. a comparator over a time-carrying value), not via setTTL.
Clocks and caches. The decision caches (direct-check, rule-result, chain) are keyed on the meta-less decision form only: includeMeta callers and pinned-clock callers always get a fresh evaluation, and value-carrying results are never cached (values are TTL-gated evidence). Unpinned callers share wall-clock cache entries — the correct default for timeless decisions. Pin { now } whenever the answer depends on when you ask; every cache bypasses itself for pinned-clock callers, so a rerun with the same { now } reproduces the decision exactly.
Overlays and partial graphs
check accepts a PartialGraphContext overlay. Overlay relations take precedence over the base graph, letting you answer "what changes if this evidence appears?" without mutating the graph.
API
The public surface is the Arbiter class:
- Graph:
addNode,addRelation,removeRelation,setRelationConfig,getNodeData,resolveNodeId,resolveKey - Check:
check(userKey, relation, objectKey, options),explain(enrichesmeta),binarymode (fast path, marks resultsbinary: true) - Reachability:
isReachable,getReachableNodes,getReachingNodes,shortestPathLength,estimateGraphDistance(isReachablereturnsnullwhen no PLTC index is available — a signal to defer to rule evaluation) - Snapshots:
enableCondensedSnapshot,toSnapshotBinary,Arbiter.fromSnapshotBinary(lossless: carries relation metadata, TTLs, and validity; malformed buffers fail fast with clean errors) - Value context:
valueManager(TTL-gated evidence),getSituationTree,monteCarloWalk
Check options:
| Option | Type | Default | Meaning |
|---|---|---|---|
includeMeta |
boolean |
false |
Attach full provenance in meta |
explain |
boolean |
false |
Enrich meta with the evaluation trace |
binary |
boolean |
false |
Binary fast path; results marked binary: true |
now |
number |
engine clock | Pinned temporal context for TTL gates |
partialGraphContext |
PartialGraphContext |
none | Overlay taking precedence over the base graph |
Development
npm install # install dependencies
npm test # full suite
npm run test:rigor # js-rigor campaigns (property-based + fuzzing)
npm run benchmark # compare against the committed perf baseline
npm run benchmark:save # record a new perf baseline
CI runs the full suite, the rigor campaigns, and the benchmark on every push; v* tags additionally publish the package to the @arbiter registry.
Design Notes
- Possibility is a maxitive ranking. Fusions preserve the weakest validity label under arbitrary dependence; conjunctive operators surface conflict mass instead of silently averaging it.
- One evaluation path. The rule engine has a single, uncompiled evaluator — parity between normal, binary, partial-graph, and snapshot-restored checks is structural, and the rigor campaigns enforce it.
- Snapshots are a trust boundary. Restoring untrusted bytes must produce a clean, bounded error — never a hang, a crash, or silently corrupted data. The deserializer cross-validates every count field before use.