js-rigor: security affordances — gated provenance, audit hook, DoS hardening, explicit semantics

Per the trust-boundary direction (the caller owns evidence validation):

- Explicit possibilistic semantics module (src/core/possibility.js): the
  single authoritative home for what each operator means (max = disjunctive
  already-valid; min = unvalidified conjunctive ranking with the K-
  validification and surfaced conflict mass; product = Thm-4 heuristic;
  interior OWA = non-maxitive heuristic; reliability = adaptation, never
  conflated with plausibility).
- Provenance is opt-in (re-entrant tracing practice): default check
  results carry only {label, operator, regime}; conflictMass,
  validifiedPossibility, sources, and nonMaxitive appear only under
  includeMeta and on the explain surface. The direct-check cache now
  caches only the meta-less form — includeMeta callers always get a fresh
  full evaluation (previously a cached minimal result was served for
  includeMeta requests, silently stripping detail).
- Audit affordance: new Arbiter({ audit }) emits one record per check
  (decision, possibility, binary, partialGraphUsed, validityLabel,
  sources). The engine stores nothing — the caller owns persistence;
  zero cost when the hook is absent (and the full validity is forced only
  on audit-enabled deployments).
- DoS hardening: partial-graph size limits are enforced BEFORE the
  context allocation (the caller-supplied overlay is the per-check
  allocation point); the CondensedGraphBinary reader gained full bounds
  guards so malformed snapshot buffers fail with clean errors instead of
  RangeError crashes or oversized allocations.
- New security-affordance pins: gating, audit records, and pre-allocation
  limits.
This commit is contained in:
John Dvorak
2026-08-02 09:28:57 -07:00
parent 58e8b0e030
commit 86729715f1
10 changed files with 268 additions and 25 deletions
+14 -11
View File
@@ -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 { Arbiter } from '../core/Arbiter.js';
import { RuleEvaluator } from './RuleEvaluator.js'; import { RuleEvaluator } from './RuleEvaluator.js';
import { RuleCollector } from './RuleCollector.js'; import { RuleCollector } from './RuleCollector.js';
@@ -86,7 +86,7 @@ export class AuthorizationChecker {
// Check cache first using composite key (if caching is enabled) // Check cache first using composite key (if caching is enabled)
let cachedResult = null; let cachedResult = null;
let cacheHint = null; let cacheHint = null;
if (!hasPartialGraph && this.decisionCache.directEnabled) { if (!hasPartialGraph && this.decisionCache.directEnabled && !includeMeta) {
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey); const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null; cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null;
@@ -111,8 +111,9 @@ export class AuthorizationChecker {
reason: 'missing_node' reason: 'missing_node'
}; };
// Cache the result (only when no partial graph — same guard as success path) // Cache the result (only when no partial graph and the default
if (!explain && !hasPartialGraph) { // meta-less form — includeMeta callers get a fresh full evaluation)
if (!explain && !hasPartialGraph && !includeMeta) {
this._cacheDirectCheckResult(userKey, relation, objectKey, result); this._cacheDirectCheckResult(userKey, relation, objectKey, result);
} }
return result; return result;
@@ -163,9 +164,11 @@ export class AuthorizationChecker {
} else { } else {
result = { result = {
possibility: directRel.possibility, possibility: directRel.possibility,
validity: directRel.validity !== undefined validity: includeMeta
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) ? buildValidity('identity', [effectiveRelation], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, directRel.possibility)
: DEFAULT_VALIDITY, : minimalValidity(directRel.validity !== undefined
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility)
: DEFAULT_VALIDITY),
// A denied decision (possibility 0) must not leak the // A denied decision (possibility 0) must not leak the
// relation's reliability — the rule-collection path zeroes it. // relation's reliability — the rule-collection path zeroes it.
reliability: directRel.possibility > 0 reliability: directRel.possibility > 0
@@ -223,8 +226,8 @@ export class AuthorizationChecker {
result.meta.cache = cacheHint; result.meta.cache = cacheHint;
} }
// Cache the result using composite key // Cache the result using composite key (meta-less form only)
if (!explain && !hasPartialGraph) { if (!explain && !hasPartialGraph && !includeMeta) {
this._cacheDirectCheckResult(userKey, relation, objectKey, result); this._cacheDirectCheckResult(userKey, relation, objectKey, result);
} }
return result; return result;
@@ -348,7 +351,7 @@ export class AuthorizationChecker {
const finalResult = { const finalResult = {
possibility: resPossibility || 0, possibility: resPossibility || 0,
reliability: res.reliability !== undefined ? res.reliability : 1.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 }), ...(collectValues && res.collectedValues && Array.isArray(res.collectedValues) && { collectedValues: res.collectedValues }),
...(includeMeta && { ...(includeMeta && {
meta: { meta: {
@@ -566,7 +569,7 @@ export class AuthorizationChecker {
const result = { const result = {
possibility: maxAllow, possibility: maxAllow,
reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0, reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0,
validity: finalValidity, validity: includeMeta ? finalValidity : minimalValidity(finalValidity),
...(includeMeta && { ...(includeMeta && {
meta: { meta: {
allow: bestAllow, allow: bestAllow,
+1 -3
View File
@@ -144,9 +144,7 @@ export class CompiledEvaluator {
const result = { const result = {
possibility: relationStrength, possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
validity: directRel.validity !== undefined validity: buildValidity('identity', [relName], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, relationStrength),
? buildValidity('identity', [relName], [normalizeValidity(directRel.validity)], 1, relationStrength)
: DEFAULT_VALIDITY,
possibility_allow: relationStrength, possibility_allow: relationStrength,
possibility_deny: 0, possibility_deny: 0,
...(includeMeta && { ...(includeMeta && {
+1 -3
View File
@@ -71,9 +71,7 @@ export class DirectRule extends BaseRule {
const authResult = { const authResult = {
possibility: relationStrength, possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
validity: directRel.validity !== undefined validity: this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength),
? this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength)
: this._defaultValidity(),
possibility_allow: relationStrength, // For binary mode possibility_allow: relationStrength, // For binary mode
possibility_deny: 0, // DirectRule doesn't deny possibility_deny: 0, // DirectRule doesn't deny
...(includeMeta && { ...(includeMeta && {
+6
View File
@@ -24,6 +24,12 @@ export class Arbiter {
constructor(options = {}) { constructor(options = {}) {
this.options = 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. // Injectable cache factory (DI): defaults to the built-in SimpleLRUCache.
// Pass options.cacheFactory to substitute another cache implementation. // Pass options.cacheFactory to substitute another cache implementation.
+4
View File
@@ -92,6 +92,10 @@ export class ExplainSerializer {
result: allowPossibility > 0, result: allowPossibility > 0,
possibility: allowPossibility, possibility: allowPossibility,
...(reliability !== undefined ? { reliability } : {}), ...(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, determinedBy: ruleMeta?.reason || result?.reason || null,
level, level,
ruleType: ruleMeta?.ruleType || ruleMeta?.type || null, ruleType: ruleMeta?.ruleType || ruleMeta?.type || null,
+36
View File
@@ -13,6 +13,20 @@ export class ArbiterChecks {
// Extract epsilon/delta if present and pass through // Extract epsilon/delta if present and pass through
if (options.partialGraph && !options.partialGraphContext) { 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); options.partialGraphContext = this.arbiter._createPartialGraphContext(options.partialGraph);
} }
@@ -53,6 +67,12 @@ export class ArbiterChecks {
clientStateId, clientStateId,
...options ...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 // Ensure epsilon/delta are present if specified
if (epsilon !== undefined) authOptions.epsilon = epsilon; if (epsilon !== undefined) authOptions.epsilon = epsilon;
if (delta !== undefined) authOptions.delta = delta; if (delta !== undefined) authOptions.delta = delta;
@@ -71,6 +91,22 @@ export class ArbiterChecks {
} }
if (options.explain) return result; 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; return result;
} }
+20
View File
@@ -88,6 +88,20 @@ class BinaryReader {
this.decoder = new TextDecoder(); 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) { align(alignment) {
if (alignment <= 1) return; if (alignment <= 1) return;
const padding = (alignment - (this.offset % alignment)) % alignment; const padding = (alignment - (this.offset % alignment)) % alignment;
@@ -95,36 +109,42 @@ class BinaryReader {
} }
readUint8() { readUint8() {
this.ensure(1);
const value = this.view.getUint8(this.offset); const value = this.view.getUint8(this.offset);
this.offset += 1; this.offset += 1;
return value; return value;
} }
readUint16() { readUint16() {
this.ensure(2);
const value = this.view.getUint16(this.offset, true); const value = this.view.getUint16(this.offset, true);
this.offset += 2; this.offset += 2;
return value; return value;
} }
readUint32() { readUint32() {
this.ensure(4);
const value = this.view.getUint32(this.offset, true); const value = this.view.getUint32(this.offset, true);
this.offset += 4; this.offset += 4;
return value; return value;
} }
readInt32() { readInt32() {
this.ensure(4);
const value = this.view.getInt32(this.offset, true); const value = this.view.getInt32(this.offset, true);
this.offset += 4; this.offset += 4;
return value; return value;
} }
readFloat64() { readFloat64() {
this.ensure(8);
const value = this.view.getFloat64(this.offset, true); const value = this.view.getFloat64(this.offset, true);
this.offset += 8; this.offset += 8;
return value; return value;
} }
readBytes(length) { readBytes(length) {
this.ensure(length);
const bytes = new Uint8Array(this.view.buffer, this.offset, length); const bytes = new Uint8Array(this.view.buffer, this.offset, length);
this.offset += length; this.offset += length;
return bytes; return bytes;
+93
View File
@@ -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
});
+15
View File
@@ -143,6 +143,21 @@ export function buildValidity(optsOrOp, sourcesArg = [], sourceLabelsArg = [], K
} }
/** Merge several rule-level validity blocks into one (weakest label, unioned sources). */ /** 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) { export function mergeValidity(blocks) {
const present = (blocks || []).filter(Boolean); const present = (blocks || []).filter(Boolean);
if (present.length === 0) { if (present.length === 0) {
+78 -8
View File
@@ -33,7 +33,8 @@ function mk() {
describe('Possibilistic validity metadata (rigor)', () => { describe('Possibilistic validity metadata (rigor)', () => {
it('FIXED: labels, operators, conflict mass, validification per kind', () => { 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(); const a = mk();
a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); 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'); const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.label, 'heuristic'); assert.equal(r.validity.label, 'heuristic');
assert.equal(r.validity.operator, 'identity'); assert.equal(r.validity.operator, 'identity');
assert.deepEqual(r.validity.sources, ['r1']); const r2 = a.check('u:0', 't', 'd:0', { includeMeta: true });
assert.equal(r.validity.conflictMass, 0); assert.deepEqual(r2.validity.sources, ['r1']);
assert.equal(r2.validity.conflictMass, 0);
} }
// labeled direct propagates its label // labeled direct propagates its label
{ {
@@ -60,7 +62,7 @@ describe('Possibilistic validity metadata (rigor)', () => {
const r = a.check('u:0', 't', 'd:0'); const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.label, 'conformal', 'weakest label wins through max'); assert.equal(r.validity.label, 'conformal', 'weakest label wins through max');
assert.equal(r.validity.operator, '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 // 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' }); a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
const r = a.check('u:0', 't', 'd:0'); const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'owa'); 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'); assert.equal(r.validity.label, 'heuristic', 'averaging never claims validity');
} }
// min fusion: approximate, conflict mass, validification // min fusion: approximate, conflict mass, validification
@@ -82,8 +84,9 @@ describe('Possibilistic validity metadata (rigor)', () => {
const r = a.check('u:0', 't', 'd:0'); const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'min'); assert.equal(r.validity.operator, 'min');
assert.equal(r.validity.label, 'approximate', 'unvalidified conjunctive is approximate at best'); 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'); const rDetail = a.check('u:0', 't', 'd:0', { includeMeta: true });
assert.equal(r.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5'); 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 // 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 }); a.addRelation('g:0', 'viewer', 'd:0', { possibility: 0.6 });
const r = a.check('u:0', 't', 'd:0'); const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'min'); 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 // 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`); 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'
);
});
});