feat: value-gated direct rules, per-value cache keys, NOT negation; fix value-object checks
CI / test (push) Successful in 6m50s
CI / benchmark (push) Successful in 52s
CI / publish (push) Has been skipped

DirectRule and the direct-check fast path now enforce expectedValue from a DSL
literal (balance(user, 5)) or a per-check value-object override, closing a
silent over-grant where any edge of a value-carrying fact matched. Rule and
decision caches append :v<value> so different amounts never share a key.
NOT negation is applied after union/intersection/exclusion evaluation (the
only negation site was dead code in evaluateLogical), so NOT x now returns
1 - p instead of the raw possibility.
This commit is contained in:
John Dvorak
2026-08-03 20:26:57 -07:00
parent f6e3ae1922
commit 40fa1f8eb2
4 changed files with 73 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@arbiter/core",
"version": "1.0.6",
"version": "1.0.7",
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
"license": "ISC",
"author": "",
+26 -2
View File
@@ -172,7 +172,30 @@ export class AuthorizationChecker {
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
reason: 'threshold_not_met'
};
} else {
} else if (config?.expectedValue !== undefined && directRel.value !== config.expectedValue) {
// Value gate (mirrors DirectRule): a config carrying expectedValue
// (from a DSL literal like balance(user, 5)) only grants when the
// edge carries exactly that value.
result = {
possibility: 0,
reliability: 0,
...(includeMeta && {
meta: { reason: 'value_mismatch', expectedValue: config.expectedValue, actualValue: directRel.value }
}),
reason: 'value_mismatch'
};
} else if (options.expectedValue !== undefined && directRel.value !== options.expectedValue) {
// Per-check value-object override (check(user, can_withdraw, 5)):
// the value arrives on the check options, not the config.
result = {
possibility: 0,
reliability: 0,
...(includeMeta && {
meta: { reason: 'value_mismatch', expectedValue: options.expectedValue, actualValue: directRel.value }
}),
reason: 'value_mismatch'
};
} else {
result = {
possibility: directRel.possibility,
validity: includeMeta
@@ -308,7 +331,8 @@ export class AuthorizationChecker {
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(userId, relation, objectId)
? this._getRuleResultCacheKey(userId, relation, objectId) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null;
if (canCacheRuleResult) {
const cached = this.decisionCache.getRule(ruleCacheKey);
+26 -4
View File
@@ -67,7 +67,8 @@ export class RuleEvaluator {
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
options.cacheRuleResult !== false;
const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null;
if (canCacheRuleResult) {
@@ -95,17 +96,17 @@ export class RuleEvaluator {
if (rule.union) {
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.intersection) {
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.exclusion) {
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
}
// Defeasible configs ({ type: 'defeasible', when/unless/never/always })
@@ -191,6 +192,27 @@ export class RuleEvaluator {
return result;
}
/**
* Apply the NOT operator's negation to a logical rule result. The DSL emits
* `NOT x` as { type:'logical', intersection:{ rules:[x], negate:true } };
* the individual union/intersection/exclusion evaluators don't honor the
* flag, so it is applied here after evaluation (possibility -> 1 - p).
*/
_applyNegate(result, rule) {
const negate = Boolean(
(rule.union && rule.union.negate) ||
(rule.intersection && rule.intersection.negate) ||
(rule.exclusion && rule.exclusion.negate)
);
if (!negate || !result || typeof result.possibility !== 'number') return result;
return {
...result,
possibility: Math.max(0, 1 - result.possibility),
...(result.meta ? { meta: { ...result.meta, negated: true } } : {}),
reason: 'negated'
};
}
_getComparatorCacheSignature(rule) {
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
+20
View File
@@ -56,6 +56,26 @@ export class DirectRule extends BaseRule {
})
}, []);
}
// Value gate: a rule carrying expectedValue (from a DSL literal like
// `balance(user, 5)`, or a per-check value-object override like
// `check(user, can_withdraw, 5)`) only grants when the matched edge carries
// exactly that value. Without it a value-carrying fact matches ANY edge of
// the relation, silently over-granting past the declared amount.
const expectedValue = rule.expectedValue !== undefined ? rule.expectedValue : options.expectedValue;
if (expectedValue !== undefined && directRel.value !== expectedValue) {
return this._createStandardResult({
possibility: 0,
...(includeMeta && {
meta: {
ruleType: 'direct',
reason: 'value_mismatch',
expectedValue,
actualValue: directRel.value
}
})
}, []);
}
const relationStrength = directRel.possibility;
const _source = directRel.source || 'persistent';