/** * rigor/multi-object-independence.test.js — js-rigor property tests for * cross-object isolation. * * Shared groups connect multiple objects: alice is a member of group:eng, * and BOTH doc:1 and doc:2 have owner tuples pointing at group:eng. * Mutations affecting one object must never change another object's * checks. * * Properties verified: * * - PER-OBJECT ORACLE PARITY: every check on every object equals the * per-object oracle computed from the edge set (TTU: * max over tuples of min(tupleP, memberP); chain: BFS per object). * - MUTATION ISOLATION: after every mutation targeting one object, all * OTHER objects' checks are unchanged (equal to their own oracles). */ 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]; const USERS = ['user:alice', 'user:bob']; const DOCS = ['doc:1', 'doc:2', 'doc:3']; const GROUPS = ['group:eng', 'group:design']; function fail(message) { throw new Error(message); } function mulberry32(seed) { let a = seed >>> 0; return { next() { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } }; } function buildArbiter() { const arb = new Arbiter(); for (const k of [...USERS, ...DOCS, ...GROUPS]) { arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('doc') ? 'doc' : 'group'); } arb.setRelationConfig('owner', { type: 'direct' }); arb.setRelationConfig('member_of', { type: 'direct' }); arb.setRelationConfig('can_edit', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); arb.setRelationConfig('viewer', { type: 'direct' }); arb.setRelationConfig('member_of2', { type: 'direct' }); return arb; } function randomState(rng) { // Random membership + tuple edges const edges = []; for (const user of USERS) { for (const grp of GROUPS) { if (rng.next() < 0.6) { const p = POS[Math.floor(rng.next() * POS.length)]; edges.push(['member_of', user, grp, p]); } } } for (const doc of DOCS) { for (const grp of GROUPS) { if (rng.next() < 0.6) { const p = POS[Math.floor(rng.next() * POS.length)]; edges.push(['owner', doc, grp, p]); } } } return edges; } function applyEdges(arb, edges) { for (const [rel, src, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p }); } function ttuOracle(user, doc, edges) { let best = 0; for (const [rel, src, dst, p] of edges) { if (rel !== 'owner' || src !== doc) continue; const member = edges.find(e => e[0] === 'member_of' && e[1] === user && e[2] === dst); best = Math.max(best, Math.min(p, member ? member[3] : 0)); } return best; } function checkAll(arb, edges) { const results = {}; for (const user of USERS) { for (const doc of DOCS) { results[`${user}|${doc}`] = { got: arb.check(user, 'can_edit', doc, {}).possibility, oracle: ttuOracle(user, doc, edges) }; } } return results; } describe('Multi-object independence (rigor)', () => { it('PER-OBJECT ORACLE PARITY + MUTATION ISOLATION through random mutations', async () => { async function check({ seed, mutations }) { const rng = mulberry32(seed); const edges = randomState(rng); const arb = buildArbiter(); applyEdges(arb, edges); const verify = (tag) => { const results = checkAll(arb, edges); for (const [key, r] of Object.entries(results)) { if (Math.abs(r.got - r.oracle) > EPS) { fail(`${tag} ${key}: got=${r.got} oracle=${r.oracle}`); } } }; verify('initial'); for (let i = 0; i < mutations; i++) { // Mutate a single edge; the target is one user/doc pair const rel = rng.next() < 0.5 ? 'owner' : 'member_of'; const src = rel === 'owner' ? DOCS[Math.floor(rng.next() * DOCS.length)] : USERS[Math.floor(rng.next() * USERS.length)]; const dst = GROUPS[Math.floor(rng.next() * GROUPS.length)]; const idx = edges.findIndex(e => e[0] === rel && e[1] === src && e[2] === dst); if (idx !== -1) { arb.removeRelation(src, rel, dst); edges.splice(idx, 1); } else { const p = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation(src, rel, dst, { possibility: p }); edges.push([rel, src, dst, p]); } verify(`mutation ${i}`); } return { edges: edges.length }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 80000), mutations: rigor.gen.int(3, 10) }) )) ], rigor.crucible([ rigor.invariant('multi-object-isolation', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1000, seed: 'multi-object-independence' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-object-isolation'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `multi-object isolation violated in ${inv.failureCount} cases`); }); });