import { Arbiter } from '../core/Arbiter.js'; import { RuleEvaluator } from './RuleEvaluator.js'; import { RuleCollector } from './RuleCollector.js'; import { ScratchBuffers } from './ScratchBuffers.js'; import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js'; import { DecisionCache } from './DecisionCache.js'; export class AuthorizationChecker { constructor(arbiter, options = {}) { this.arbiter = arbiter; this.ruleEvaluator = new RuleEvaluator(arbiter); this.ruleCollector = new RuleCollector(arbiter); // DecisionCache port — RF-03 closure. When not injected, default to // an ArbiterDecisionCache that forwards to the arbiter's existing // cache fields, preserving the behavior every test relies on. this.decisionCache = options.decisionCache || new DecisionCache(arbiter); } check(userKey, relation, objectKey, options = {}) { // Handle backward compatibility if (options instanceof Set) { options = { _visited: options, _currentRelation: arguments[4] }; } if (!options.scratch) { options.scratch = new ScratchBuffers(); } const { _visited = new Set(), _currentRelation = null, // Threshold-based early exit options minAllowPossibility = null, maxDenyPossibility = null, fastPath = false, // NEW: Binary mode for ultra-fast decisive authorization binary = false } = options; const explain = options.explain === true; const includeMeta = options.includeMeta === undefined ? explain : options.includeMeta; const trackEvaluation = options.trackEvaluation === undefined ? (explain ? true : false) : options.trackEvaluation; let collectValues = options.collectValues; const hasPartialGraph = !!options.partialGraphContext; // BINARY MODE: Ultra-fast decisive authorization if (binary) { return this._checkBinary(userKey, relation, objectKey, { _visited, _currentRelation, minAllowPossibility: minAllowPossibility || 0.8, // Default strict threshold maxDenyPossibility: maxDenyPossibility || 0.8, includeMeta, trackEvaluation, // Preserve every caller option (partialGraphContext, clientStateId, // ...) — the binary path previously rebuilt a six-field object and // silently dropped the partial graph, so binary checks denied // grants that the normal path allowed. ...options }); } const config = this.arbiter.relationConfigs.get(relation); if (collectValues === undefined) { collectValues = explain || config?._needsValues || false; } // CI-001 fix: the previous `!config._compiled` guard made the // direct-check fast path dead. `setRelationConfig` compiles // synchronously and sets `_compiled` immediately (ArbiterConfig.js), // so the guard was always false in production and `_cacheDirectCheckResult` // never fired. The fast-path decision is independent of compilation state. // // Narrowing: derived evidence rules normalize to type 'direct' but carry // `dependsOn` (e.g. session_authenticated_action { userIsActive(user) }). // Their semantics live in the compiled dependency evaluation, not in a raw // direct lookup — the fast path must not bypass them, or gate checks deny // with 'no_relation' where the full path derives possibility 1.0. const hasDerivedDependencies = Array.isArray(config?.dependsOn) && config.dependsOn.length > 0; const useFastPath = config && config.type === 'direct' && !config.union && !config.intersection && !config.exclusion && !hasDerivedDependencies; const effectiveThreshold = minAllowPossibility ?? config?.minPossibility ?? null; // A direct config may override the relation it checks (rule.relation). // The fast path must honor that override or it diverges from the // rule-evaluation path (e.g. can_read -> gateway_context_ref). const effectiveRelation = (config && (config.relation || config.rel)) || relation; if (useFastPath) { // Check cache first using composite key (if caching is enabled) let cachedResult = null; let cacheHint = null; if (!hasPartialGraph && this.decisionCache.directEnabled) { const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); const [hitResult, status] = this.decisionCache.peekDirect(cacheKey); cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null; if (status === 'hit') { cacheHint = { hit: true, result: hitResult }; if (!explain) { return hitResult; } } else if (explain && status === 'expired') { cacheHint = { hit: false, result: hitResult }; } } // Ultra-fast direct check const userId = this.arbiter.resolveNodeId(userKey, options); const objectId = this.arbiter.resolveNodeId(objectKey, options); if (userId === undefined || objectId === undefined) { const result = { possibility: 0, ...(includeMeta && { meta: { reason: 'missing_node' } }), reason: 'missing_node' }; // Cache the result (only when no partial graph — same guard as success path) if (!explain && !hasPartialGraph) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; } if (_visited.size) { const useKeyedVisited = this._getVisitedMode(_visited); const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null; if (useKeyedVisited) { if (_visited.has(visitKey)) { return { possibility: 0, ...(includeMeta && { meta: { reason: 'cycle' } }), reason: 'cycle' }; } } else { for (const visited of _visited) { if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) { return { possibility: 0, ...(includeMeta && { meta: { reason: 'cycle' } }), reason: 'cycle' }; } } } } // Direct index lookup let directRel = null; let partialRel = null; if (hasPartialGraph) { partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId); } directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel; let result; if (directRel) { // Check threshold-based early exit if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) { result = { possibility: 0, ...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }), reason: 'threshold_not_met' }; } else { result = { possibility: directRel.possibility, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, ...(includeMeta && { meta: { allow: { ruleType: 'direct', reason: 'direct', source: directRel.source || 'persistent', layer_name: directRel.layer_name || null, source_class: directRel.source_class || null, reducer_applied: directRel.reducer_applied || null } } }), reason: 'direct_match' }; // Collect values if present if (collectValues && directRel.value !== undefined) { result.collectedValues = [{ value: directRel.value, source: 'direct_relation', relation: relation, userKey: userKey, objectKey: objectKey }]; } } } else { // Fast-path miss: attach remediation when the missing (effective) // relation is declared as an injectable witness source — the caller // needs to know which relation to satisfy. const missingConfig = this.arbiter.relationConfigs.get(effectiveRelation); let remediation = null; if (missingConfig && missingConfig.injectable) { remediation = buildRemediation(null, { status: 'required', additional_options: [{ relation: effectiveRelation, object: objectKey }] }); } result = { possibility: 0, reliability: 0, ...(includeMeta && { meta: { reason: 'no_relation' } }), reason: 'no_relation', ...(remediation ? { remediation } : {}) }; } if (explain && cacheHint) { result.meta = result.meta || {}; result.meta.cache = cacheHint; } // Cache the result using composite key if (!explain && !hasPartialGraph) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; } this.arbiter.relationManager._ensureIndicesBuilt(); const userId = this.arbiter.resolveNodeId(userKey, options); const objectId = this.arbiter.resolveNodeId(objectKey, options); if (userId === undefined || objectId === undefined) { const missingNode = userId === undefined ? userKey : objectKey; const missingType = userId === undefined ? 'user' : 'object'; return { possibility: 0, ...(includeMeta && { meta: { reason: 'missing_node', missingNode, missingType } }), reason: 'missing_node' }; } if (!config) { return { possibility: 0, ...(includeMeta && { meta: { reason: 'no_config' } }), reason: 'no_config' }; } const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta && !binary && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled; const ruleCacheKey = canCacheRuleResult ? this._getRuleResultCacheKey(userId, relation, objectId) : null; if (canCacheRuleResult) { const cached = this.decisionCache.getRule(ruleCacheKey); if (cached) { return cached; } } const useKeyedVisited = this._getVisitedMode(_visited); const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null; if (useKeyedVisited) { if (_visited.has(visitKey)) { return { possibility: 0, ...(includeMeta && { meta: { reason: 'cycle' } }), reason: 'cycle' }; } } else { for (const visited of _visited) { if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) { return { possibility: 0, ...(includeMeta && { meta: { reason: 'cycle' } }), reason: 'cycle' }; } } } _visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey }); const shouldTrackEvaluation = trackEvaluation && includeMeta; const evaluationErrors = shouldTrackEvaluation ? [] : null; const evaluationPath = shouldTrackEvaluation ? { userKey, relation, objectKey, config, rules: [], visitedPath: Array.from(_visited), errors: evaluationErrors } : null; const evalOptions = { ...options, fastPath, minAllowPossibility, minPossibility: minAllowPossibility, maxDenyPossibility, trackEvaluation: shouldTrackEvaluation, collectValues, includeMeta }; if (config.union || config.intersection || config.exclusion) { const res = this.ruleEvaluator.evaluateRule( userId, userKey, objectId, objectKey, config, _visited, relation, evalOptions ); if (evaluationPath) { evaluationPath.type = 'logical_operator'; evaluationPath.operator = config.union ? 'union' : config.intersection ? 'intersection' : 'exclusion'; evaluationPath.result = res; } // Handle both old format (possibility_allow) and new format (possibility) const resPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility; // Extract allow/deny from meta to avoid conflicts const { allow: metaAllow, deny: metaDeny, ...restMeta } = res.meta || {}; const remediation = buildRemediation(extractRemediation(res)); const finalResult = { possibility: resPossibility || 0, reliability: res.reliability !== undefined ? res.reliability : 1.0, ...(includeMeta && { meta: { ...restMeta, // Spread meta without allow/deny allow: res.meta_allow || metaAllow, // Use meta_allow if available, otherwise meta.allow deny: res.meta_deny || metaDeny, ...(evaluationPath && { evaluation: evaluationPath }), earlyExit: res.meta?.earlyExit || false } }), ...(remediation ? { remediation } : {}), reason: res.reason || 'logical_operator_evaluation' }; return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult); } const rules = this.ruleCollector.collectRules(config, null, relation); if (evaluationPath) { evaluationPath.type = 'rule_collection'; evaluationPath.collectedRules = rules.length; } let maxAllow = 0; let maxDeny = 0; let bestAllowReliability = 0; let bestDenyReliability = 0; let bestAllow = null; let bestDeny = null; let reason = undefined; let allRuleResults = shouldTrackEvaluation ? [] : null; let allCollectedValues = collectValues ? [] : null; // Collect values from all evaluated rules const remediationOptions = []; let ruleIndex = 0; for (const rule of rules) { const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, evalOptions); // Handle both old format (possibility_allow/deny) and new format (possibility) const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility; const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0; const resMeta = res.meta_allow || res.meta?.allow || null; mergeRemediationOptions(remediationOptions, extractRemediation(res)); if (evaluationErrors && (res.error || res.reason === 'evaluation_error')) { evaluationErrors.push({ ruleIndex, level: rule.ruleType || null, relation: rule.relation || relation, message: res.details?.error || res.reason || 'evaluation_error', stack: res.details?.stack || null }); } // Track each rule evaluation if (shouldTrackEvaluation) { const ruleEvaluation = { rule: { type: rule.type, relation: rule.relation, ruleType: rule.ruleType, reverse: rule.reverse }, result: { possibility_allow: resAllowPossibility, possibility_deny: resDenyPossibility, reason: res.reason }, meta: { allow: resMeta, deny: res.meta_deny, full: res.meta || null } }; allRuleResults.push(ruleEvaluation); } ruleIndex += 1; // Preserve specific reasons from rule evaluations if (res.reason === 'cycle') reason = 'cycle'; if (res.reason === 'no_path') reason = 'no_path'; if (res.reason === 'no_similar_users') reason = 'no_similar_users'; if (res.reason === 'no_similar_authorized') reason = 'no_similar_authorized'; if (res.reason === 'no_similar_objects') reason = 'no_similar_objects'; if (res.reason === 'no_user_objects') reason = 'no_user_objects'; if (res.reason === 'no_target_embedding') reason = 'no_target_embedding'; if (res.reason === 'no_embedding') reason = 'no_embedding'; // Add chain rule reasons if (res.reason === 'chain_path_found') reason = 'chain_path_found'; if (res.reason === 'no_chain_path_found') reason = 'no_chain_path_found'; if (res.reason === 'no_chain_steps_defined') reason = 'no_chain_steps_defined'; // Add parent rule reasons if (res.reason === 'no_parent_relationship_found') reason = 'no_parent_relationship_found'; if (res.reason === 'no_parent_relationship_path_above_threshold') reason = 'no_parent_relationship_path_above_threshold'; // Add multi-hop rule reasons if (res.reason === 'no_multihop_path_found') reason = 'no_multihop_path_found'; if (res.reason === 'multihop_path_found') reason = 'multihop_path_found'; // Add direct rule reasons if (res.reason === 'direct_match') reason = 'direct_match'; if (res.reason === 'no_direct_match') reason = 'no_direct_match'; // Add tuple-to-userset reasons if (res.reason === 'tuple_to_userset_match') reason = 'tuple_to_userset_match'; if (res.reason === 'no_tuple_to_userset_match') reason = 'no_tuple_to_userset_match'; // Add relational comparator reasons if (res.reason === 'values_compared_comparison_true') reason = 'values_compared_comparison_true'; if (res.reason === 'values_compared_comparison_false') reason = 'values_compared_comparison_false'; if (res.reason === 'values_compared_comparison_insufficient') reason = 'values_compared_comparison_insufficient'; if (resAllowPossibility > maxAllow) { maxAllow = resAllowPossibility; bestAllow = resMeta; bestAllowReliability = res.reliability !== undefined ? res.reliability : 1.0; } if (resDenyPossibility > maxDeny) { maxDeny = resDenyPossibility; bestDeny = res.meta_deny; bestDenyReliability = res.reliability !== undefined ? res.reliability : 1.0; } // Fast path early exit checks if (fastPath) { let shouldExit = false; let exitReason = null; // Check allow threshold if (minAllowPossibility !== null && maxAllow >= minAllowPossibility) { shouldExit = true; exitReason = 'allow_threshold_met'; } // Check deny threshold if (maxDenyPossibility !== null && maxDeny >= maxDenyPossibility) { shouldExit = true; exitReason = 'deny_threshold_met'; } if (shouldExit) { if (evaluationPath) { evaluationPath.rules = allRuleResults; evaluationPath.earlyExit = { reason: exitReason, threshold: exitReason === 'allow_threshold_met' ? minAllowPossibility : maxDenyPossibility, actualValue: exitReason === 'allow_threshold_met' ? maxAllow : maxDeny, rulesEvaluated: allRuleResults.length, totalRules: rules.length }; Arbiter.log('early exit triggered:', evaluationPath.earlyExit); } const finalResult = { possibility: maxAllow, reliability: maxAllow > 0 ? bestAllowReliability : 0, ...(includeMeta && { meta: { allow: bestAllow, deny: bestDeny, ...(evaluationPath && { evaluation: evaluationPath }), earlyExit: true, maxDeny: maxDeny } }), reason: exitReason }; return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult); } } // Collect values from all evaluated rules if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) { allCollectedValues.push(...res.collectedValues); } } if (evaluationPath) { evaluationPath.rules = allRuleResults; evaluationPath.finalResult = { maxAllow, maxDeny, reason }; } // Determine final reason based on evaluation let finalReason = reason; if (maxAllow === 0 && maxDeny === 0) { finalReason = reason || 'no_matching_rule'; } else if (maxAllow > 0 && maxDeny > 0) { finalReason = 'conflicting_rules'; } else if (maxAllow > 0) { finalReason = 'allow_rule_matched'; } else if (maxDeny > 0) { finalReason = 'deny_rule_matched'; } const remediation = maxAllow === 0 ? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' }) : null; const result = { possibility: maxAllow, reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0, ...(includeMeta && { meta: { allow: bestAllow, deny: bestDeny, ...(evaluationPath && { evaluation: evaluationPath }), maxDeny: maxDeny, // Keep deny info in meta for debugging ...(collectValues && allCollectedValues.length > 0 && { collectedValues: allCollectedValues }), ...(maxAllow === 0 && remediation ? { remediation } : {}) } }), ...(maxAllow === 0 && remediation ? { remediation } : {}), reason: finalReason }; // Add collectedValues at top level if there are any if (collectValues && allCollectedValues.length > 0) { result.collectedValues = allCollectedValues; } return this._maybeCacheRuleResult(result, relation, ruleCacheKey, canCacheRuleResult); } _getRuleResultCacheKey(userId, relation, objectId) { return this.arbiter.keyManager.createCompositeKey(userId, relation, objectId); } _maybeCacheRuleResult(result, relation, cacheKey, enabled) { if (!enabled || !cacheKey) return result; this.decisionCache.setRule(cacheKey, result); this.decisionCache.trackRuleKeyForRelation(relation, cacheKey); return result; } /** * Binary mode: Ultra-fast decisive authorization with strict thresholds * Returns simple allow/deny decisions with minimal overhead */ _checkBinary(userKey, relation, objectKey, options = {}) { const { _visited = new Set(), _currentRelation = null, minAllowPossibility = 0.8, maxDenyPossibility = 0.8, includeMeta = false, trackEvaluation = false } = options; // Track evaluation for binary mode const evaluation = (includeMeta || trackEvaluation) ? { type: 'binary', userKey, relation, objectKey, thresholds: { minAllowPossibility, maxDenyPossibility }, evaluationStarted: Date.now() } : null; const userId = this.arbiter.resolveNodeId(userKey, options); const objectId = this.arbiter.resolveNodeId(objectKey, options); if (userId === undefined || objectId === undefined) { return { possibility: 0, reason: 'missing_node', binary: true, ...(evaluation && { evaluation }) }; } // Check for cycles using efficient approach const useKeyedVisited = this._getVisitedMode(_visited); const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null; if (useKeyedVisited) { if (_visited.has(visitKey)) { return { possibility: 0, reason: 'cycle', binary: true, ...(evaluation && { evaluation }) }; } } else { for (const visited of _visited) { if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) { return { possibility: 0, reason: 'cycle', binary: true, ...(evaluation && { evaluation }) }; } } } _visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey }); const config = this.arbiter.relationConfigs.get(relation); if (!config) { return { possibility: 0, reason: 'no_config', binary: true, ...(evaluation && { evaluation }) }; } if (evaluation) { evaluation.rulesEvaluated = 0; evaluation.earlyTermination = false; } // Fast path for direct relations in binary mode if (config.type === 'direct') { let directRel = null; let partialRel = null; // A direct config may alias an underlying relation (config.relation); // the checked relation name alone is the wrong lookup key. const effectiveRelation = config.relation || relation; if (options.partialGraphContext) { partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId); } directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel; if (directRel) { const allow = directRel.possibility >= minAllowPossibility; const deny = false; // Direct relations don't have explicit deny values if (evaluation) { evaluation.rulesEvaluated = 1; evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } return { possibility: directRel.possibility, reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), allow, deny }; } else { if (evaluation) { evaluation.rulesEvaluated = 1; evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } return { possibility: 0, reason: 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), allow: false, deny: false }; } } // Handle logical operators with binary evaluation if (config.union || config.intersection || config.exclusion) { const res = this.ruleEvaluator.evaluateRule( userId, userKey, objectId, objectKey, config, _visited, relation, { fastPath: true, minAllowPossibility, maxDenyPossibility, binary: true, ...options } ); if (evaluation) { evaluation.rulesEvaluated = 1; evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } // Handle both old format (possibility_allow/deny) and new format (possibility) const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility; const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0; // Binary decision based on strict thresholds const allow = resAllowPossibility >= minAllowPossibility; const deny = resDenyPossibility >= maxDenyPossibility; return { possibility: resAllowPossibility || 0, reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), allow, // Keep for backwards compatibility deny // Keep for backwards compatibility }; } // Collect and evaluate rules with early termination const rules = this.ruleCollector.collectRules(config, null, relation); let maxAllow = 0; let maxDeny = 0; for (const rule of rules) { if (evaluation) { evaluation.rulesEvaluated++; } const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, { fastPath: true, minAllowPossibility, maxDenyPossibility, binary: true, ...options }); // Prefer the continuous possibility; binarized possibility_allow (1|0) // must only be a fallback so reported strengths stay continuous. const resAllowPossibility = typeof res.possibility === 'number' ? res.possibility : (res.possibility_allow !== undefined ? res.possibility_allow : 0); const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0; if (resAllowPossibility > maxAllow) { maxAllow = resAllowPossibility; } if (resDenyPossibility > maxDeny) { maxDeny = resDenyPossibility; } // BINARY EARLY TERMINATION: Stop as soon as we hit a threshold if (maxAllow >= minAllowPossibility) { if (evaluation) { evaluation.earlyTermination = true; evaluation.terminationReason = 'allow_threshold_met'; evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } return { possibility: maxAllow, reason: 'allow', binary: true, ...(evaluation && { evaluation }), allow: true, // Keep for backwards compatibility deny: false // Keep for backwards compatibility }; } if (maxDeny >= maxDenyPossibility) { if (evaluation) { evaluation.earlyTermination = true; evaluation.terminationReason = 'deny_threshold_met'; evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } return { possibility: 0, reason: 'deny', binary: true, ...(evaluation && { evaluation }), allow: false, // Keep for backwards compatibility deny: true // Keep for backwards compatibility }; } // Limit rule evaluation in binary mode for performance. // maxBinaryRules defaults to Infinity (no cap) — set lower if you // understand the false-denial risk for policies with many rules. if (evaluation && evaluation.rulesEvaluated >= (options.maxBinaryRules ?? Infinity)) { evaluation.earlyTermination = true; evaluation.terminationReason = 'max_rules_evaluated'; break; } } if (evaluation) { evaluation.evaluationCompleted = Date.now(); evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted; } // Final binary decision const allow = maxAllow >= minAllowPossibility; const deny = maxDeny >= maxDenyPossibility; return { possibility: maxAllow, reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence', binary: true, ...(evaluation && { evaluation }), allow, // Keep for backwards compatibility deny // Keep for backwards compatibility }; } /** * Generate cache key for direct check * @private */ _getDirectCheckCacheKey(userKey, relation, objectKey) { // Use composite key for better performance - keyManager handles string-to-ID conversion internally return this.arbiter.keyManager.createCompositeKey( this.arbiter.keyManager.getStringId(userKey), relation, this.arbiter.keyManager.getStringId(objectKey) ); } _cacheDirectCheckResult(userKey, relation, objectKey, result) { if (!this.decisionCache.directEnabled) return; const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); this.decisionCache.setDirect(cacheKey, result); } _getVisitedMode(visited) { if (visited.__fastKeyed !== undefined) { return visited.__fastKeyed; } if (visited.size === 0) { visited.__fastKeyed = true; return true; } for (const entry of visited) { const isKeyed = typeof entry === 'string'; visited.__fastKeyed = isKeyed; return isKeyed; } visited.__fastKeyed = true; return true; } _getVisitedKey(userId, relation, objectId) { const relationId = this.arbiter.keyManager._getRelationId(relation); return `${userId}|${relationId}|${objectId}`; } invalidateRuleCaches(relation) { // Invalidate ChainRule caches if it exists if (this.ruleEvaluator && this.ruleEvaluator.ruleHandlers && this.ruleEvaluator.ruleHandlers.chain) { this.ruleEvaluator.ruleHandlers.chain._invalidateAllChainCaches(); } // Invalidate other rule caches as needed // TODO: Add invalidation for other rule types that have caches } }