From b6c861a10bfcd7984594ddde6397039001a2c204 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 11:44:57 -0700 Subject: [PATCH] contract: pin the canonical check-result shape across all outcome classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/authorization/AuthorizationChecker.js | 27 ++++ tests/rigor/public-contract.test.js | 156 ++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/rigor/public-contract.test.js diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 8d004bf..3644d59 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -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 }), diff --git a/tests/rigor/public-contract.test.js b/tests/rigor/public-contract.test.js new file mode 100644 index 0000000..493a868 --- /dev/null +++ b/tests/rigor/public-contract.test.js @@ -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'); +});