From 40fa1f8eb20d1c7e79ab56fc9f06c7344465c816 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 20:26:57 -0700 Subject: [PATCH] feat: value-gated direct rules, per-value cache keys, NOT negation; fix value-object checks 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 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. --- package.json | 2 +- src/authorization/AuthorizationChecker.js | 28 +++++++++++++++++++-- src/authorization/RuleEvaluator.js | 30 ++++++++++++++++++++--- src/authorization/rules/DirectRule.js | 20 +++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ed2318d..5a3e470 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index fc62735..a7fb6ce 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -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); diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index 1c33341..690c020 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -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); diff --git a/src/authorization/rules/DirectRule.js b/src/authorization/rules/DirectRule.js index 84079bb..5cae179 100644 --- a/src/authorization/rules/DirectRule.js +++ b/src/authorization/rules/DirectRule.js @@ -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';