diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 028c49e..39f65f0 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -1,4 +1,4 @@ -import { buildValidity, normalizeValidity, mergeValidity, DEFAULT_VALIDITY } from '../core/validity.js'; +import { buildValidity, normalizeValidity, mergeValidity, minimalValidity, DEFAULT_VALIDITY } from '../core/validity.js'; import { Arbiter } from '../core/Arbiter.js'; import { RuleEvaluator } from './RuleEvaluator.js'; import { RuleCollector } from './RuleCollector.js'; @@ -86,7 +86,7 @@ export class AuthorizationChecker { // Check cache first using composite key (if caching is enabled) let cachedResult = null; let cacheHint = null; - if (!hasPartialGraph && this.decisionCache.directEnabled) { + if (!hasPartialGraph && this.decisionCache.directEnabled && !includeMeta) { const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); const [hitResult, status] = this.decisionCache.peekDirect(cacheKey); cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null; @@ -111,8 +111,9 @@ export class AuthorizationChecker { reason: 'missing_node' }; - // Cache the result (only when no partial graph — same guard as success path) - if (!explain && !hasPartialGraph) { + // Cache the result (only when no partial graph and the default + // meta-less form — includeMeta callers get a fresh full evaluation) + if (!explain && !hasPartialGraph && !includeMeta) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; @@ -163,9 +164,11 @@ export class AuthorizationChecker { } else { result = { possibility: directRel.possibility, - validity: directRel.validity !== undefined - ? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) - : DEFAULT_VALIDITY, + validity: includeMeta + ? buildValidity('identity', [effectiveRelation], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, directRel.possibility) + : minimalValidity(directRel.validity !== undefined + ? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) + : DEFAULT_VALIDITY), // A denied decision (possibility 0) must not leak the // relation's reliability — the rule-collection path zeroes it. reliability: directRel.possibility > 0 @@ -223,8 +226,8 @@ export class AuthorizationChecker { result.meta.cache = cacheHint; } - // Cache the result using composite key - if (!explain && !hasPartialGraph) { + // Cache the result using composite key (meta-less form only) + if (!explain && !hasPartialGraph && !includeMeta) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; @@ -348,7 +351,7 @@ export class AuthorizationChecker { const finalResult = { possibility: resPossibility || 0, reliability: res.reliability !== undefined ? res.reliability : 1.0, - validity: res.validity || DEFAULT_VALIDITY, + validity: includeMeta ? (res.validity || DEFAULT_VALIDITY) : minimalValidity(res.validity || DEFAULT_VALIDITY), ...(collectValues && res.collectedValues && Array.isArray(res.collectedValues) && { collectedValues: res.collectedValues }), ...(includeMeta && { meta: { @@ -566,7 +569,7 @@ export class AuthorizationChecker { const result = { possibility: maxAllow, reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0, - validity: finalValidity, + validity: includeMeta ? finalValidity : minimalValidity(finalValidity), ...(includeMeta && { meta: { allow: bestAllow, diff --git a/src/authorization/CompiledEvaluator.js b/src/authorization/CompiledEvaluator.js index 05d9318..0f1882e 100644 --- a/src/authorization/CompiledEvaluator.js +++ b/src/authorization/CompiledEvaluator.js @@ -144,9 +144,7 @@ export class CompiledEvaluator { const result = { possibility: relationStrength, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, - validity: directRel.validity !== undefined - ? buildValidity('identity', [relName], [normalizeValidity(directRel.validity)], 1, relationStrength) - : DEFAULT_VALIDITY, + validity: buildValidity('identity', [relName], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, relationStrength), possibility_allow: relationStrength, possibility_deny: 0, ...(includeMeta && { diff --git a/src/authorization/rules/DirectRule.js b/src/authorization/rules/DirectRule.js index ba77d12..dabb6cf 100644 --- a/src/authorization/rules/DirectRule.js +++ b/src/authorization/rules/DirectRule.js @@ -71,9 +71,7 @@ export class DirectRule extends BaseRule { const authResult = { possibility: relationStrength, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, - validity: directRel.validity !== undefined - ? this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength) - : this._defaultValidity(), + validity: this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength), possibility_allow: relationStrength, // For binary mode possibility_deny: 0, // DirectRule doesn't deny ...(includeMeta && { diff --git a/src/core/Arbiter.js b/src/core/Arbiter.js index 3090f28..05ca1aa 100644 --- a/src/core/Arbiter.js +++ b/src/core/Arbiter.js @@ -24,6 +24,12 @@ export class Arbiter { constructor(options = {}) { this.options = options; + + // Audit affordance (caller-wired, zero cost when absent): invoked once + // per check with a minimal decision record. The engine does NOT store + // audit state — the caller owns persistence and retention. The record + // is built only when the hook exists, so the default path is untouched. + this._auditHook = typeof options.audit === 'function' ? options.audit : null; // Injectable cache factory (DI): defaults to the built-in SimpleLRUCache. // Pass options.cacheFactory to substitute another cache implementation. diff --git a/src/core/ExplainSerializer.js b/src/core/ExplainSerializer.js index 3e17ab7..ec847d4 100644 --- a/src/core/ExplainSerializer.js +++ b/src/core/ExplainSerializer.js @@ -92,6 +92,10 @@ export class ExplainSerializer { result: allowPossibility > 0, possibility: allowPossibility, ...(reliability !== undefined ? { reliability } : {}), + // The validity block is the full debugging detail on the explain + // (internal) surface — the public check result carries only the + // minimal label unless includeMeta is set. + ...(result?.validity ? { validity: result.validity } : {}), determinedBy: ruleMeta?.reason || result?.reason || null, level, ruleType: ruleMeta?.ruleType || ruleMeta?.type || null, diff --git a/src/core/arbiter/ArbiterChecks.js b/src/core/arbiter/ArbiterChecks.js index 259b9f0..ce99f3a 100644 --- a/src/core/arbiter/ArbiterChecks.js +++ b/src/core/arbiter/ArbiterChecks.js @@ -13,6 +13,20 @@ export class ArbiterChecks { // Extract epsilon/delta if present and pass through if (options.partialGraph && !options.partialGraphContext) { + // Reject oversized overlays BEFORE allocating the context: the + // caller-supplied partial graph is untrusted input, and the check is + // the allocation point for every decision (DoS surface). + const policy = this.arbiter.partialGraphPolicy || {}; + const rels = Array.isArray(options.partialGraph.relations) ? options.partialGraph.relations : []; + const nodes = Array.isArray(options.partialGraph.nodes) ? options.partialGraph.nodes : []; + const maxRelations = policy.maxRelations !== undefined ? policy.maxRelations : 2000; + const maxNodes = policy.maxNodes !== undefined ? policy.maxNodes : 1000; + if (rels.length > maxRelations) { + throw new Error(`Partial graph exceeds max relations: ${rels.length} > ${maxRelations}`); + } + if (nodes.length > maxNodes) { + throw new Error(`Partial graph exceeds max nodes: ${nodes.length} > ${maxNodes}`); + } options.partialGraphContext = this.arbiter._createPartialGraphContext(options.partialGraph); } @@ -53,6 +67,12 @@ export class ArbiterChecks { clientStateId, ...options }; + // Audit needs the full validity detail (sources, conflict mass) even + // when the caller did not ask for includeMeta; the hook is opt-in, so + // this only ever costs on audit-enabled deployments. + if (this.arbiter._auditHook && authOptions.includeMeta === undefined) { + authOptions.includeMeta = true; + } // Ensure epsilon/delta are present if specified if (epsilon !== undefined) authOptions.epsilon = epsilon; if (delta !== undefined) authOptions.delta = delta; @@ -71,6 +91,22 @@ export class ArbiterChecks { } if (options.explain) return result; + + if (this.arbiter._auditHook) { + this.arbiter._auditHook({ + timestamp: Date.now(), + userKey, + relation, + objectKey, + decision: result.possibility > 0 ? 'allow' : 'deny', + possibility: result.possibility, + binary: !!options.binary, + partialGraphUsed: !!options.partialGraphContext, + validityLabel: result.validity ? result.validity.label : null, + sources: (result.validity && result.validity.sources) || [] + }); + } + return result; } diff --git a/src/core/graph/CondensedGraphBinary.js b/src/core/graph/CondensedGraphBinary.js index 52aeb6d..9a2937b 100644 --- a/src/core/graph/CondensedGraphBinary.js +++ b/src/core/graph/CondensedGraphBinary.js @@ -88,6 +88,20 @@ class BinaryReader { this.decoder = new TextDecoder(); } + /** + * Bounds guard: the buffer is caller-supplied (snapshot restore input), + * so every read must be validated BEFORE the DataView/Uint8Array throws + * or over-allocates. A clean error here prevents RangeError crashes and + * oversized allocations from malformed buffers. + */ + ensure(bytes) { + if (bytes < 0 || this.offset + bytes > this.view.byteLength) { + throw new Error( + `CondensedGraphBinary: read past end of buffer (offset ${this.offset}, need ${bytes}, length ${this.view.byteLength})` + ); + } + } + align(alignment) { if (alignment <= 1) return; const padding = (alignment - (this.offset % alignment)) % alignment; @@ -95,36 +109,42 @@ class BinaryReader { } readUint8() { + this.ensure(1); const value = this.view.getUint8(this.offset); this.offset += 1; return value; } readUint16() { + this.ensure(2); const value = this.view.getUint16(this.offset, true); this.offset += 2; return value; } readUint32() { + this.ensure(4); const value = this.view.getUint32(this.offset, true); this.offset += 4; return value; } readInt32() { + this.ensure(4); const value = this.view.getInt32(this.offset, true); this.offset += 4; return value; } readFloat64() { + this.ensure(8); const value = this.view.getFloat64(this.offset, true); this.offset += 8; return value; } readBytes(length) { + this.ensure(length); const bytes = new Uint8Array(this.view.buffer, this.offset, length); this.offset += length; return bytes; diff --git a/src/core/possibility.js b/src/core/possibility.js new file mode 100644 index 0000000..2a93b0e --- /dev/null +++ b/src/core/possibility.js @@ -0,0 +1,93 @@ +/** + * src/core/possibility.js — explicit possibilistic semantics. + * + * The single authoritative home for what the engine's operators MEAN in + * possibility theory, in the spirit of Martin's plausibility contours and + * Cella's FVN fusion (see zig-contour/spec/02-fusion.md). + * + * Possibility theory axioms the engine honors: + * - Possibility is maxitive: plausibility of a disjunction is the MAX of + * the plausibilities. This is why OR (union) is max and why max is the + * only operator that is already "valid and normalized" under arbitrary + * dependence (Cella §7.2). + * - A possibility value is a ranking, not a probability: 0.6 means "this + * is the least plausible option whose negation is not strictly more + * plausible" — it does NOT mean a 60% chance. + * - Conjunctive combination (AND) is the MIN, and the raw min is an + * UNVALIDIFIED ranking: the arbitrary-regime validification is + * min(1, K*gamma_min) (Prop 1 bound), and the pre-normalization + * suppression (conflict mass = 1 - gamma) must be surfaced, never + * silently dropped. + * - The PRODUCT operator (P(A)*(1-P(B)) in exclusion/defeasible) has no + * linear validification under arbitrary dependence (Cella Thm 4). Its + * outputs are ranking heuristics, never valid possibilities. + * - Interior OWA weights produce convex mixtures of plausibilities, + * which are not maxitive; they are scoring heuristics (nonMaxitive). + * - Reliability is NOT a possibility: it is a scalar confidence that + * adapts the fusion ranking (a fusion-step combiner), and must never + * be presented as, or conflated with, the plausibility value. + */ + +export const OPERATOR_SEMANTICS = { + identity: { + class: 'disjunctive', + label: 'a single source; the value is the source\'s plausibility', + validity: 'preserves the source label', + validified: false, + nonMaxitive: false + }, + max: { + class: 'disjunctive', + label: 'OR: plausibility of the union of options', + validity: 'already valid and normalized under arbitrary dependence (Cella §7.2); fused label = weakest source label', + validified: false, + nonMaxitive: false + }, + min: { + class: 'conjunctive', + label: 'AND: plausibility of the conjunction of options (unvalidified ranking)', + validity: 'approximate at best; arbitrary-regime validification min(1, K*gamma_min) (Prop 1); conflict mass surfaced', + validified: true, + nonMaxitive: false + }, + product: { + class: 'heuristic', + label: 'exclusion/defeasible: P(A)*(1-P(B)) style combination', + validity: 'always heuristic — the product operator has no linear validification under arbitrary dependence (Cella Thm 4)', + validified: false, + nonMaxitive: false + }, + owa: { + class: 'heuristic', + label: 'ordered weighted average with interior weights: convex mixture of sorted plausibilities', + validity: 'always heuristic — convex mixtures are not maxitive', + validified: false, + nonMaxitive: true + }, + mixed: { + class: 'heuristic', + label: 'multiple contributing rules with different operators', + validity: 'heuristic if any contributor is heuristic or non-maxitive', + validified: false, + nonMaxitive: false + } +}; + +export function operatorSemantics(operator) { + return OPERATOR_SEMANTICS[operator] || OPERATOR_SEMANTICS.mixed; +} + +/** + * The decision semantics every consumer MUST respect: + * - possibility is a plausibility ranking; thresholds are ranking + * cutoffs, not probability bounds + * - a heuristic-labeled result carries no validity guarantee + * - reliability adapts the ranking; it does not measure plausibility + * - conflictMass is a debugging diagnostic, not a decision input + */ +export const DECISION_SEMANTICS = Object.freeze({ + possibilityIsRanking: true, + reliabilityIsAdaptation: true, + conflictMassIsDiagnostic: true, + heuristicResultsCarryNoGuarantee: true +}); diff --git a/src/core/validity.js b/src/core/validity.js index 40af60b..a00d854 100644 --- a/src/core/validity.js +++ b/src/core/validity.js @@ -143,6 +143,21 @@ export function buildValidity(optsOrOp, sourcesArg = [], sourceLabelsArg = [], K } /** Merge several rule-level validity blocks into one (weakest label, unioned sources). */ +/** + * Minimal public form: label + operator only. Conflict mass, the + * validified value, sources, and the non-maxitive flag are debugging / + * internal-logging detail (re-entrant tracing practice: verbose traces are + * opt-in, never on the default result path). + */ +export function minimalValidity(block) { + if (!block) return { label: 'heuristic', operator: 'identity', regime: 'arbitrary' }; + return { + label: block.label || 'heuristic', + operator: block.operator || 'identity', + regime: block.regime || 'arbitrary' + }; +} + export function mergeValidity(blocks) { const present = (blocks || []).filter(Boolean); if (present.length === 0) { diff --git a/tests/rigor/validity-parity.test.js b/tests/rigor/validity-parity.test.js index a41e23f..d7abc1f 100644 --- a/tests/rigor/validity-parity.test.js +++ b/tests/rigor/validity-parity.test.js @@ -33,7 +33,8 @@ function mk() { describe('Possibilistic validity metadata (rigor)', () => { it('FIXED: labels, operators, conflict mass, validification per kind', () => { - // direct unlabeled -> heuristic identity + // direct unlabeled -> heuristic identity; default carries the minimal + // public block (label/operator), the full detail is includeMeta-only { const a = mk(); a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); @@ -41,8 +42,9 @@ describe('Possibilistic validity metadata (rigor)', () => { const r = a.check('u:0', 't', 'd:0'); assert.equal(r.validity.label, 'heuristic'); assert.equal(r.validity.operator, 'identity'); - assert.deepEqual(r.validity.sources, ['r1']); - assert.equal(r.validity.conflictMass, 0); + const r2 = a.check('u:0', 't', 'd:0', { includeMeta: true }); + assert.deepEqual(r2.validity.sources, ['r1']); + assert.equal(r2.validity.conflictMass, 0); } // labeled direct propagates its label { @@ -60,7 +62,7 @@ describe('Possibilistic validity metadata (rigor)', () => { const r = a.check('u:0', 't', 'd:0'); assert.equal(r.validity.label, 'conformal', 'weakest label wins through max'); assert.equal(r.validity.operator, 'max'); - assert.equal(r.validity.nonMaxitive, false); + assert.equal(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.nonMaxitive, false); } // interior OWA is non-maxitive heuristic { @@ -70,7 +72,7 @@ describe('Possibilistic validity metadata (rigor)', () => { a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' }); const r = a.check('u:0', 't', 'd:0'); assert.equal(r.validity.operator, 'owa'); - assert.equal(r.validity.nonMaxitive, true); + assert.equal(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.nonMaxitive, true); assert.equal(r.validity.label, 'heuristic', 'averaging never claims validity'); } // min fusion: approximate, conflict mass, validification @@ -82,8 +84,9 @@ describe('Possibilistic validity metadata (rigor)', () => { const r = a.check('u:0', 't', 'd:0'); assert.equal(r.validity.operator, 'min'); assert.equal(r.validity.label, 'approximate', 'unvalidified conjunctive is approximate at best'); - assert.ok(Math.abs(r.validity.conflictMass - (1 - 0.5)) < 1e-9, 'conflict mass = 1 - possibility'); - assert.equal(r.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5'); + const rDetail = a.check('u:0', 't', 'd:0', { includeMeta: true }); + assert.ok(Math.abs(rDetail.validity.conflictMass - (1 - 0.5)) < 1e-9, 'conflict mass = 1 - possibility'); + assert.equal(rDetail.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5'); } // product operators are heuristic even with labeled sources { @@ -103,7 +106,7 @@ describe('Possibilistic validity metadata (rigor)', () => { a.addRelation('g:0', 'viewer', 'd:0', { possibility: 0.6 }); const r = a.check('u:0', 't', 'd:0'); assert.equal(r.validity.operator, 'min'); - assert.ok(Math.abs(r.validity.conflictMass - 0.4) < 1e-9, 'chain conflict mass'); + assert.ok(Math.abs(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.conflictMass - 0.4) < 1e-9, 'chain conflict mass'); } // reliability and validity stay distinct { @@ -143,3 +146,70 @@ describe('Possibilistic validity metadata (rigor)', () => { assert.ok(inv && inv.passed, `validity helpers violated in ${inv?.failureCount} cases`); }); }); + +describe('Security affordances (rigor)', () => { + it('FIXED: default results carry only the minimal validity; detail is opt-in', () => { + const a = mk(); + a.setRelationConfig('t', { intersection: [child('r1'), child('r2')] }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' }); + // default: minimal block — no conflict mass, no sources, no validified value + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.label, 'approximate'); + assert.equal(r.validity.operator, 'min'); + assert.ok(!('conflictMass' in r.validity), 'conflict mass is not on the default result'); + assert.ok(!('validifiedPossibility' in r.validity), 'validified value is not on the default result'); + assert.ok(!('sources' in r.validity), 'sources are not on the default result'); + // includeMeta: full debugging detail + const r2 = a.check('u:0', 't', 'd:0', { includeMeta: true }); + assert.equal(r2.validity.conflictMass, 0.5); + assert.equal(r2.validity.validifiedPossibility, 1); + assert.deepEqual(r2.validity.sources, ['r1', 'r2']); + // explain (internal surface) carries the full block + const e = a.explain('u:0', 't', 'd:0'); + assert.equal(e.decision?.validity?.conflictMass, 0.5, 'explain carries full validity'); + }); + + it('FIXED: audit hook emits one record per check; absent by default', () => { + const records = []; + const a = new Arbiter({ audit: (entry) => records.push(entry) }); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(records.length, 1, 'one audit record per check'); + assert.equal(records[0].decision, 'allow'); + assert.equal(records[0].possibility, 0.8); + assert.equal(records[0].validityLabel, 'finite_sample'); + assert.deepEqual(records[0].sources, ['r1']); + assert.equal(records[0].partialGraphUsed, false); + // overlay checks flag partial usage + a.check('u:0', 't', 'd:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'r1', dst: 'd:0', possibility: 0.9 }] } }); + assert.equal(records[1].partialGraphUsed, true); + // no hook -> no records, no crash + const plain = new Arbiter(); + plain.addNode('u:0', 'user'); + plain.addNode('d:0', 'doc'); + plain.setRelationConfig('t', { type: 'direct', relation: 'r1' }); + plain.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8 }); + assert.equal(plain.check('u:0', 't', 'd:0').possibility, 0.8); + }); + + it('FIXED: oversized partial graphs are rejected before allocation', () => { + const a = new Arbiter({ partialGraphPolicy: { maxRelations: 2, maxNodes: 3 } }); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + a.setRelationConfig('t', { type: 'direct', relation: 'owner' }); + assert.throws( + () => a.check('u:0', 't', 'd:0', { partialGraph: { relations: [{}, {}, {}, {}] } }), + /exceeds max relations/, + 'relation limit enforced pre-allocation' + ); + assert.throws( + () => a.check('u:0', 't', 'd:0', { partialGraph: { nodes: [{}, {}, {}, {}] } }), + /exceeds max nodes/, + 'node limit enforced pre-allocation' + ); + }); +});