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:
@@ -108,6 +108,8 @@ export class AuthorizationChecker {
|
||||
if (userId === undefined || objectId === undefined) {
|
||||
const result = {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'missing_node' } }),
|
||||
reason: 'missing_node'
|
||||
};
|
||||
@@ -128,6 +130,8 @@ export class AuthorizationChecker {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
@@ -137,6 +141,8 @@ export class AuthorizationChecker {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
@@ -217,6 +223,7 @@ export class AuthorizationChecker {
|
||||
result = {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'no_relation' } }),
|
||||
reason: 'no_relation',
|
||||
...(remediation ? { remediation } : {})
|
||||
@@ -247,6 +254,8 @@ export class AuthorizationChecker {
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
reason: 'missing_node',
|
||||
@@ -261,6 +270,8 @@ export class AuthorizationChecker {
|
||||
if (!config) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'no_config' } }),
|
||||
reason: 'no_config'
|
||||
};
|
||||
@@ -285,6 +296,8 @@ export class AuthorizationChecker {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
@@ -294,6 +307,8 @@ export class AuthorizationChecker {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
@@ -636,6 +651,8 @@ export class AuthorizationChecker {
|
||||
if (userId === undefined || objectId === undefined) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'missing_node',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
@@ -649,6 +666,7 @@ export class AuthorizationChecker {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
@@ -660,6 +678,7 @@ export class AuthorizationChecker {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
@@ -675,6 +694,8 @@ export class AuthorizationChecker {
|
||||
if (!config) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'no_config',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
@@ -709,6 +730,9 @@ export class AuthorizationChecker {
|
||||
|
||||
return {
|
||||
possibility: directRel.possibility,
|
||||
reliability: directRel.possibility > 0
|
||||
? (directRel.reliability !== undefined ? directRel.reliability : 1.0)
|
||||
: 0,
|
||||
validity: includeMeta
|
||||
? (directRel.validity !== undefined
|
||||
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility)
|
||||
@@ -731,6 +755,7 @@ export class AuthorizationChecker {
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'insufficient_confidence',
|
||||
binary: true,
|
||||
@@ -849,6 +874,8 @@ export class AuthorizationChecker {
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'deny',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user