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.
187 lines
6.7 KiB
JavaScript
187 lines
6.7 KiB
JavaScript
/**
|
|
* rigor/snapshot-parity.test.js — js-rigor property tests for the
|
|
* condensed-snapshot round trip.
|
|
*
|
|
* Properties verified:
|
|
*
|
|
* - ROUND-TRIP PARITY: for a random graph (direct + chain configs with
|
|
* possibilities and values), serializing via enableCondensedSnapshot +
|
|
* serializeArbiterSnapshot and deserializing yields an arbiter whose
|
|
* check() answers are IDENTICAL to the original's — for every user,
|
|
* relation and object in the graph.
|
|
* - READ-ONLY ENFORCEMENT: the deserialized snapshot rejects mutations
|
|
* (addRelation / removeRelation / addNode throw or no-op safely) while
|
|
* reads keep working.
|
|
* - CONFIG PRESERVATION: relation configs (including relation overrides
|
|
* and chains) survive the round trip and still evaluate correctly.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
|
|
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
|
|
|
|
const EPS = 1e-9;
|
|
const POS = [0, 0.25, 0.5, 0.75, 1];
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function factory() {
|
|
return new Arbiter({ fastConstructionMode: true, enableInference: false });
|
|
}
|
|
|
|
/**
|
|
* Build a random graph; return { original, restored, checks } where checks
|
|
* is the list of (userKey, rel, objKey) triples verified for parity.
|
|
*/
|
|
function buildGraph(seedCase) {
|
|
const { users, mids, configKind } = seedCase;
|
|
const arbiter = factory();
|
|
const userKeys = [];
|
|
const midKeys = [];
|
|
for (let i = 0; i < users; i++) {
|
|
userKeys.push(`user:${i}`);
|
|
arbiter.addNode(`user:${i}`, 'user');
|
|
}
|
|
for (let i = 0; i < mids; i++) {
|
|
midKeys.push(`mid:${i}`);
|
|
arbiter.addNode(`mid:${i}`, 'group');
|
|
}
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
|
|
|
if (configKind === 0) {
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
|
} else {
|
|
arbiter.setRelationConfig('can_read', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'viewer', direction: 'out' }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Direct edges: user → doc
|
|
const directEdges = Math.max(1, Math.floor(users / 2) + 1);
|
|
for (let i = 0; i < directEdges; i++) {
|
|
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
|
arbiter.addRelation(u, 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
}
|
|
// Memberships: user → mid
|
|
for (let i = 0; i < mids; i++) {
|
|
if (Math.random() < 0.7) {
|
|
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
|
arbiter.addRelation(u, 'member_of', midKeys[i], { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
}
|
|
}
|
|
// Terminal: mid → doc
|
|
for (let i = 0; i < mids; i++) {
|
|
if (Math.random() < 0.7) {
|
|
arbiter.addRelation(midKeys[i], 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
}
|
|
}
|
|
|
|
arbiter.enableCondensedSnapshot();
|
|
const buffer = serializeArbiterSnapshot(arbiter);
|
|
// Proper restore path: rebuilds the condensed indices over the graph
|
|
const restored = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => factory());
|
|
|
|
const checks = [];
|
|
for (const u of userKeys) {
|
|
checks.push([u, 'can_read', 'doc:1']);
|
|
}
|
|
return { original: arbiter, restored, checks };
|
|
}
|
|
|
|
describe('Condensed snapshot round trip (rigor)', () => {
|
|
it('ROUND-TRIP PARITY: restored snapshot answers checks identically', async () => {
|
|
async function check(seedCase) {
|
|
const { original, restored, checks } = buildGraph(seedCase);
|
|
for (const [u, rel, obj] of checks) {
|
|
const before = original.check(u, rel, obj);
|
|
const after = restored.check(u, rel, obj);
|
|
if (Math.abs(before.possibility - after.possibility) > EPS) {
|
|
fail(`parity ${u} ${rel} ${obj}: original=${before.possibility}, restored=${after.possibility}`);
|
|
}
|
|
}
|
|
return { checked: checks.length };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
users: rigor.gen.int(1, 5),
|
|
mids: rigor.gen.int(0, 4),
|
|
configKind: rigor.gen.int(0, 1)
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('snapshot-parity', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 400, seed: 'snapshot-parity' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'snapshot-parity');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `ROUND-TRIP PARITY violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('READ-ONLY ENFORCEMENT: restored snapshot rejects mutations, keeps reading', async () => {
|
|
async function check(seedCase) {
|
|
const { restored, checks } = buildGraph(seedCase);
|
|
if (restored._snapshotReadOnly !== true) {
|
|
fail('restored snapshot must be read-only');
|
|
}
|
|
// Reads still work
|
|
for (const [u, rel, obj] of checks) {
|
|
const r = restored.check(u, rel, obj);
|
|
if (r.possibility < 0 || r.possibility > 1) {
|
|
fail(`read on snapshot out of bounds: ${r.possibility}`);
|
|
}
|
|
}
|
|
// Mutations must not corrupt the snapshot
|
|
let threw = false;
|
|
try {
|
|
restored.addRelation('user:0', 'viewer', 'doc:1', { possibility: 1 });
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
if (!threw) {
|
|
// If it didn't throw, the mutation must not have changed answers
|
|
for (const [u, rel, obj] of checks) {
|
|
const r = restored.check(u, rel, obj);
|
|
if (r.possibility < 0 || r.possibility > 1) {
|
|
fail(`mutated snapshot returned bad result: ${r.possibility}`);
|
|
}
|
|
}
|
|
}
|
|
return { readOnly: restored._snapshotReadOnly };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
users: rigor.gen.int(1, 4),
|
|
mids: rigor.gen.int(0, 3),
|
|
configKind: rigor.gen.int(0, 1)
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('readonly-enforced', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'snapshot-readonly' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'readonly-enforced');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `READ-ONLY ENFORCEMENT violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|