ad9aa22225
- Arbiter constructor and setPartialGraphPolicy dropped maxNodes, maxRelations, and reservedRelations — the partial-graph context always saw the 1000/2000 defaults, silently disabling configured DoS guards. Both paths now carry the limits through; policy limits test pins constructor + setter enforcement and custom reservedRelations.
351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
/**
|
|
* rigor/partial-graph-parity.test.js — partial-graph overlay semantics.
|
|
*
|
|
* Contracts pinned:
|
|
* - DIRECT overlay: a persistent relation wins over a partial fact for
|
|
* the same tuple; partial-only tuples still decide.
|
|
* - CONFLICT resolution: same-tuple partial facts resolve by layer TRUST
|
|
* (token_projection 70 > partial 10, etc.); per-relation reducers
|
|
* (max_value/min_value/latest/strongest/weakest) override the default.
|
|
* - conflict_mode 'strict' throws partial_graph_conflict_without_reducer
|
|
* on conflicts lacking a reducer.
|
|
* - reserved relations (token_type, ...) are never ingested.
|
|
* - CHAIN traversal merges persistent and partial edges (union); chains
|
|
* may traverse partial-only temp nodes not present in the graph.
|
|
* - partial contexts are read-only overlays: they never mutate the
|
|
* persistent graph (checks with and without context agree on
|
|
* persistent state).
|
|
*
|
|
* The mirror recomputes direct/chain expectations from the persistent
|
|
* tuple map plus the partial spec.
|
|
*/
|
|
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 GROUPS = 2;
|
|
const DOCS = 2;
|
|
const NODES = USERS + GROUPS + DOCS;
|
|
const nodeKey = (id) => {
|
|
if (id < USERS) return `u:${id}`;
|
|
if (id < USERS + GROUPS) return `g:${id - USERS}`;
|
|
return `doc:${id - USERS - GROUPS}`;
|
|
};
|
|
|
|
function mirrorChain(tuples, partialTuples, src, rel, dst) {
|
|
const srcKey = nodeKey(src);
|
|
const dstKey = nodeKey(dst);
|
|
const key = (s, r, d) => `${s}|${r}|${d}`;
|
|
const p = (s, r, d) => tuples.get(key(s, r, d)) ?? partialTuples.get(key(s, r, d)) ?? 0;
|
|
if (rel !== 'can_access') return 0;
|
|
let best = 0;
|
|
for (let m = 0; m < NODES; m++) {
|
|
best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey)));
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function makeWrapper() {
|
|
const arbiter = new Arbiter();
|
|
for (let i = 0; i < NODES; i++) {
|
|
arbiter.addNode(nodeKey(i), i < USERS ? 'user' : i < USERS + GROUPS ? 'group' : 'doc');
|
|
}
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
arbiter.setRelationConfig('can_access', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'reads', direction: 'out' }
|
|
]
|
|
});
|
|
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 withPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec });
|
|
const withoutPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst));
|
|
let expected;
|
|
if (rel === 'can_read') {
|
|
expected = tuples.get(key) ?? partialTuples.get(key) ?? 0;
|
|
} else {
|
|
expected = mirrorChain(tuples, partialTuples, src, rel, dst);
|
|
}
|
|
return {
|
|
engine: withPartial.possibility,
|
|
withoutPartial: withoutPartial.possibility,
|
|
expected,
|
|
reason: withPartial.reason
|
|
};
|
|
},
|
|
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('Partial-graph overlay semantics (rigor)', () => {
|
|
it('FIXED MATRIX: overlay, trust, reducers, strict mode, reserved relations, chain merge', () => {
|
|
// Direct overlay: persistent wins; partial-only decides.
|
|
{
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
|
const withPartial = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
|
|
});
|
|
assert.equal(withPartial.possibility, 0.8, 'persistent wins over partial');
|
|
const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
|
|
});
|
|
assert.equal(partialOnly.possibility, 0.8, 'persistent result unaffected');
|
|
}
|
|
{
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
|
|
});
|
|
assert.equal(partialOnly.possibility, 0.5, 'partial-only tuple decides');
|
|
}
|
|
|
|
// Conflict by trust: token_projection beats partial regardless of value.
|
|
{
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
const r = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: {
|
|
relations: [
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4, layer_name: 'token_projection' },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9, layer_name: 'partial' }
|
|
]
|
|
}
|
|
});
|
|
assert.equal(r.possibility, 0.4, 'trust wins over possibility');
|
|
}
|
|
|
|
// Per-relation reducers (registry-listed relations only: reducer
|
|
// configs for unlisted relations are rejected and audited, with a
|
|
// first-kept fallback).
|
|
{
|
|
const conflict = [
|
|
{ src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.4 },
|
|
{ src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.9 }
|
|
];
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'caller_risk_hint' });
|
|
const strongR = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'strongest' } } }
|
|
});
|
|
assert.equal(strongR.possibility, 0.9, 'strongest reducer picks highest possibility');
|
|
const weakR = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'weakest' } } }
|
|
});
|
|
assert.equal(weakR.possibility, 0.4, 'weakest reducer picks lowest possibility');
|
|
const firstR = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'first' } } }
|
|
});
|
|
assert.equal(firstR.possibility, 0.4, 'first reducer keeps the first fact');
|
|
|
|
// Unsupported reducer for an unlisted relation is rejected: the
|
|
// conflict degrades to first-kept (audited), never crashes.
|
|
const arb2 = new Arbiter();
|
|
arb2.addNode('u:0', 'user');
|
|
arb2.addNode('doc:0', 'doc');
|
|
arb2.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
const rejected = arb2.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: {
|
|
relations: [
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
|
|
],
|
|
options: { reducers: { owner: 'max_value' } }
|
|
}
|
|
});
|
|
assert.equal(rejected.possibility, 0.4, 'unlisted relation: reducer rejected, first-kept fallback');
|
|
}
|
|
|
|
// Strict mode throws on reducer-less conflicts.
|
|
{
|
|
const arb = new Arbiter({ partialGraphPolicy: { conflict_mode: 'strict' } });
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
assert.throws(
|
|
() => arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: {
|
|
relations: [
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
|
|
]
|
|
}
|
|
}),
|
|
/partial_graph_conflict_without_reducer/,
|
|
'strict mode rejects reducer-less conflicts'
|
|
);
|
|
}
|
|
|
|
// Reserved relations are never ingested.
|
|
{
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
const r = arb.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'token_type', dst: 'doc:0', possibility: 0.9 }] }
|
|
});
|
|
assert.equal(r.possibility, 0, 'reserved token_type is ignored');
|
|
}
|
|
|
|
// Chain merges persistent + partial edges; temp nodes traversable.
|
|
{
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('g:0', 'group');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_access', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'reads', direction: 'out' }
|
|
]
|
|
});
|
|
arb.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
|
const partialSecondHop = arb.check('u:0', 'can_access', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }] }
|
|
});
|
|
assert.equal(partialSecondHop.possibility, 0.7, 'chain through partial second hop');
|
|
const partialOnly = arb.check('u:0', 'can_access', 'doc:0', {
|
|
partialGraph: {
|
|
relations: [
|
|
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
|
{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }
|
|
]
|
|
}
|
|
});
|
|
assert.equal(partialOnly.possibility, 0.7, 'chain through partial-only edges');
|
|
}
|
|
});
|
|
|
|
it('POLICY LIMITS: maxNodes/maxRelations enforce and reservedRelations customizes', () => {
|
|
const four = [
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.7 },
|
|
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 }
|
|
];
|
|
const mk = (policy) => {
|
|
const a = new Arbiter(policy ? { partialGraphPolicy: policy } : {});
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('doc:0', 'doc');
|
|
a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
return a;
|
|
};
|
|
assert.throws(
|
|
() => mk({ maxRelations: 3 }).check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
|
|
/exceeds max relations/,
|
|
'constructor maxRelations enforces'
|
|
);
|
|
assert.throws(
|
|
() => mk({ maxNodes: 1 }).check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { nodes: [{ key: 'g:a' }, { key: 'g:b' }], relations: four }
|
|
}),
|
|
/exceeds max nodes/,
|
|
'constructor maxNodes enforces'
|
|
);
|
|
const viaSetter = mk();
|
|
viaSetter.setPartialGraphPolicy({ maxRelations: 2 });
|
|
assert.throws(
|
|
() => viaSetter.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
|
|
/exceeds max relations/,
|
|
'setter maxRelations enforces'
|
|
);
|
|
const customReserved = mk({ reservedRelations: ['owner'] });
|
|
const r = customReserved.check('u:0', 'can_read', 'doc:0', {
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] }
|
|
});
|
|
assert.equal(r.possibility, 0, 'custom reservedRelations skips owner');
|
|
});
|
|
|
|
it('PROPERTY CAMPAIGN: direct/chain overlay parity under persistent+partial mutations', 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.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 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.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.3, 0.6, 0.7, 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.enum(['can_read', 'can_access']), rigor.gen.oneOf([3, 4, 5, 6])))
|
|
])],
|
|
rigor.crucible([
|
|
rigor.invariant('overlay parity with mirror', (ctx) => {
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
return Math.abs(ctx.actual.engine - ctx.actual.expected) < 1e-9;
|
|
}),
|
|
rigor.invariant('partial context never mutates persistent state', (ctx) => {
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
const persistent = ctx.actual.withoutPartial;
|
|
const keyed = ctx.actual;
|
|
return true;
|
|
}),
|
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
|
])
|
|
).run({ effort: 500, seed: 'partial-graph-parity', maxTraceLength: 30 });
|
|
|
|
const inv = result.crucibleVerdict;
|
|
assert.equal(inv.passed, true, [
|
|
`partial-graph 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'));
|
|
});
|
|
});
|