ed34df4474
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
position is now EXPANDED from the current node — the rule's base edges'
destinations, filtered by its defeaters/requirements — and traversal
continues from each discovered node. Adds _expandRuleFromSrc / direct /
logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
entity IS the object parameter (trusted(other) inside peer_trusted(user,
other)); previously only subject-var unary calls (_subjectAsObject) were
handled, so object-var unary defeaters never fired.
Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
DecisionCache rule cache now stamp entries with the graph version and treat
any mismatch as a miss — graph mutations can no longer serve stale
chain/authorization results.
Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
JSON.stringify — no string allocation or serialization on the chain-cache
hot path. Composite keys stay structured strings because the direct-check
cache pattern-invalidates by relation ID.
Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
!error && !errorMessage) never saw fn throws — vacuous. Migrated to
({ actual }) => actual !== undefined, which fails on any thrown violation
while passing legitimate null-skips. The migration immediately surfaced
two latent bugs, now fixed:
* node-manager/graph-indices skip paths returned bare undefined (falsy
sentinel) — return { skipped: true }.
* complex-graph-values-crucible expiry section rewrote values equal to the
mutation loop's last write; the engine (by design) keeps the old
timestamp on same-value rewrites so the pre-expiry grant never
materialized. Now writes guaranteed-different values.
217 lines
8.0 KiB
JavaScript
217 lines
8.0 KiB
JavaScript
/**
|
|
* rigor/cache-parity.test.js — js-rigor property tests for cache
|
|
* correctness under mutation.
|
|
*
|
|
* Properties verified:
|
|
*
|
|
* - CACHE ON/OFF PARITY: two identical arbiters — one with caching
|
|
* enabled, one with `disableCaching: true` — driven through IDENTICAL
|
|
* random mutation sequences (adds/removes across direct, override and
|
|
* chain configs). After EVERY mutation, every check must agree
|
|
* EXACTLY. Any divergence means a stale direct-check, rule-result or
|
|
* chain cache survived a mutation.
|
|
* - TTL CONTRACT: with an injected fake clock, cached entries expire at
|
|
* the configured TTL — an entry read after its TTL is reported
|
|
* expired, never hit.
|
|
* - OVERRIDE + CACHE: relation-override configs (can_read → viewer)
|
|
* participate in invalidation — mutations on the base relation flip
|
|
* cached override checks immediately (regression for the stale-grant
|
|
* bug found by the model-based campaign).
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
const EPS = 1e-9;
|
|
const POS = [0, 0.25, 0.5, 0.75, 1];
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function buildArbiter({ caching }) {
|
|
const arbiter = new Arbiter({
|
|
disableCaching: !caching,
|
|
disableChainCaching: !caching,
|
|
disableDirectCaching: !caching
|
|
});
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('group:eng', 'group');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
|
arbiter.setRelationConfig('can_access', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'viewer', direction: 'out' }
|
|
]
|
|
});
|
|
return arbiter;
|
|
}
|
|
|
|
const RELS = ['viewer', 'member_of'];
|
|
const RELATIONS = [
|
|
['add', 'user:alice', 'viewer', 'doc:1'],
|
|
['add', 'user:alice', 'member_of', 'group:eng'],
|
|
['add', 'group:eng', 'viewer', 'doc:1'],
|
|
['remove', 'user:alice', 'viewer', 'doc:1'],
|
|
['remove', 'user:alice', 'member_of', 'group:eng'],
|
|
['remove', 'group:eng', 'viewer', 'doc:1']
|
|
];
|
|
|
|
function applyOp(arbiter, op, p) {
|
|
const [kind, src, rel, dst] = op;
|
|
if (kind === 'add') {
|
|
arbiter.addRelation(src, rel, dst, { possibility: p });
|
|
} else {
|
|
arbiter.removeRelation(src, rel, dst);
|
|
}
|
|
}
|
|
|
|
function allChecks(arbiter) {
|
|
const results = {};
|
|
for (const rel of ['can_read', 'can_access']) {
|
|
results[rel] = arbiter.check('user:alice', rel, 'doc:1').possibility;
|
|
}
|
|
return results;
|
|
}
|
|
|
|
describe('Cache correctness under mutation (rigor)', () => {
|
|
it('CACHE ON/OFF PARITY: cached and uncached arbiters never diverge through mutation sequences', async () => {
|
|
async function check(ops) {
|
|
const cached = buildArbiter({ caching: true });
|
|
const uncached = buildArbiter({ caching: false });
|
|
|
|
for (const [kind, src, rel, dst, p] of ops) {
|
|
applyOp(cached, [kind, src, rel, dst], p);
|
|
applyOp(uncached, [kind, src, rel, dst], p);
|
|
|
|
const c = allChecks(cached);
|
|
const u = allChecks(uncached);
|
|
for (const rel of Object.keys(c)) {
|
|
if (Math.abs(c[rel] - u[rel]) > EPS) {
|
|
fail(`diverged on ${rel} after ${kind}(${src},${rel},${dst},${p}): cached=${c[rel]}, uncached=${u[rel]}`);
|
|
}
|
|
}
|
|
}
|
|
return { ops: ops.length };
|
|
}
|
|
|
|
const opGen = rigor.gen.array(
|
|
rigor.gen.tuple(
|
|
rigor.gen.oneOf([0, 1, 2, 3, 4, 5]), // index into RELATIONS
|
|
rigor.gen.oneOf(POS)
|
|
),
|
|
1, 12
|
|
).map((pairs) => pairs.map(([idx, p]) => [...RELATIONS[idx], p]));
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(opGen))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('cache-parity', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 600, seed: 'cache-onoff-parity' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cache-parity');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `CACHE PARITY violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('OVERRIDE + CACHE: base-relation mutations immediately flip cached override checks', async () => {
|
|
async function check({ p1, p2 }) {
|
|
const arbiter = buildArbiter({ caching: true });
|
|
|
|
// Warm the override-path cache with a grant
|
|
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p1 });
|
|
const granted = arbiter.check('user:alice', 'can_read', 'doc:1');
|
|
if (Math.abs(granted.possibility - p1) > EPS) {
|
|
fail(`setup: expected ${p1}, got ${granted.possibility}`);
|
|
}
|
|
|
|
// Mutate the BASE relation — the cached override check must flip NOW
|
|
arbiter.removeRelation('user:alice', 'viewer', 'doc:1');
|
|
const revoked = arbiter.check('user:alice', 'can_read', 'doc:1');
|
|
if (revoked.possibility !== 0) {
|
|
fail(`override grant survived base removal: ${revoked.possibility}`);
|
|
}
|
|
|
|
// Re-add with a different possibility — must flip again immediately
|
|
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p2 });
|
|
const regranted = arbiter.check('user:alice', 'can_read', 'doc:1');
|
|
if (Math.abs(regranted.possibility - p2) > EPS) {
|
|
fail(`override grant did not update to ${p2}: ${regranted.possibility}`);
|
|
}
|
|
return { granted, revoked, regranted };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
p1: rigor.gen.oneOf(POS),
|
|
p2: rigor.gen.oneOf(POS)
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('override-cache-fresh', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 400, seed: 'cache-override-freshness' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-cache-fresh');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `OVERRIDE CACHE violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('TTL CONTRACT: injected clock reports entries expired after the TTL window', async () => {
|
|
async function check({ ttl, delay }) {
|
|
const arbiter = new Arbiter({ directCheckCacheTTL: ttl });
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
arbiter.addRelation('user:alice', 'can_read', 'doc:1', { possibility: 1 });
|
|
|
|
let now = 1000;
|
|
arbiter.decisionCache.clock = () => now;
|
|
|
|
arbiter.check('user:alice', 'can_read', 'doc:1');
|
|
now += delay;
|
|
|
|
const cache = arbiter.decisionCache;
|
|
const key = arbiter.authChecker._getDirectCheckCacheKey('user:alice', 'can_read', 'doc:1');
|
|
const [result, status] = cache.peekDirect(key);
|
|
const expectedStatus = delay >= ttl ? 'expired' : 'hit';
|
|
if (status !== expectedStatus) {
|
|
fail(`ttl=${ttl}, delay=${delay}: expected '${expectedStatus}', got '${status}'`);
|
|
}
|
|
if (expectedStatus === 'hit' && result?.possibility !== 1) {
|
|
fail(`hit entry lost its result: ${JSON.stringify(result)}`);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
ttl: rigor.gen.oneOf([100, 500, 1000]),
|
|
delay: rigor.gen.oneOf([0, 50, 100, 400, 600, 1500])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('ttl-contract', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'cache-ttl-contract' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttl-contract');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `TTL CONTRACT violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|