Files
core/src/authorization/rules/ChainRule.js
T

720 lines
27 KiB
JavaScript
Raw Normal View History

import { BaseRule } from './BaseRule.js';
import { Arbiter } from '../../core/Arbiter.js';
import { OWAFusion } from '../../utils/OWAFusion.js';
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
/**
* ChainRule - Evaluates access by following a chain of relations and collecting values along the path
*
* This rule enables traversing through multiple entities via relations and collects
* values from each step in the path. Perfect for scenarios like:
* "Sum balances from all accounts user can debit from"
*
* CLEAN SEMANTICS:
* - Authorization: Based on path reachability to target entity (returns raw possibility)
* - Value Collection: Collects values along the path with full path tracking
* - Uses ValueContext for efficient caching and aggregation
*
* Path Semantics:
* - Along chain: MIN operator (possibilistic conjunction)
* - Across paths: MAX/OWA operator (disjunctive)
* - Values: Collected with full path metadata for aggregation at logical level
*
* Configuration:
* {
* type: 'chain',
* steps: [
* { relation: 'can_debit', direction: 'out' }, // user → accounts
* { relation: 'has_balance', direction: 'out' } // accounts → currency
* ],
* // Value collection (optional - defaults to enabled)
* collectValues: true, // Whether to collect values (default: true)
* valueFilters: { // Optional filters for value collection
* steps: [0, 1], // Which steps to collect from (default: all)
* relations: ['has_balance'], // Which relations to collect from (default: step relations)
* minValue: 0, // Minimum value threshold
* maxValue: 1000 // Maximum value threshold
* },
* valueAggregation: 'sum', // How to pre-aggregate VALUES ('sum', 'max', 'min', 'average')
*
* // Standard rule fields
* reverse: false // Reverse traversal direction (default: false)
* }
*/
export class ChainRule extends BaseRule {
constructor(arbiter) {
super(arbiter);
// Chain-specific caching with HyperbolicLRUCache for better memory management
this.maxCacheSize = 2000;
this.cacheTTL = 600000; // 10 minutes
this.pathCacheTTL = 300000; // 5 minutes
// Only create caches if caching is not disabled
if (!arbiter.disableCaching && !arbiter.disableChainCaching) {
// Chain result cache with HyperbolicLRUCache
this.chainResultCache = arbiter.cacheFactory(this.maxCacheSize, {
onEvict: (key, value) => {
// Optional: track evictions for debugging
this.stats = this.stats || {};
this.stats.evictedResults = (this.stats.evictedResults || 0) + 1;
}
});
} else {
this.chainResultCache = null;
}
}
/**
* Evaluate chain rule with smart reachability optimization
* Uses reachability check as a hint, but doesn't fail fast if TreeCover index is uncertain
*/
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
// Normalize rule to get reverse flag
const normalizedRule = this._normalizeRule(rule);
const { reverse = false } = normalizedRule;
// Check if PLTC bypass is requested (for testing/ground truth computation)
// Also skip when a partial graph is present — PLTC is built from persistent data only
const { bypassPLTC = false } = options;
const hasPartialGraph = !!options.partialGraphContext;
if (!bypassPLTC && !hasPartialGraph) {
// Smart reachability check: PLTC is 100% accurate, so we can fail fast on false
// Use backward index for reverse chains
const direction = reverse ? 'backward' : 'forward';
const reachabilityResult = this._quickReachabilityCheck(userKey, objectKey, { direction });
if (reachabilityResult === false) {
// PLTC is 100% accurate - if it says false, definitely no path exists
// Fast fail for unreachable cases
return this._createStandardResult({
possibility: 0,
reliability: 1.0,
...(options.includeMeta && {
meta: {
method: 'pltc_fast_fail',
reason: 'not_reachable',
sourceKey: userKey,
targetKey: objectKey,
direction
}
}),
reason: 'not_reachable'
}, []);
}
// If reachabilityResult is true, path exists but we still need to check relation types
// If null, PLTC not initialized, proceed with chain evaluation
}
// Proceed with chain evaluation (either PLTC said true/null, or bypassPLTC is enabled)
return this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
}
/**
* Evaluate chain traversal and value collection using ValueContext
* @protected
*/
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
const { fastPath, minPossibility, valueContext, includeMeta = true } = options;
// Normalize rule configuration
const normalizedRule = this._normalizeRule(rule);
const {
steps,
collectValues = true, // Default to true
valueFilters = {},
valueAggregation = 'sum',
reverse = false
} = normalizedRule;
const collectValuesEnabled = collectValues && !!valueContext;
if (!steps || steps.length === 0) {
return this._createStandardResult({
possibility: 0,
reliability: 1.0,
...(includeMeta && { meta: null }),
reason: 'no_chain_steps_defined'
}, []);
}
// Convert string keys to numeric IDs if needed
const userIdNum = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
const objectIdNum = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
const hasPartialGraph = !!(options.partialGraphContext);
// Threshold-mode (binary / fastPath-with-threshold) evaluations collapse
// sub-threshold paths to 0 and early-exit; their results are NOT
// interchangeable with full-mode values. The shared chain result cache
// must be neither consulted nor populated in threshold mode, or binary
// checks get served full-mode values (and vice versa).
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
// A caller-pinned clock (options.now) makes the result per-clock: a
// chain result captured at one time (with then-fresh values) must not
// be served to a caller asking about another time. Same contract as
// the rule result cache (RuleEvaluator): pinned-clock callers bypass
// the chain cache entirely — both reads and writes.
const temporalPinned = options.now !== undefined && options.now !== null;
// Check for cached chain result (use numeric IDs) - only if caching is enabled
// Skip cache when a partial graph is present to prevent cross-request leakage
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
if (cachedResult) {
return cachedResult;
}
}
// Determine starting point
let startId, startKey;
if (reverse) {
startId = objectIdNum;
startKey = objectKey;
} else {
startId = userIdNum;
startKey = userKey;
}
// Track all paths through the chain with their accumulated possibilities
let currentPaths = [{
id: startId,
key: startKey,
possibility: 1.0,
path: [startKey], // Track the full path
pathEntities: [{ id: startId, key: startKey, source: 'persistent' }]
}];
let allCollectedValues = [];
// Traverse through each step
for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
// Normalize string steps (emitted by the DSL compiler as
// ['works_in','has_access']) to the object form the traversal expects.
const rawStep = steps[stepIndex];
const step = typeof rawStep === 'string'
? { relation: rawStep, direction: 'out' }
: rawStep;
const { relation: stepRelation, direction } = step;
if (!stepRelation || !direction) {
currentPaths = [];
break;
}
// For each current path, extend it via the relation
const pathMap = new Map();
const MAX_PATHS_PER_STEP = 100;
for (const currentPath of currentPaths) {
if (pathMap.size >= MAX_PATHS_PER_STEP) break;
const relations = this._getRelationsForStep(currentPath.id, stepRelation, direction, options);
for (const rel of relations) {
const nextId = direction === 'in' ? rel.src : rel.dst;
const nextKey = this.arbiter.resolveKey(nextId, options);
if (nextKey) {
// Calculate path possibility (MIN along chain)
const nextPossibility = Math.min(currentPath.possibility, rel.possibility ?? 1.0);
if (fastPath && nextPossibility < minPossibility) {
continue;
}
// Collect values from this step BEFORE path deduplication:
// a weaker parallel path is still a valid value source, and
// dropping it first would silently discard its contribution.
if (collectValuesEnabled && this._shouldCollectFromStep(stepIndex, valueFilters)) {
const stepValues = this._collectValuesFromStep(
currentPath,
rel,
stepIndex,
stepRelation,
direction,
valueFilters,
valueContext,
options
);
allCollectedValues.push(...stepValues);
}
// Deduplicate: keep best path per node
const existing = pathMap.get(nextId);
const nextReliability = (currentPath.reliability ?? 1.0) * (rel.reliability ?? 1.0);
if (existing && existing.possibility > nextPossibility) continue;
if (existing && existing.possibility === nextPossibility && (existing.reliability ?? 1.0) >= nextReliability) continue;
// Create extended path
const extendedPath = {
id: nextId,
key: nextKey,
possibility: nextPossibility,
reliability: nextReliability,
path: [...currentPath.path, nextKey],
pathEntities: [...currentPath.pathEntities, { id: nextId, key: nextKey, source: rel.source || 'persistent' }]
};
pathMap.set(nextId, extendedPath);
}
}
}
currentPaths = Array.from(pathMap.values());
// Early exit if no paths found
if (currentPaths.length === 0) {
break;
}
}
// Determine if target was reached and calculate final possibility
const targetKey = reverse ? userKey : objectKey;
const targetId = reverse ? userIdNum : objectIdNum;
const targetPaths = currentPaths.filter(path => path.id === targetId);
let finalPossibility = 0;
let finalReliability = 1.0;
if (targetPaths.length > 0) {
// Use MAX across paths (disjunctive); the winning path's reliability
// is the product of its edges' reliabilities (MIN along the chain).
const best = targetPaths.reduce((a, b) =>
(b.possibility > a.possibility ||
(b.possibility === a.possibility && (b.reliability ?? 1.0) > (a.reliability ?? 1.0)))
? b : a
);
finalPossibility = best.possibility;
finalReliability = best.reliability ?? 1.0;
}
if (fastPath && finalPossibility < minPossibility) {
return this._createStandardResult({
possibility: 0,
reliability: 1.0,
meta: null,
reason: 'no_chain_path_found'
}, []);
}
// Add collected values to ValueContext if available
if (valueContext && allCollectedValues.length > 0) {
valueContext.addCollectedValues(allCollectedValues, 'chain', rule);
}
// Apply value aggregation with optional bilattice reasoning
const useBilattice = normalizedRule.useBilattice || false;
const epistemicMode = normalizedRule.epistemicMode || 'hybrid';
const capacityType = normalizedRule.capacityType || 'simple_support';
let aggregatedCollectedValues;
let epistemicAnalysis = null;
if (useBilattice && allCollectedValues.length > 0) {
const scale = QualitativeScale.fivePoint(); // Default scale for bilattice analysis
const capacity = this._createCapacityFromValues(allCollectedValues, scale, capacityType);
const bilatticeResult = this._combineEvidenceWithBilattice(allCollectedValues, {
method: valueAggregation,
useBilattice: true,
capacity: capacity,
scale: scale,
epistemicMode: epistemicMode
});
// Convert bilattice result back to collected values format
aggregatedCollectedValues = [{
value: bilatticeResult.value,
possibility: bilatticeResult.possibility,
path: ['bilattice_aggregated'],
source: {
entityKey: 'chain_aggregation',
relation: 'bilattice_fusion',
step: -1
},
metadata: {
timestamp: Date.now(),
reliability: 1.0,
aggregationMethod: `bilattice_${epistemicMode}`,
epistemicAnalysis: bilatticeResult.epistemicAnalysis
}
}];
epistemicAnalysis = bilatticeResult.epistemicAnalysis;
} else {
aggregatedCollectedValues = this._aggregateCollectedValues(
allCollectedValues,
valueAggregation
);
}
// Build authorization result with single possibility value
const authResult = {
possibility: finalPossibility,
reliability: finalReliability,
validity: this._validity('min', normalizedRule.steps.map(s => s.relation).filter(Boolean), [], normalizedRule.steps.length, finalPossibility),
...(includeMeta && {
meta: finalPossibility > 0 ? {
ruleType: 'chain',
reason: 'chain_path_found',
rule,
chainLength: steps.length,
pathsToTarget: targetPaths.length,
totalPaths: currentPaths.length,
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
useBilattice: useBilattice,
epistemicMode: useBilattice ? epistemicMode : undefined,
epistemicAnalysis: epistemicAnalysis
} : null
}),
...(includeMeta && {
meta_allow: finalPossibility > 0 ? {
ruleType: 'chain',
reason: 'chain_path_found',
rule,
chainLength: steps.length,
pathsToTarget: targetPaths.length,
totalPaths: currentPaths.length,
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null
} : null
}),
reason: finalPossibility > 0 ? 'chain_path_found' : 'no_chain_path_found'
};
const result = this._createStandardResult(authResult, aggregatedCollectedValues);
// Cache the chain result (use numeric IDs) - only if caching is enabled
// Do not cache when a partial graph is present to prevent cross-request leakage
// Do not cache threshold-mode results (see isThresholdEval above).
// Do not cache pinned-clock results either — the entry is per-clock
// and would be served to later unpinned callers as if it were timeless.
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
}
return result;
}
/**
* Get relations for a step based on direction
* @private
*/
_getRelationsForStep(entityId, relation, direction, options = null) {
if (direction === 'out') {
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
} else if (direction === 'in') {
return this.arbiter.relationManager.getRelationsToDst(entityId, relation, options);
} else {
// Default to 'out' for backward compatibility
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
}
}
/**
* Generate cache key for chain result using numeric IDs
* @private
*/
_getChainResultCacheKey(userId, objectId, steps) {
return this.arbiter.keyManager.createChainKey(userId, objectId, steps);
}
_getCachedChainResult(userId, objectId, steps) {
if (!this.chainResultCache) return null;
const key = this._getChainResultCacheKey(userId, objectId, steps);
const entry = this.chainResultCache.get(key);
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
if (entry && cacheNow - entry.timestamp < this.cacheTTL) {
return entry.result;
}
// HyperbolicLRUCache handles eviction automatically, no need to manually delete
return null;
}
/**
* Cache chain result
* @private
*/
_cacheChainResult(userId, objectId, steps, result) {
if (!this.chainResultCache) return;
const key = this._getChainResultCacheKey(userId, objectId, steps);
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.chainResultCache.set(key, {
result,
timestamp: cacheNow
});
}
_invalidateAllChainCaches() {
if (this.chainResultCache) {
this.chainResultCache.clear();
}
}
/**
* Check if we should collect values from this step
* @private
*/
_shouldCollectFromStep(stepIndex, valueFilters) {
if (!valueFilters) return true;
// Check step filter
if (valueFilters.steps && Array.isArray(valueFilters.steps)) {
return valueFilters.steps.includes(stepIndex);
}
return true;
}
/**
* Collect values from a single step in the chain
* @private
*/
_collectValuesFromStep(currentPath, relation, stepIndex, stepRelation, direction, valueFilters, valueContext, options = null) {
const collectedValues = [];
// Check if this relation should be collected based on filters
if (valueFilters.relations && Array.isArray(valueFilters.relations)) {
if (!valueFilters.relations.includes(stepRelation)) {
return collectedValues;
}
}
// If relation has a value, collect it as a blurred interval
if (relation.value !== undefined && relation.value !== null) {
// Apply value filters
if (valueFilters.minValue !== undefined && relation.value < valueFilters.minValue) {
return collectedValues;
}
if (valueFilters.maxValue !== undefined && relation.value > valueFilters.maxValue) {
return collectedValues;
}
// Let ValueManager handle age-based blurring instead of hard TTL filtering
// The ValueManager will apply appropriate blurring based on relation age
// Get blurred interval from ValueManager
if (!relation) {
return collectedValues;
}
if (!this.arbiter.valueManager) {
return collectedValues;
}
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow);
if (blurred.interval) {
const sourceEntity = direction === 'in' ?
this.arbiter.resolveKey(relation.dst, options) :
this.arbiter.resolveKey(relation.src, options);
const targetEntity = direction === 'in' ?
this.arbiter.resolveKey(relation.src, options) :
this.arbiter.resolveKey(relation.dst, options);
const collectedValue = this._createCollectedValue(
blurred.interval, // Pass interval instead of point value
blurred.possibility, // Use decayed possibility
currentPath.path,
{
entityKey: sourceEntity,
relation: stepRelation,
step: stepIndex,
direction: direction,
fullPath: currentPath.path,
stepPosition: stepIndex,
originalValue: relation.value, // Keep original point value for reference
source: relation.source || 'persistent'
},
{
timestamp: relation.changed_last_at || relation.updated_last_at ||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
reliability: blurred.reliability,
pathPossibility: currentPath.possibility,
relationPossibility: relation.possibility ?? 1.0,
currentPossibility: blurred.possibility, // Add current (decayed) possibility
interval: blurred.interval, // Include interval in metadata
source: relation.source || 'persistent'
}
);
collectedValues.push(collectedValue);
}
}
// Also try to get values from ValueContext if available
if (valueContext && currentPath.pathEntities.length > stepIndex) {
const entity = currentPath.pathEntities[stepIndex];
const contextValues = valueContext.getValues(entity.id, stepRelation);
for (const contextValue of contextValues) {
// Apply value filters
if (valueFilters.minValue !== undefined && contextValue.value < valueFilters.minValue) {
continue;
}
if (valueFilters.maxValue !== undefined && contextValue.value > valueFilters.maxValue) {
continue;
}
// Let ValueManager handle age-based blurring for ValueContext values too
// Create a temporary relation object for ValueManager
const tempRelation = {
src: entity.id,
dst: entity.id, // Self-relation for value storage
rel: stepRelation,
value: contextValue.value,
possibility: contextValue.possibility,
reliability: contextValue.reliability,
changed_last_at: contextValue.timestamp
};
if (!tempRelation) {
Arbiter.DEBUG && Arbiter.log('ChainRule: tempRelation is undefined, skipping value collection');
return collectedValues;
}
if (!this.arbiter.relationManager || !this.arbiter.relationManager.valueManager) {
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
return collectedValues;
}
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow);
if (blurred.interval) {
const collectedValue = this._createCollectedValue(
blurred.interval,
Math.min(currentPath.possibility, blurred.possibility),
currentPath.path,
{
entityKey: entity.key,
relation: stepRelation,
step: stepIndex,
direction: direction,
fullPath: currentPath.path,
stepPosition: stepIndex,
fromValueContext: true,
originalValue: contextValue.value
},
{
timestamp: contextValue.timestamp,
reliability: blurred.reliability,
pathPossibility: currentPath.possibility,
relationPossibility: contextValue.possibility,
currentPossibility: blurred.possibility,
interval: blurred.interval,
source: contextValue.source || 'persistent'
}
);
collectedValues.push(collectedValue);
}
}
}
return collectedValues;
}
/**
* Aggregate collected values based on aggregation method using interval arithmetic
* @private
*/
_aggregateCollectedValues(collectedValues, aggregationMethod) {
if (collectedValues.length === 0) return [];
if (collectedValues.length === 1) return collectedValues;
// Group values by path for aggregation
const valuesByPath = new Map();
for (const cv of collectedValues) {
const pathKey = cv.source?.fullPath?.join('->') || 'unknown_path';
if (!valuesByPath.has(pathKey)) {
valuesByPath.set(pathKey, []);
}
valuesByPath.get(pathKey).push(cv);
}
const aggregatedValues = [];
for (const [pathKey, pathValues] of valuesByPath) {
if (pathValues.length === 1) {
aggregatedValues.push(pathValues[0]);
continue;
}
// Extract intervals and metadata for OWA fusion
const intervals = pathValues.map(cv => cv.value); // cv.value is already an interval
const possibilities = pathValues.map(cv => cv.possibility);
const metas = pathValues.map(cv => ({
...cv.source,
...cv.metadata,
originalInterval: cv.value
}));
// Use OWAFusion's interval arithmetic
const intervalResult = OWAFusion.fuseIntervalsWithMeta(
intervals,
metas,
null, // Use default weights
aggregationMethod
);
// Aggregate possibilities
const possibilityResult = OWAFusion.fuseWithMeta(
possibilities,
metas,
null,
aggregationMethod === 'sum' ? 'average' : aggregationMethod // For sum, average possibilities
);
// Create aggregated collected value
const aggregatedCV = this._createCollectedValue(
intervalResult.interval,
possibilityResult.value,
pathValues[0].path,
{
...pathValues[0].source,
aggregationMethod,
aggregatedFromCount: pathValues.length
},
{
...pathValues[0].metadata,
reliability: intervalResult.meta?.reliability ||
Math.min(...pathValues.map(cv => cv.metadata?.reliability || 1.0)),
aggregatedFromIntervals: intervals,
interval: intervalResult.interval
}
);
aggregatedValues.push(aggregatedCV);
}
return aggregatedValues;
}
/**
* Normalize rule configuration (for future extensibility)
* @private
*/
_normalizeRule(rule) {
if (rule.chain) return { ...rule, ...rule.chain, chain: undefined };
return rule;
}
}