From 8f863275c279f0faf7fbf880ed6158e9968a4baa Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 31 Jul 2026 15:31:34 -0700 Subject: [PATCH] js-rigor: fix binary mode dropping partial graphs; binary-partial parity campaign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary branch of AuthorizationChecker.check rebuilt its options with a fixed six-field object, silently discarding partialGraphContext (and any other caller option) — binary checks denied grants the normal path allowed. Now spreads all caller options through. binary-partial-parity pins: partial grants above the threshold allow, below deny, persistent wins over partial, and binary decisions agree with normal decisions on the same overlay. --- src/authorization/AuthorizationChecker.js | 7 +- tests/rigor/binary-partial-parity.test.js | 164 ++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/rigor/binary-partial-parity.test.js diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 796bbce..5ed18ec 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -50,7 +50,12 @@ export class AuthorizationChecker { minAllowPossibility: minAllowPossibility || 0.8, // Default strict threshold maxDenyPossibility: maxDenyPossibility || 0.8, includeMeta, - trackEvaluation + trackEvaluation, + // Preserve every caller option (partialGraphContext, clientStateId, + // ...) — the binary path previously rebuilt a six-field object and + // silently dropped the partial graph, so binary checks denied + // grants that the normal path allowed. + ...options }); } diff --git a/tests/rigor/binary-partial-parity.test.js b/tests/rigor/binary-partial-parity.test.js new file mode 100644 index 0000000..2f0be1a --- /dev/null +++ b/tests/rigor/binary-partial-parity.test.js @@ -0,0 +1,164 @@ +/** + * 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 }); + + 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')); + }); +});