2 Commits

Author SHA1 Message Date
John Dvorak a875089b65 chore: bump version to 1.0.8 for CI publish
CI / test (push) Failing after 6m53s
CI / benchmark (push) Has been skipped
CI / publish (push) Has been skipped
2026-08-03 20:36:32 -07:00
John Dvorak 40fa1f8eb2 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.
2026-08-03 20:26:57 -07:00
4 changed files with 73 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@arbiter/core", "name": "@arbiter/core",
"version": "1.0.6", "version": "1.0.8",
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.", "description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
"license": "ISC", "license": "ISC",
"author": "", "author": "",
+25 -1
View File
@@ -172,6 +172,29 @@ export class AuthorizationChecker {
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }), ...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
reason: 'threshold_not_met' reason: 'threshold_not_met'
}; };
} 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 { } else {
result = { result = {
possibility: directRel.possibility, possibility: directRel.possibility,
@@ -308,7 +331,8 @@ export class AuthorizationChecker {
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta && const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled; !binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
const ruleCacheKey = canCacheRuleResult const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(userId, relation, objectId) ? this._getRuleResultCacheKey(userId, relation, objectId) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null; : null;
if (canCacheRuleResult) { if (canCacheRuleResult) {
const cached = this.decisionCache.getRule(ruleCacheKey); const cached = this.decisionCache.getRule(ruleCacheKey);
+26 -4
View File
@@ -67,7 +67,8 @@ export class RuleEvaluator {
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned && !binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
options.cacheRuleResult !== false; options.cacheRuleResult !== false;
const ruleCacheKey = canCacheRuleResult const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) ? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null; : null;
if (canCacheRuleResult) { if (canCacheRuleResult) {
@@ -95,17 +96,17 @@ export class RuleEvaluator {
if (rule.union) { if (rule.union) {
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); 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) { if (rule.intersection) {
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); 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) { if (rule.exclusion) {
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); 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 }) // Defeasible configs ({ type: 'defeasible', when/unless/never/always })
@@ -191,6 +192,27 @@ export class RuleEvaluator {
return result; 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) { _getComparatorCacheSignature(rule) {
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand); const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand); const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
+20
View File
@@ -57,6 +57,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 relationStrength = directRel.possibility;
const _source = directRel.source || 'persistent'; const _source = directRel.source || 'persistent';
const _allowMeta = { const _allowMeta = {