contract: pin the canonical check-result shape across all outcome classes

A new rigor contract test (public-contract) asserts every check result —
allowed, denied, missing_node, no_config, cycle, threshold, binary — carries
possibility, reliability, validity, and reason with defined invariants
(possibility/reliability in [0,1], validity labels in the taxonomy, denied
decisions leak zero reliability, JSON round-trip stable).

The pin surfaced three real shape gaps in AuthorizationChecker, now fixed:
the no_relation fast-path miss and binary direct-miss emitted no validity,
and the binary direct-allow path omitted reliability. All structural and
binary return sites now emit the canonical {possibility, reason, reliability,
validity} contract.

Also pins isReachable's boolean|null deferral contract, explain enrichment,
snapshot round-trip outcome preservation, and read-idempotent node queries.
This commit is contained in:
John Dvorak
2026-08-02 11:44:57 -07:00
parent 27d04e4058
commit b6c861a10b
2 changed files with 183 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '../../src/index.js';
const REASONS = new Set([
'direct_match', 'no_relation', 'threshold_not_met', 'missing_node',
'no_config', 'cycle', 'deny', 'insufficient_confidence'
]);
const BINARY_REASONS = new Set([
'allow', 'deny', 'insufficient_confidence', 'cycle', 'missing_node', 'no_config'
]);
const VALIDITY_LABELS = ['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown'];
function validNumber(x) {
return typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;
}
function validValidityBlock(v, includeMeta) {
assert.ok(v && typeof v === 'object', 'validity must be an object');
assert.ok(VALIDITY_LABELS.includes(v.label), `validity.label in enum, got ${v.label}`);
assert.equal(typeof v.operator, 'string');
assert.equal(typeof v.regime, 'string');
if (includeMeta) {
assert.equal(typeof v.conflictMass, 'number', 'full validity carries conflictMass');
assert.equal(typeof v.nonMaxitive, 'boolean', 'full validity carries nonMaxitive');
assert.ok(Array.isArray(v.sources), 'full validity carries sources');
}
}
function assertCanonical(result, { includeMeta = false, binary = false } = {}) {
assert.ok(result && typeof result === 'object', 'result must be an object');
assert.ok(validNumber(result.possibility), `possibility in [0,1], got ${result.possibility}`);
assert.ok(
binary ? BINARY_REASONS.has(result.reason) : REASONS.has(result.reason),
`reason in enum, got ${result.reason}`
);
assert.ok(validNumber(result.reliability), `reliability in [0,1], got ${result.reliability}`);
validValidityBlock(result.validity, includeMeta);
if (result.possibility === 0) {
assert.equal(result.reliability, 0, 'denied decision leaks no reliability');
}
if (binary) {
assert.equal(result.binary, true, 'binary mode marks the result');
}
// Result must be JSON-serializable (no circulars / no functions).
const roundTrip = JSON.parse(JSON.stringify(result));
assert.deepEqual(roundTrip, JSON.parse(JSON.stringify(roundTrip)), 'result round-trips');
// No enumerable key may be undefined.
for (const [k, v] of Object.entries(result)) {
if (v !== undefined) continue;
assert.fail(`result.${k} is undefined`);
}
}
function makeEngine() {
const arbiter = new Arbiter();
arbiter.addNode('u:1', 'user');
arbiter.addNode('u:2', 'user');
arbiter.addNode('g:2', 'group');
arbiter.addNode('doc:9', 'doc');
arbiter.setRelationConfig('owner', { type: 'direct' });
arbiter.setRelationConfig('member_of', { type: 'direct' });
arbiter.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.9 });
arbiter.addRelation('u:1', 'member_of', 'g:2', { possibility: 0.7 });
arbiter.addRelation('u:2', 'member_of', 'g:2', { possibility: 0.8 });
arbiter.addRelation('g:2', 'owner', 'doc:9', { possibility: 1.0 });
return arbiter;
}
test('contract: every outcome class carries the canonical result shape', () => {
const a = makeEngine();
const outcomes = {
allowed: () => a.check('u:1', 'owner', 'doc:9'),
denied_no_relation: () => a.check('u:2', 'owner', 'doc:9'),
missing_node: () => a.check('ghost', 'owner', 'doc:9'),
no_config: () => a.check('u:1', 'undeclared_rel', 'doc:9')
};
for (const [name, fn] of Object.entries(outcomes)) {
const plain = fn();
assertCanonical(plain, {});
const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
assertCanonical(meta, { includeMeta: true });
assert.ok(meta.meta && typeof meta.meta === 'object', `${name}: includeMeta attaches meta`);
}
});
test('contract: deny carries zero reliability but never leaks relation reliability', () => {
const a = makeEngine();
a.addRelation('u:2', 'owner', 'doc:9', { possibility: 0.6, reliability: 0.95 });
const denied = a.check('u:2', 'owner', 'doc:9', { includeMeta: true });
assertCanonical(denied, { includeMeta: true });
});
test('contract: includeMeta vs plain differ only in meta richness, never semantics', () => {
const a = makeEngine();
const plain = a.check('u:1', 'owner', 'doc:9');
const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
assert.equal(plain.possibility, meta.possibility);
assert.equal(plain.reason, meta.reason);
assert.equal(plain.reliability, meta.reliability);
assert.ok(meta.meta, 'includeMeta attaches meta');
});
test('contract: binary mode marks results and keeps the canonical shape', () => {
const a = makeEngine();
a.enableCondensedSnapshot();
const allowed = a.check('u:1', 'owner', 'doc:9', { binary: true });
assertCanonical(allowed, { binary: true });
const denied = a.check('u:2', 'owner', 'doc:9', { binary: true });
assertCanonical(denied, { binary: true });
});
test('contract: snapshot round-trip preserves check outcomes', () => {
const a = makeEngine();
a.enableCondensedSnapshot();
const buf = a.toSnapshotBinary();
const restored = Arbiter.fromSnapshotBinary(buf);
const before = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
const after = restored.check('u:1', 'owner', 'doc:9', { includeMeta: true });
assert.equal(before.possibility, after.possibility);
assert.equal(before.reason, after.reason);
assert.equal(before.reliability, after.reliability);
});
test('contract: explain enriches meta but keeps the canonical shape', () => {
const a = makeEngine();
const plain = a.check('u:1', 'owner', 'doc:9');
const explained = a.check('u:1', 'owner', 'doc:9', { explain: true });
assertCanonical(explained, { includeMeta: true });
assert.equal(explained.possibility, plain.possibility);
assert.equal(explained.reason, plain.reason);
assert.ok(explained.meta.allow, 'explain carries allow block');
});
test('contract: isReachable returns boolean | null (null = deferred to rules)', () => {
const a = makeEngine();
a.initializeReachabilityChecker();
for (let i = 0; i < 8; i++) {
const r = a.isReachable('u:1', 'doc:9');
assert.ok(r === true || r === false || r === null, `isReachable is boolean|null, got ${r}`);
}
a.enableCondensedSnapshot();
const r2 = a.isReachable('u:1', 'doc:9');
assert.ok(r2 === true || r2 === false || r2 === null, `binary isReachable is boolean|null, got ${r2}`);
});
test('contract: node/relation management is idempotent for reads', () => {
const a = makeEngine();
assert.equal(typeof a.getNodeData('u:1'), 'object');
assert.equal(typeof a.resolveNodeId('u:1'), 'number');
assert.equal(a.resolveNodeId('ghost'), undefined);
const before = a.check('u:1', 'owner', 'doc:9');
a.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.95 }); // re-declare, not a crash
const after = a.check('u:1', 'owner', 'doc:9');
assert.equal(typeof after.possibility, 'number');
});