/** * rigor/binary-partial-parity.test.js — binary-mode decisions with * partial graphs. * * Bug-28 regression pin: the binary branch of AuthorizationChecker.check * rebuilt its options with a fixed six-field object and dropped * partialGraphContext, so a binary check with a partial grant denied * everything. Contracts pinned: * - binary decisions honor partial facts: a partial grant above * minAllowPossibility allows; below it denies; * - persistent relations still win over partial facts in binary mode; * - binary and normal decisions agree on the same partial overlay * (binary.allow === (normalPossibility >= threshold) for direct * configs). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const USERS = 2; const DOCS = 2; const NODES = USERS + DOCS; const nodeKey = (id) => (id < USERS ? `u:${id}` : `doc:${id - USERS}`); const THRESHOLD = 0.6; function makeWrapper() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) { arbiter.addNode(nodeKey(i), i < USERS ? 'user' : 'doc'); } arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const tuples = new Map(); const partialTuples = new Map(); const tupleKey = (s, r, d) => `${s}|${r}|${d}`; const ops = []; const wrapper = { engine: arbiter, tuples, partialTuples, add(src, rel, dst, p) { arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p }); tuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p); return { ok: true }; }, addPartial(src, rel, dst, p) { partialTuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p); return { ok: true }; }, check(src, rel, dst) { const baseRel = rel === 'can_read' ? 'owner' : rel; const key = tupleKey(nodeKey(src), baseRel, nodeKey(dst)); const partialSpec = { relations: [...partialTuples.entries()].map(([k, p]) => { const [s, r, d] = k.split('|'); return { src: s, relation: r, dst: d, possibility: p }; }) }; const binaryRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), { binary: true, minAllowPossibility: THRESHOLD, maxDenyPossibility: THRESHOLD, partialGraph: partialSpec }); const normalRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec }); const persistentValue = tuples.get(key) ?? 0; const partialValue = partialTuples.get(key) ?? 0; return { binaryAllow: binaryRes.allow, binaryReason: binaryRes.reason, normalPossibility: normalRes.possibility, persistentValue, partialValue, expected: persistentValue > 0 ? persistentValue : partialValue }; }, clone() { const fresh = makeWrapper(); for (const op of ops) { const [name, ...args] = op; fresh[name](...args); } return fresh; } }; const record = (name, fn) => (...args) => { const res = fn(...args); ops.push([name, ...args]); return res; }; wrapper.add = record('add', wrapper.add); wrapper.addPartial = record('addPartial', wrapper.addPartial); wrapper.check = record('check', wrapper.check); return wrapper; } describe('Binary mode with partial graphs (rigor)', () => { it('FIXED MATRIX: binary honors partial grants; persistent wins; decisions agree', () => { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const grant = arb.check('u:0', 'can_read', 'doc:0', { binary: true, minAllowPossibility: THRESHOLD, partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] } }); assert.equal(grant.allow, true, 'partial grant above threshold allows'); assert.equal(grant.possibility, 0.9, 'binary returns the partial possibility'); const weak = arb.check('u:0', 'can_read', 'doc:0', { binary: true, minAllowPossibility: THRESHOLD, partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.2 }] } }); assert.equal(weak.allow, false, 'partial grant below threshold denies'); arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); const persistent = arb.check('u:0', 'can_read', 'doc:0', { binary: true, minAllowPossibility: THRESHOLD, partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.3 }] } }); assert.equal(persistent.allow, true, 'persistent 0.8 wins over partial 0.3'); }); it('PROPERTY CAMPAIGN: binary and normal decisions agree on the same overlay', async () => { const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); }, rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.1, 0.3, 0.5, 0.7, 0.9]))), rigor.method('addPartial', function (s, r, d, p) { return this.addPartial(s, r, d, p); }, rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.4, 0.6, 0.8, 0.95]))), rigor.method('check', function (s, r, d) { return this.check(s, r, d); }, rigor.args(rigor.gen.int(0, USERS - 1), rigor.gen.constant('can_read'), rigor.gen.oneOf([2, 3]))) ])], rigor.crucible([ rigor.invariant('binary decision equals normal decision at threshold', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; const { binaryAllow, normalPossibility } = ctx.actual; const normalAllow = normalPossibility >= THRESHOLD; return binaryAllow === normalAllow; }), rigor.invariant('expected overlay value matches the engine', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; const { normalPossibility, expected } = ctx.actual; return Math.abs(normalPossibility - expected) < 1e-9; }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 400, seed: 'binary-partial-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }}); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `binary/partial parity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });