import { DirectRule } from './rules/DirectRule.js'; import { ComputedRule } from './rules/ComputedRule.js'; import { ParentRule } from './rules/ParentRule.js'; import { TupleToUsersetRule } from './rules/TupleToUsersetRule.js'; import { MultiHopRule } from './rules/MultiHopRule.js'; import { LogicalOperators } from './rules/LogicalOperators.js'; import { RelationalComparatorRouter } from './rules/RelationalComparatorRouter.js'; import { ChainRule } from './rules/ChainRule.js'; import { ChallengeRule } from './rules/ChallengeRule.js'; import { ValueContext } from './ValueContext.js'; export class RuleEvaluator { constructor(arbiter) { this.arbiter = arbiter; this.ruleHandlers = { direct: new DirectRule(arbiter), computed: new ComputedRule(arbiter), parent: new ParentRule(arbiter), tuple_to_userset: new TupleToUsersetRule(arbiter), multi_hop: new MultiHopRule(arbiter), relational_comparator: new RelationalComparatorRouter(arbiter, this), chain: new ChainRule(arbiter), challenge: new ChallengeRule(arbiter) }; this.logicalOperators = new LogicalOperators(arbiter, this); this._valueRequirementCache = new Map(); } evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) { const { binary = false, valueContext = null, includeMeta = true } = options; // Convert string keys to numeric IDs if needed const numericUserId = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId; const numericObjectId = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId; const needsValueContext = valueContext !== null && valueContext !== undefined ? true : (options.collectValues === true || this._ruleRequiresValues(rule, new Set())); const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null; const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext); const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta }; // A caller-pinned clock (options.now) makes the result per-clock: the // result caches must not serve a decision evaluated at another time. const temporalPinned = options.now !== undefined && options.now !== null; const canCacheRuleResult = !!this.arbiter.ruleResultCache && currentRelation && !binary && !options.partialGraphContext && !includeMeta && !temporalPinned && options.cacheRuleResult !== false; const ruleCacheKey = canCacheRuleResult ? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) : null; if (canCacheRuleResult) { const cached = this.arbiter.ruleResultCache.get(ruleCacheKey); if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) { this.arbiter.ruleResultCacheStats.hits++; if (collectValues && finalValueContext && cached.result?.collectedValues?.length) { const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule'); finalValueContext.addCollectedValues(cached.result.collectedValues, ruleType, rule); } return cached.result; } this.arbiter.ruleResultCacheStats.misses++; } // BINARY MODE: Fast evaluation with early termination if (binary) { return this._evaluateRuleBinary(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); } if (rule.union) { const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); return this._maybeCacheRuleResult(result, 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); } if (rule.exclusion) { const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); } // Defeasible configs ({ type: 'defeasible', when/unless/never/always }) // have no rule-handler entry; route them to the defeasible evaluator // with the components normalized to the union/intersection format. if (this.logicalOperators._isDefeasibleLogic(rule)) { const result = this.logicalOperators.evaluateDefeasible( numericUserId, userKey, numericObjectId, objectKey, wrapDefeasibleComponents(rule), visited, currentRelation, enhancedOptions ); return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); } // For other rule types, evaluate normally first // Shorthand operand objects ({ relation: 'owner' } inside logical rules, // or caller-supplied raw configs) carry no type: treat them as direct // rules instead of failing with unknown_rule_type. const handler = this.ruleHandlers[rule.type || (rule.relation || rule.rel || rule.label || rule.name ? 'direct' : null)]; if (!handler) { return { possibility_allow: 0, possibility_deny: 0, ...(collectValues && { collectedValues: [] }), ...(includeMeta && { meta_allow: null, meta_deny: null }), reason: 'unknown_rule_type' }; } const result = handler.evaluate(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); // Add collected values to value context if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) { finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule); } // Return original result if inference didn't help or values weren't sufficient return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); } _getRuleResultCacheKey(userId, relation, objectId, rule) { const base = this.arbiter.keyManager.createCompositeKey(userId, relation, objectId); let suffix = 'rule'; if (rule) { if (rule.union || rule.intersection || rule.exclusion) { suffix = 'logical'; } else if (rule.type === 'direct' || (!rule.type && (rule.relation || rule.rel || rule.label || rule.name) && !rule.union && !rule.intersection && !rule.exclusion)) { // Shorthand operands ({ relation: 'editor' }) dispatch to the direct // handler but carry no type; without this, every shorthand child of // a logical rule shares one cache key and the first child's result // is served for all of them. suffix = `direct:${rule.relation || rule.rel || rule.label || rule.name || 'unknown'}`; } else if (rule.type === 'tuple_to_userset') { suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`; } else if (rule.type === 'chain' && Array.isArray(rule.steps)) { const stepSig = rule.steps.map(step => { if (typeof step === 'string') return step; return `${step.relation || 'unknown'}:${step.direction || 'out'}`; }).join('>'); suffix = `chain:${stepSig}`; } else if (rule.type === 'multi_hop') { suffix = `multihop:${rule.relation || 'unknown'}:${rule.maxDepth || 'auto'}`; } else if (rule.type === 'parent') { suffix = `parent:${rule.parentRelation || 'unknown'}`; } else if (rule.type === 'relational_comparator') { const signature = this._getComparatorCacheSignature(rule); suffix = `comparator:${rule.comparator || 'unknown'}:${signature}`; } else { suffix = rule.type || 'rule'; } } return `${base}|${suffix}`; } _maybeCacheRuleResult(result, relation, cacheKey, enabled) { if (!enabled || !cacheKey || !relation) return result; this.arbiter.ruleResultCache.set(cacheKey, { result, timestamp: Date.now() }); this.arbiter._cacheRuleResult(relation, cacheKey); return result; } _getComparatorCacheSignature(rule) { const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand); const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand); return `L(${leftSig})|R(${rightSig})`; } _getComparatorOperandSignature(operand) { if (!operand || typeof operand !== 'object') return 'none'; const valueRelation = operand.valueRelation || operand.valueRelationResolved; if (valueRelation) return `relation:${valueRelation}`; const nestedRule = operand.rule || null; if (!nestedRule || typeof nestedRule !== 'object') return 'value'; if (nestedRule.type === 'direct') { const rel = nestedRule.relation || nestedRule.rel || nestedRule.label || nestedRule.name || 'unknown'; return `${nestedRule.reverse ? 'rev:' : ''}${rel}`; } if (nestedRule.type === 'logical') { if (nestedRule._optimized?.kind === 'direct_list') { const rels = nestedRule._optimized.direct .map((direct) => `${direct.reverse ? 'rev:' : ''}${direct.relation}`) .join(','); return `direct_list:${nestedRule.op || 'logical'}:${rels}`; } if (Array.isArray(nestedRule.children)) { const childKeys = nestedRule.children.map((child) => { if (child?.type === 'direct') { return `${child.reverse ? 'rev:' : ''}${child.relation}`; } return child?.type || 'unknown'; }).join(','); return `logical:${nestedRule.op || 'logical'}:${childKeys}`; } return `logical:${nestedRule.op || 'logical'}`; } if (nestedRule.union) { const rules = Array.isArray(nestedRule.union.rules) ? nestedRule.union.rules : (Array.isArray(nestedRule.union) ? nestedRule.union : []); const rels = rules.map((child) => { if (child?.type === 'direct') { const rel = child.relation || child.rel || child.label || child.name || 'unknown'; return `${child.reverse ? 'rev:' : ''}${rel}`; } return child?.type || 'unknown'; }).join(','); return `logical:union:${rels}`; } if (nestedRule.intersection) { const rules = Array.isArray(nestedRule.intersection.rules) ? nestedRule.intersection.rules : (Array.isArray(nestedRule.intersection) ? nestedRule.intersection : []); const rels = rules.map((child) => { if (child?.type === 'direct') { const rel = child.relation || child.rel || child.label || child.name || 'unknown'; return `${child.reverse ? 'rev:' : ''}${rel}`; } return child?.type || 'unknown'; }).join(','); return `logical:intersection:${rels}`; } if (nestedRule.exclusion) { const rules = Array.isArray(nestedRule.exclusion.rules) ? nestedRule.exclusion.rules : (Array.isArray(nestedRule.exclusion) ? nestedRule.exclusion : []); const rels = rules.map((child) => { if (child?.type === 'direct') { const rel = child.relation || child.rel || child.label || child.name || 'unknown'; return `${child.reverse ? 'rev:' : ''}${rel}`; } return child?.type || 'unknown'; }).join(','); return `logical:exclusion:${rels}`; } return nestedRule.type || 'value'; } /** * Binary mode rule evaluation: Ultra-fast with strict thresholds and value context */ _evaluateRuleBinary(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) { const { minAllowPossibility = 0.8, maxDenyPossibility = 0.8, fastPath = true, valueContext = null, includeMeta = true } = options; const needsValueContext = valueContext !== null && valueContext !== undefined ? true : (options.collectValues === true || this._ruleRequiresValues(rule, new Set())); const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null; const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext); const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta }; // Handle logical operators in binary mode if (rule.union) { return this.logicalOperators.evaluateUnion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, { ...enhancedOptions, binary: true, fastPath: true }); } if (rule.intersection) { return this.logicalOperators.evaluateIntersection(userId, userKey, objectId, objectKey, rule, visited, currentRelation, { ...enhancedOptions, binary: true, fastPath: true }); } if (rule.exclusion) { return this.logicalOperators.evaluateExclusion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, { ...enhancedOptions, binary: true, fastPath: true }); } // Defeasible rules (when/unless/never/always/requires) have no entry in // ruleHandlers — normal mode reaches them via the compiled evaluator, // but binary mode short-circuits to this method, where a bare handler // lookup returned unknown_rule_type (a hard binary deny for every // defeasible config). Route them through the defeasible evaluator, // which implements binary/threshold modes natively. if (this.logicalOperators._isDefeasibleLogic(rule)) { // The compiled path ran defeasible rules in normal mode regardless of // the binary flag (compiled.mode defaulted to 'normal'), so the binary // decision was always the thresholded normal combination. Preserve // that contract: force normal mode; the checker applies the binary // thresholds to the result. const wrapped = wrapDefeasibleComponents(rule); wrapped.mode = 'normal'; return this.logicalOperators.evaluateDefeasible(userId, userKey, objectId, objectKey, wrapped, visited, currentRelation, { ...enhancedOptions, binary: false, fastPath: false }); } // Get rule handler const handler = this.ruleHandlers[rule.type]; if (!handler) { return { possibility_allow: 0, possibility_deny: 0, ...(collectValues && { collectedValues: [] }), ...(includeMeta && { meta_allow: null, meta_deny: null }), reason: 'unknown_rule_type', binary: true }; } // Evaluate rule with binary options const result = handler.evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, { ...enhancedOptions, binary: true, fastPath: true }); // Add collected values to value context in binary mode too // This is important if downstream rules in a logical operator need them if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) { finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule); } // Binary mode means we only care about allow/deny based on thresholds const allow = result.possibility_allow >= minAllowPossibility; const deny = result.possibility_deny >= maxDenyPossibility; return { possibility_allow: allow ? 1 : 0, possibility_deny: deny ? 1 : 0, // Preserve the real continuous possibility so logical operators // (union/intersection/exclusion) can aggregate child strengths — // binarized 0/1 values alone corrupt max/min/product aggregation. possibility: result.possibility ?? result.possibility_allow ?? (allow ? 1 : 0), ...(includeMeta && { meta_allow: allow ? (result.meta_allow || result.meta) : null, meta_deny: deny ? result.meta_deny : null }), reason: result.reason || (allow ? 'binary_allow_threshold_met' : (deny ? 'binary_deny_threshold_met' : 'binary_threshold_not_met')), binary: true, collectedValues: result.collectedValues || [] // Preserve collected values }; } /** * Get value context statistics for debugging */ getValueContextStats(options = {}) { const { valueContext } = options; return valueContext ? valueContext.getStats() : null; } _ruleRequiresValues(rule, visitedRelations) { if (!rule || typeof rule !== 'object') return false; if (rule.type === 'relational_comparator' || rule.qualitative === true) { rule._needsValues = true; return true; } if (rule.type === 'computed') { const relationName = rule.relation; if (relationName) { if (visitedRelations.has(relationName)) return false; visitedRelations.add(relationName); if (this._valueRequirementCache.has(relationName)) { return this._valueRequirementCache.get(relationName); } const config = this.arbiter.relationConfigs.get(relationName); const requiresValues = this._ruleRequiresValues(config, visitedRelations); this._valueRequirementCache.set(relationName, requiresValues); if (config && typeof config === 'object') { config._needsValues = requiresValues; } return requiresValues; } } const traverse = (child) => this._ruleRequiresValues(child, visitedRelations); if (Array.isArray(rule.union) && rule.union.some(traverse)) { rule._needsValues = true; return true; } if (rule.union && Array.isArray(rule.union.rules) && rule.union.rules.some(traverse)) { rule._needsValues = true; return true; } if (Array.isArray(rule.intersection) && rule.intersection.some(traverse)) { rule._needsValues = true; return true; } if (rule.intersection && Array.isArray(rule.intersection.rules) && rule.intersection.rules.some(traverse)) { rule._needsValues = true; return true; } if (Array.isArray(rule.exclusion) && rule.exclusion.some(traverse)) { rule._needsValues = true; return true; } if (rule.when && this._ruleRequiresValues(rule.when, visitedRelations)) { rule._needsValues = true; return true; } if (rule.unless && this._ruleRequiresValues(rule.unless, visitedRelations)) { rule._needsValues = true; return true; } if (rule.requires && this._ruleRequiresValues(rule.requires, visitedRelations)) { rule._needsValues = true; return true; } rule._needsValues = false; return false; } } /** * Wrap raw defeasible components (single rules like * `{ when: { type: 'direct', relation: 'x' } }`) into the object shape * LogicalOperators.evaluateDefeasible expects * (`when.intersection.rules[]`, `unless.union.rules[]`, `always.direct`). * Logical (union/intersection/exclusion) components pass through. */ function wrapDefeasibleComponents(rule) { // The defeasible evaluator's normalize expects the component format // when.intersection.rules[] / unless.union.rules[] (not bare {rules}). const wrapUnion = (component) => { if (!component) return component; if (component.union) return component; if (Array.isArray(component)) return { union: { rules: component } }; return { union: { rules: [component] } }; }; const wrapIntersection = (component) => { if (!component) return component; if (component.intersection) return component; if (Array.isArray(component)) return { intersection: { rules: component } }; return { intersection: { rules: [component] } }; }; return { ...rule, when: rule.when ? wrapIntersection(rule.when) : rule.when, unless: rule.unless ? wrapUnion(rule.unless) : rule.unless, never: rule.never ? wrapUnion(rule.never) : rule.never, requires: rule.requires ? wrapUnion(rule.requires) : rule.requires, always: rule.always ? { direct: rule.always.direct || rule.always } : rule.always }; }