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