/** * rigor/protocol-snapshot-lifecycle.test.js — snapshot lifecycle protocol * as a stateful rigor.object campaign with gated invariants * (before/after/between/when/unless). * * Lifecycle protocol (pinned from engine probes): * - add/remove/setConfig: allowed while LIVE. * - enable() (enableCondensedSnapshot): freezes RELATION writes * (add/remove now THROW) but queries keep working; configs stay * mutable; double-enable is idempotent. * - restore(): serializes the condensed graph and rebuilds a fresh * read-only arbiter; relation writes keep throwing; configs remain * mutable and are NOT part of the snapshot; checks reflect the * snapshot values (16-bit quantized). * - between enable() and restore(): queries serve live values exactly. * - after restore(): queries serve quantized snapshot values. * - double-restore (snapshot-of-snapshot) is supported. * * The wrapper mirrors the protocol independently (flag + tuple mirror); * every method runs the REAL engine and rethrows engine errors, so the * invariants compare engine behavior against the mirror's prediction. */ 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 TOL = 0.5 / 65535 + 1e-9; const NODES = 6; const nodeKey = (id) => (id < 2 ? `u:${id}` : id === 2 ? 'g:0' : `doc:${id - 3}`); function mirrorCheck(tuples, configs, src, rel, dst) { const srcKey = nodeKey(src); const dstKey = nodeKey(dst); const key = (s, r, d) => `${s}|${r}|${d}`; const p = (s, r, d) => tuples.get(key(s, r, d)) ?? 0; if (rel === 'can_read') { return p(srcKey, 'owner', dstKey); } if (rel === 'can_access') { let best = 0; for (let m = 0; m < NODES; m++) { best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey))); } return best; } return 0; } function makeWrapper() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); const tuples = new Map(); const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`; const ops = []; const wrapper = { flag: 'live', buffer: null, frozen: null, engine: arbiter, ops, add(src, rel, dst, p) { const key = nodeKey(src); const dstKey = nodeKey(dst); arbiter.addRelation(key, rel, dstKey, { possibility: p }); // throws when frozen tuples.set(tupleKey(key, rel, dstKey), p); return { ok: true }; }, remove(src, rel, dst) { arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst)); // throws when frozen tuples.delete(tupleKey(nodeKey(src), rel, nodeKey(dst))); return { ok: true }; }, setConfig(which) { if (which === 'chain') { arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); } else { arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); } return { ok: true }; }, enable() { if (wrapper.flag === 'restored') { throw new Error('enable after restore is out of contract'); } arbiter.enableCondensedSnapshot(); wrapper.flag = 'enabled'; return { ok: true }; }, restore() { const buf = serializeArbiterSnapshot(arbiter); // throws when not enabled wrapper.frozen = new Map(tuples); wrapper.buffer = buf; const next = ArbiterSnapshot.fromSnapshotBinary(buf, {}, () => new Arbiter()); wrapper.engine = next; wrapper.flag = 'restored'; return { ok: true, bytes: buf.byteLength }; }, check(src, rel, dst) { const result = wrapper.engine.check(nodeKey(src), rel, nodeKey(dst)); const expected = mirrorCheck(tuples, null, src, rel, dst); return { engine: result.possibility, expected, flag: wrapper.flag }; }, clone() { const fresh = makeWrapper(); for (const op of ops) { const [name, ...args] = op; if (name === 'check') fresh.check(...args); else fresh[name](...args); } return fresh; } }; const record = (name, fn) => { return (...args) => { const result = fn(...args); ops.push([name, ...args]); return result; }; }; wrapper.add = record('add', wrapper.add); wrapper.remove = record('remove', wrapper.remove); wrapper.setConfig = record('setConfig', wrapper.setConfig); wrapper.enable = record('enable', wrapper.enable); wrapper.restore = record('restore', wrapper.restore); wrapper.check = record('check', wrapper.check); return wrapper; } const nodeArg = rigor.gen.int(0, NODES - 1); const relArg = rigor.gen.enum(['owner', 'member_of', 'reads']); const checkRelArg = rigor.gen.enum(['can_read', 'can_access']); const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); }, rigor.args(nodeArg, relArg, nodeArg, rigor.gen.oneOf([0.1, 0.3, 0.7, 0.9, 0.333]))), rigor.method('remove', function (src, rel, dst) { return this.remove(src, rel, dst); }, rigor.args(nodeArg, relArg, nodeArg)), rigor.method('setConfig', function (which) { return this.setConfig(which); }, rigor.args(rigor.gen.enum(['direct', 'chain']))), rigor.method('enable', function () { return this.enable(); }), rigor.method('restore', function () { return this.restore(); }), rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); }, rigor.args(rigor.gen.int(0, 1), checkRelArg, rigor.gen.oneOf([3, 4, 5]))) ])], rigor.crucible([ rigor.after('graph.enable', ({ objects }) => objects.graph.flag === 'enabled'), rigor.after('graph.restore', ({ objects }) => objects.graph.flag === 'restored' && objects.graph.frozen !== null), rigor.before('graph.enable', ({ objects }) => objects.graph.flag !== 'restored'), rigor.between('graph.enable', 'graph.restore', ({ action, objects, actual }) => { if (action !== 'graph.check') return true; return actual.engine === actual.expected && actual.flag === 'enabled'; }), rigor.when(({ action, objects }) => action === 'graph.add' || action === 'graph.remove', ({ action, objects, error }) => { const frozen = objects.graph.flag !== 'live'; const threw = error !== null; if (frozen) return threw === true; return threw === false; }), rigor.when(({ action }) => action === 'graph.check', ({ objects, actual }) => { if (actual.flag === 'restored') { return Math.abs(actual.engine - actual.expected) <= TOL; } return actual.engine === actual.expected; }), rigor.unless(({ action }) => action === 'graph.check', ({ action, objects, error }) => { if (action === 'graph.add' || action === 'graph.remove') return true; if (action === 'graph.restore') return objects.graph.flag !== 'live' || error !== null; return true; }), rigor.after('graph.setConfig', ({ objects, error }) => error === null || objects.graph.flag === 'restored' ? true : false), rigor.beforeStep(0, ({ objects, calls }) => { const g = objects.graph; return g.tuples.size === 0 && g.flag === 'live' && calls.length === 0; }), rigor.afterStep(2, ({ objects, calls }) => { const g = objects.graph; if (g.flag === 'restored') return true; const engineKeys = new Set(); for (const r of g.engine.relations) { const srcKey = g.engine.keyByNodeId.get(r.src); const dstKey = g.engine.keyByNodeId.get(r.dst); if (srcKey !== undefined && dstKey !== undefined) { engineKeys.add(`${srcKey}|${r.rel}|${dstKey}`); } } for (const k of g.tuples.keys()) { if (!engineKeys.has(k)) return false; } return g.tuples.size === engineKeys.size; }), rigor.beforeStep(4, ({ objects, calls }) => { const g = objects.graph; if (g.flag === 'live' || g.flag === 'enabled') { return g.tuples.size === g.engine.relations.length; } return true; }) ]) ).run({ effort: 400, seed: 'snapshot-lifecycle-protocol', maxTraceLength: 24 }); describe('Snapshot lifecycle protocol (rigor gated invariants)', () => { it('every random lifecycle sequence honors the protocol', () => { const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `protocol violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 5).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });