2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/dsl-mutation-parity.test.js — js-rigor property tests that a
|
|
|
|
|
* DSL-compiled arbiter and an equivalent hand-written arbiter stay in
|
|
|
|
|
* lock-step through identical MUTATION SEQUENCES.
|
|
|
|
|
*
|
|
|
|
|
* Properties verified:
|
|
|
|
|
*
|
|
|
|
|
* - SEQUENCE PARITY: starting from the same graph, apply the same random
|
|
|
|
|
* add/remove sequence to both the DSL-compiled and the hand-written
|
|
|
|
|
* arbiter; after EVERY mutation the check() answers agree exactly.
|
|
|
|
|
* This exercises compiled configs (dependency indexes, caches,
|
|
|
|
|
* invalidation) under mutation, not just static evaluation.
|
|
|
|
|
* - GRANT/REVOKE CYCLES: interleaved add/remove of the same tuple keeps
|
|
|
|
|
* both arbiters consistent (no stale compiled state).
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { rigor } from '@rigor/core';
|
|
|
|
|
import { Arbiter } from '../../src/index.js';
|
2026-08-03 09:17:33 -07:00
|
|
|
import { DSLCompiler } from '@arbiter/evidence-dsl/DSLCompiler';
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const EPS = 1e-9;
|
|
|
|
|
const POS = [0, 0.25, 0.5, 0.75, 1];
|
|
|
|
|
|
|
|
|
|
function fail(message) {
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DSL = `
|
|
|
|
|
definition Doc { id: string }
|
|
|
|
|
definition Dept { id: string }
|
|
|
|
|
fact owns(user: User, doc: Doc)
|
|
|
|
|
fact works_in(user: User, dept: Dept)
|
|
|
|
|
fact has_access(dept: Dept, doc: Doc)
|
|
|
|
|
evidence can_read(user: User, doc: Doc) { owns(user, doc) }
|
|
|
|
|
evidence can_access(user: User, doc: Doc) { works_in(user, *d) { has_access(d, doc) } }
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
function buildCompiledArbiter() {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('dept:eng', 'group');
|
|
|
|
|
arbiter.addNode('doc:1', 'doc');
|
|
|
|
|
const compiler = new DSLCompiler(arbiter);
|
|
|
|
|
const result = compiler.compile(DSL, 'mutation-parity');
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new Error(`DSL compile failed: ${result.errors.join('; ')}`);
|
|
|
|
|
}
|
|
|
|
|
return arbiter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildManualArbiter() {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('dept:eng', 'group');
|
|
|
|
|
arbiter.addNode('doc:1', 'doc');
|
|
|
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owns' });
|
|
|
|
|
arbiter.setRelationConfig('can_access', {
|
|
|
|
|
type: 'chain',
|
|
|
|
|
steps: [
|
|
|
|
|
{ relation: 'works_in', direction: 'out' },
|
|
|
|
|
{ relation: 'has_access', direction: 'out' }
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
return arbiter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RELATIONS = ['owns', 'works_in', 'has_access'];
|
|
|
|
|
const SUBJECTS = ['user:alice', 'dept:eng'];
|
|
|
|
|
|
|
|
|
|
function applyMutation(arbiter, op) {
|
|
|
|
|
const [kind, src, rel, dst, p] = op;
|
|
|
|
|
if (kind === 'add') {
|
|
|
|
|
arbiter.addRelation(src, rel, dst, { possibility: p });
|
|
|
|
|
} else {
|
|
|
|
|
arbiter.removeRelation(src, rel, dst);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('DSL-compiled vs hand-written parity under mutation (rigor)', () => {
|
|
|
|
|
it('SEQUENCE PARITY: identical mutation sequences keep compiled and manual arbiters in lock-step', async () => {
|
|
|
|
|
async function check(operations) {
|
|
|
|
|
const compiled = buildCompiledArbiter();
|
|
|
|
|
const manual = buildManualArbiter();
|
|
|
|
|
|
|
|
|
|
// Same starting graph on both
|
|
|
|
|
for (const arb of [compiled, manual]) {
|
|
|
|
|
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: 0.5 });
|
|
|
|
|
arb.addRelation('user:alice', 'works_in', 'dept:eng', { possibility: 1 });
|
|
|
|
|
arb.addRelation('dept:eng', 'has_access', 'doc:1', { possibility: 0.75 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const op of operations) {
|
|
|
|
|
applyMutation(compiled, op);
|
|
|
|
|
applyMutation(manual, op);
|
|
|
|
|
|
|
|
|
|
for (const rel of ['can_read', 'can_access']) {
|
|
|
|
|
const c = compiled.check('user:alice', rel, 'doc:1');
|
|
|
|
|
const m = manual.check('user:alice', rel, 'doc:1');
|
|
|
|
|
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
|
|
|
|
fail(`parity ${rel} after ${JSON.stringify(op)}: compiled=${c.possibility}, manual=${m.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ops: operations.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mutationGen = rigor.gen.oneOf([
|
|
|
|
|
rigor.gen.tuple(
|
|
|
|
|
rigor.gen.constant('add'),
|
|
|
|
|
rigor.gen.oneOf(SUBJECTS),
|
|
|
|
|
rigor.gen.oneOf(RELATIONS),
|
|
|
|
|
rigor.gen.oneOf(SUBJECTS),
|
|
|
|
|
rigor.gen.oneOf(POS)
|
|
|
|
|
),
|
|
|
|
|
rigor.gen.tuple(
|
|
|
|
|
rigor.gen.constant('remove'),
|
|
|
|
|
rigor.gen.oneOf(SUBJECTS),
|
|
|
|
|
rigor.gen.oneOf(RELATIONS),
|
|
|
|
|
rigor.gen.oneOf(SUBJECTS),
|
|
|
|
|
rigor.gen.constant(0)
|
|
|
|
|
)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.array(mutationGen, 1, 10)
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.invariant('sequence-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 400, seed: 'dsl-mutation-parity' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'sequence-parity');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `SEQUENCE PARITY violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GRANT/REVOKE CYCLES: repeated add/remove of the same tuple never desyncs', async () => {
|
|
|
|
|
async function check({ cycles, p }) {
|
|
|
|
|
const compiled = buildCompiledArbiter();
|
|
|
|
|
const manual = buildManualArbiter();
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < cycles; i++) {
|
|
|
|
|
for (const arb of [compiled, manual]) {
|
|
|
|
|
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: p });
|
|
|
|
|
}
|
|
|
|
|
for (const rel of ['can_read', 'can_access']) {
|
|
|
|
|
const c = compiled.check('user:alice', rel, 'doc:1');
|
|
|
|
|
const m = manual.check('user:alice', rel, 'doc:1');
|
|
|
|
|
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
|
|
|
|
fail(`grant cycle ${i} ${rel}: compiled=${c.possibility}, manual=${m.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const arb of [compiled, manual]) {
|
|
|
|
|
arb.removeRelation('user:alice', 'owns', 'doc:1');
|
|
|
|
|
}
|
|
|
|
|
const c = compiled.check('user:alice', 'can_read', 'doc:1');
|
|
|
|
|
const m = manual.check('user:alice', 'can_read', 'doc:1');
|
|
|
|
|
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
|
|
|
|
fail(`revoke cycle ${i}: compiled=${c.possibility}, manual=${m.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
if (c.possibility !== 0) {
|
|
|
|
|
fail(`revoke cycle ${i}: grant survived removal (${c.possibility})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { cycles };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
cycles: rigor.gen.int(2, 8),
|
|
|
|
|
p: rigor.gen.oneOf([0.25, 0.5, 1])
|
|
|
|
|
})
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.invariant('grant-revoke-cycles', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 300, seed: 'dsl-grant-revoke-cycles' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'grant-revoke-cycles');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `GRANT/REVOKE CYCLES violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
});
|