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:
@@ -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,
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user