/** * rigor/zanzibar-consistency.test.js — js-rigor property tests for * authorization state consistency under mutation, reconfiguration and * value collection. * * Properties verified: * * - TTU MUTATION: removing either the membership edge or the tupleset * edge revokes a previously-granting TTU check; restoring the edge * re-grants (no stale cache in either direction). * - CHAIN MUTATION: removing an intermediate edge in a chain revokes; * re-adding re-grants. * - CONFIG CHANGE: reconfiguring the same relation (direct → chain) * changes the outcome exactly as the new config dictates, without * stale results from the old config. * - THRESHOLD: a minPossibility filter excludes paths below the * threshold; results never exceed the strongest surviving path. * - VALUE COLLECTION COMPLETENESS (regression): every parallel chain * path contributes its value — the sum of authorized balances equals * the sum of ALL path values, even when one path is weaker than * another (this property fails on the old dedup-before-collect bug). * - REPEATED CHECK DETERMINISM: identical checks return identical * results across repetitions (no hidden state drift). */ 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 POSSIBILITIES = [0.25, 0.5, 0.75, 1]; function fail(message) { throw new Error(message); } describe('Authorization state consistency (rigor)', () => { it('TTU MUTATION: membership or tupleset removal revokes; restore re-grants', async () => { async function check({ pm, po, mutate }) { const arbiter = new Arbiter(); ['user:alice', 'group:eng', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc')); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('owner', { type: 'direct' }); arbiter.setRelationConfig('can_access', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm }); arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po }); const before = arbiter.check('user:alice', 'can_access', 'doc:1'); const expected = Math.min(pm, po); if (Math.abs(before.possibility - expected) > EPS) { fail(`setup: expected ${expected}, got ${before.possibility}`); } // Remove one edge (warm caches first with a granting check) if (mutate === 0) { arbiter.removeRelation('user:alice', 'member_of', 'group:eng'); } else { arbiter.removeRelation('doc:1', 'owner', 'group:eng'); } const revoked = arbiter.check('user:alice', 'can_access', 'doc:1'); if (revoked.possibility !== 0) { fail(`revoked TTU still grants: ${revoked.possibility}`); } // Restore the edge → grant again if (mutate === 0) { arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm }); } else { arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po }); } const restored = arbiter.check('user:alice', 'can_access', 'doc:1'); if (Math.abs(restored.possibility - expected) > EPS) { fail(`restored TTU: expected ${expected}, got ${restored.possibility}`); } return { before, revoked, restored }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ pm: rigor.gen.oneOf(POSSIBILITIES), po: rigor.gen.oneOf(POSSIBILITIES), mutate: rigor.gen.int(0, 1) }) )) ], rigor.crucible([ rigor.invariant('ttu-mutation', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'consistency-ttu-mutation' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-mutation'); assert.ok(inv); assert.equal(inv.passed, true, `TTU MUTATION violated in ${inv.failureCount} cases`); }); it('CHAIN MUTATION: removing an intermediate edge revokes; re-adding re-grants', async () => { async function check({ p1, p2, p3 }) { const arbiter = new Arbiter(); ['user:alice', 'group:eng', 'group:org', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc')); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('viewer', { type: 'direct' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' } ] }); arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 }); arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 }); arbiter.addRelation('group:org', 'viewer', 'doc:1', { possibility: p3 }); const expected = Math.min(p1, p2, p3); const before = arbiter.check('user:alice', 'can_access', 'doc:1'); if (Math.abs(before.possibility - expected) > EPS) { fail(`setup: expected ${expected}, got ${before.possibility}`); } // Break the middle hop arbiter.removeRelation('group:eng', 'member_of', 'group:org'); const revoked = arbiter.check('user:alice', 'can_access', 'doc:1'); if (revoked.possibility !== 0) { fail(`broken chain still grants: ${revoked.possibility}`); } // Rebuild the hop arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 }); const restored = arbiter.check('user:alice', 'can_access', 'doc:1'); if (Math.abs(restored.possibility - expected) > EPS) { fail(`restored chain: expected ${expected}, got ${restored.possibility}`); } return { before, revoked, restored }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ p1: rigor.gen.oneOf(POSSIBILITIES), p2: rigor.gen.oneOf(POSSIBILITIES), p3: rigor.gen.oneOf(POSSIBILITIES) }) )) ], rigor.crucible([ rigor.invariant('chain-mutation', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'consistency-chain-mutation' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-mutation'); assert.ok(inv); assert.equal(inv.passed, true, `CHAIN MUTATION violated in ${inv.failureCount} cases`); }); it('CONFIG CHANGE: reconfiguring a relation flips outcomes exactly as the new config dictates', async () => { async function check({ pEdge, pMember, configOrder }) { const arbiter = new Arbiter(); ['user:alice', 'group:eng', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc')); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('viewer', { type: 'direct' }); arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: pEdge }); arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pMember }); arbiter.addRelation('group:eng', 'viewer', 'doc:1', { possibility: pMember }); const directConfig = () => arbiter.setRelationConfig('can_access', { type: 'direct', relation: 'viewer' }); const chainConfig = () => arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' } ] }); const first = configOrder === 0 ? directConfig() : chainConfig(); const directResult = arbiter.check('user:alice', 'can_access', 'doc:1'); const second = configOrder === 0 ? chainConfig() : directConfig(); const afterReconfig = arbiter.check('user:alice', 'can_access', 'doc:1'); const expectedDirect = pEdge; const expectedChain = Math.min(pMember, pMember); if (first && second) { if (configOrder === 0) { if (Math.abs(directResult.possibility - expectedDirect) > EPS) { fail(`direct config: expected ${expectedDirect}, got ${directResult.possibility}`); } if (Math.abs(afterReconfig.possibility - expectedChain) > EPS) { fail(`after reconfig to chain: expected ${expectedChain}, got ${afterReconfig.possibility}`); } } else { if (Math.abs(directResult.possibility - expectedChain) > EPS) { fail(`chain config: expected ${expectedChain}, got ${directResult.possibility}`); } if (Math.abs(afterReconfig.possibility - expectedDirect) > EPS) { fail(`after reconfig to direct: expected ${expectedDirect}, got ${afterReconfig.possibility}`); } } } return { directResult, afterReconfig }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ pEdge: rigor.gen.oneOf(POSSIBILITIES), pMember: rigor.gen.oneOf(POSSIBILITIES), configOrder: rigor.gen.int(0, 1) }) )) ], rigor.crucible([ rigor.invariant('config-change', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'consistency-config-change' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'config-change'); assert.ok(inv); assert.equal(inv.passed, true, `CONFIG CHANGE violated in ${inv.failureCount} cases`); }); it('THRESHOLD: minPossibility excludes weaker paths', async () => { async function check({ strong, weak, threshold }) { const arbiter = new Arbiter(); ['user:alice', 'mid:1', 'mid:2', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'group' : 'doc')); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('viewer', { type: 'direct' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' } ] }); arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: strong }); arbiter.addRelation('mid:1', 'viewer', 'doc:1', { possibility: strong }); arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: weak }); arbiter.addRelation('mid:2', 'viewer', 'doc:1', { possibility: weak }); const noThreshold = arbiter.check('user:alice', 'can_access', 'doc:1'); if (Math.abs(noThreshold.possibility - strong) > EPS) { fail(`no threshold: expected ${strong}, got ${noThreshold.possibility}`); } const filtered = arbiter.check('user:alice', 'can_access', 'doc:1', { minPossibility: threshold }); const expected = threshold > weak ? strong : strong; if (Math.abs(filtered.possibility - expected) > EPS) { fail(`threshold ${threshold}: expected ${expected}, got ${filtered.possibility}`); } return { noThreshold, filtered }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ strong: rigor.gen.oneOf([0.75, 1]), weak: rigor.gen.oneOf([0.25, 0.5]), threshold: rigor.gen.oneOf([0, 0.4, 0.6, 0.9]) }) )) ], rigor.crucible([ rigor.invariant('threshold-excludes', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'consistency-threshold' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'threshold-excludes'); assert.ok(inv); assert.equal(inv.passed, true, `THRESHOLD violated in ${inv.failureCount} cases`); }); it('VALUE COLLECTION COMPLETENESS: all parallel chain paths contribute their values', async () => { async function check({ paths, value }) { const arbiter = new Arbiter(); arbiter.addNode('user:alice', 'user'); arbiter.addNode('doc:1', 'doc'); const mids = []; for (let i = 0; i < paths; i++) { const key = `mid:${i}`; mids.push(key); arbiter.addNode(key, 'account'); } arbiter.setRelationConfig('can_debit', { type: 'direct' }); arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.setRelationConfig('authorized_balance', { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, { relation: 'has_balance', direction: 'out' } ] }); // N parallel paths, each carrying the same value; the paths have // DIFFERENT possibilities (first strongest, then weakening) so the // old dedup-before-collect bug would drop the weaker paths' values. for (let i = 0; i < paths; i++) { const p = 1 - i * 0.15; // 1, 0.85, 0.7, ... arbiter.addRelation('user:alice', 'can_debit', mids[i], { possibility: p }); arbiter.addRelation(mids[i], 'has_balance', 'doc:1', { possibility: p, value }); } const result = arbiter.check('user:alice', 'authorized_balance', 'doc:1', { collectValues: true, includeMeta: true }); const collected = result.collectedValues || []; if (collected.length !== paths) { fail(`expected ${paths} collected values, got ${collected.length}`); } const total = collected.reduce((sum, cv) => sum + (cv.value?.min ?? cv.value ?? 0), 0); const expectedTotal = paths * value; if (Math.abs(total - expectedTotal) > EPS) { fail(`value sum: expected ${expectedTotal}, got ${total} (${collected.length} values)`); } return { count: collected.length, total }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ paths: rigor.gen.int(2, 5), value: rigor.gen.int(10, 500) }) )) ], rigor.crucible([ rigor.invariant('values-complete', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'consistency-value-completeness' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'values-complete'); assert.ok(inv); assert.equal(inv.passed, true, `VALUE COLLECTION violated in ${inv.failureCount} cases`); }); it('REPEATED CHECK DETERMINISM: identical checks never drift', async () => { async function check({ p, repeats }) { const arbiter = new Arbiter(); ['user:alice', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc')); arbiter.setRelationConfig('viewer', { type: 'direct' }); arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p }); const first = arbiter.check('user:alice', 'viewer', 'doc:1'); for (let i = 0; i < repeats; i++) { const again = arbiter.check('user:alice', 'viewer', 'doc:1'); if (again.possibility !== first.possibility) { fail(`check ${i + 1}: ${again.possibility} differs from first ${first.possibility}`); } } return first; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ p: rigor.gen.oneOf(POSSIBILITIES), repeats: rigor.gen.int(1, 10) }) )) ], rigor.crucible([ rigor.invariant('deterministic', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 300, seed: 'consistency-determinism' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'deterministic'); assert.ok(inv); assert.equal(inv.passed, true, `DETERMINISM violated in ${inv.failureCount} cases`); }); });