import { ProgramNode, DefinitionNode, FactNode, EvidenceNode, MeasureNode, DirectEvidenceNode, PatternMatchNode, DefeasibleLogicNode, FusionNode, PredicateNode, ExpressionNode } from '../nodes/index.js'; /** * Rule Generator for converting AST to setRelationConfig calls * Generates rule configurations that interface with the existing rule system */ export class RuleGenerator { constructor(arbiter, options = {}) { this.arbiter = arbiter; this.generatedRules = new Map(); this.errors = []; this.dependencyIndex = new Map(); this.evidenceNames = new Set(); // Relation names this generator has installed on the arbiter, tracked PER // PROGRAM SCOPE (the compile() program name). Recompiling the same scope // uninstalls relations that scope previously declared but no longer does — // otherwise a revoked evidence/fact keeps its config and still grants // (stale-permission leak). Relations from OTHER scopes (compileMultiple // coexistence) are never touched. this._installedByScope = new Map(); this._currentScope = 'default'; // Default depth for bounded self-recursion when the DSL `limit N` is absent. this.maxRecursionDepth = options.maxRecursionDepth ?? 3; } /** * Generate rules from AST program * @param {ProgramNode} program - AST program to generate rules from * @returns {Object} Generation result with success status and errors */ generateRules(program, scopeName = 'default') { this.errors = []; this._currentScope = scopeName; this.program = program; this.generatedRules.clear(); this.dependencyIndex.clear(); // Facts declared `BEHAVES AS transitive` are resolved as bounded transitive // closure (multi_hop) everywhere they are referenced — both the fact's own // config and any direct rule that references the fact. Without this the // declaration parses but grants only direct edges (silent no-op). Value is // the closure depth (the fact's `limit N`, or the recursion default). this.transitiveFacts = new Map(); for (const fact of program.facts || []) { const behavior = fact && fact.behavior; if (behavior && (behavior.behavior === 'transitive' || behavior === 'transitive')) { const depth = (fact.limit && typeof fact.limit === 'object' ? fact.limit.value : fact.limit) ?? this.maxRecursionDepth; this.transitiveFacts.set(fact.name, depth); } } try { // Generate rules for each evidence definition program.evidence.forEach(evidence => { this.generateEvidenceRules(evidence); }); // Generate rules for each measure definition program.measures.forEach(measure => { this.generateMeasureRules(measure); }); // Generate fact relation configs (requires injection at check time) program.facts.forEach(fact => { this.generateFactConfig(fact); }); // Generate source relation configs (injectable, recency-gated proofs). // Without a config a source referenced by an evidence would never // resolve — the reference would lower to a config-less direct rule that // grants nothing. (program.sources || []).forEach(source => { this.generateSourceConfig(source); }); // Resolve evidence composition: a rule that references another derived // evidence (WHEN can_read(user, doc) where can_read is an evidence) is // lowered in place to that evidence's own config — compile-time inlining // (a linker pass), so the engine evaluates a fully-resolved config tree // and never needs a sub-query traversal mechanism. Forward references are // handled because every evidence config is built before this pass runs. this.resolveEvidenceReferences(); // Apply generated rules to arbiter this.applyRulesToArbiter(); return { success: this.errors.length === 0, errors: this.errors, generatedCount: this.generatedRules.size }; } catch (error) { this.errors.push(`Generation error: ${error.message}`); return { success: false, errors: this.errors, generatedCount: 0 }; } } /** * Generate rules for an evidence definition * @param {EvidenceNode} evidence - Evidence to generate rules for */ generateEvidenceRules(evidence) { const relationName = evidence.name; this.evidenceNames.add(relationName); const ruleConfig = this.buildRuleConfig(evidence); if (ruleConfig) { this._annotateDependencies(relationName, ruleConfig); this.generatedRules.set(relationName, ruleConfig); } } /** * Generate rules for a measure definition * @param {MeasureNode} measure - Measure to generate rules for */ generateMeasureRules(measure) { const relationName = measure.name; const ruleConfig = this.buildMeasureRuleConfig(measure); if (ruleConfig) { this._annotateDependencies(relationName, ruleConfig); this.generatedRules.set(relationName, ruleConfig); } } /** * Generate relation config for a fact declaration. * Facts require injection at check time (e.g. from user DB, session store). * Registered as type: 'direct' with requiresInjection flag so the gate-check * pipeline can pre-fetch facts before calling graphStore.check(). * @param {FactNode} fact - Fact to generate config for */ generateFactConfig(fact) { const name = fact.name; const params = fact.params || []; const paramNames = params.map(p => p.name); const paramTypes = params.map(p => p.type); // BEHAVES AS transitive: the fact resolves to bounded transitive closure. // A multi_hop config walks the relation's edges up to maxDepth (the fact's // `limit N`, or the recursion default), so a direct check on the fact — // and any evidence that references it — follows multi-hop paths instead of // only direct edges. const transitiveDepth = this.transitiveFacts.get(name); const base = { isFactRelation: true, requiresInjection: true, arity: paramTypes.length, paramTypes: paramTypes, paramNames: paramNames, cacheDirective: fact.cacheDirective || fact.cache || 'lazy', properties: Object.fromEntries( (fact.properties || []).map(p => typeof p === 'string' ? [p, true] : Array.isArray(p) ? p : [p, true] ) ) }; this.generatedRules.set(name, transitiveDepth !== undefined ? { type: 'multi_hop', relation: name, maxDepth: transitiveDepth, pathAggregation: 'max', reverse: false, fallbackToBasicPaths: true, collectValues: false, ...base } : { type: 'direct', relation: name, ...base }); } generateSourceConfig(source) { const name = source.name; const params = source.params || []; const withinMs = source.within ? this._durationToMs(source.within) : null; this.generatedRules.set(name, { type: 'direct', relation: name, isSourceRelation: true, requiresInjection: true, arity: params.length, paramTypes: params.map(p => p.paramType), paramNames: params.map(p => p.name), ...(withinMs !== null ? { withinMs } : {}) }); } _annotateDependencies(relationName, ruleConfig) { if (!ruleConfig || typeof ruleConfig !== 'object') return; const dependsOn = new Set(); const dependsByLevel = { never: new Set(), always: new Set(), requires: new Set(), when: new Set(), unless: new Set(), ordinary: new Set() }; const collect = (rule, targetSet) => { if (!rule || typeof rule !== 'object') return; if (rule.type === 'direct' && rule.relation) { targetSet.add(rule.relation); } if (rule.type === 'tuple_to_userset') { if (rule.tuplesetRelation) targetSet.add(rule.tuplesetRelation); if (rule.computedRelation) targetSet.add(rule.computedRelation); } if (rule.type === 'parent' && rule.parentRelation) { targetSet.add(rule.parentRelation); } if (rule.type === 'multi_hop' && rule.relation) { targetSet.add(rule.relation); } if (rule.type === 'chain' && Array.isArray(rule.steps)) { for (const step of rule.steps) { if (typeof step === 'string') targetSet.add(step); else if (step && typeof step.relation === 'string') targetSet.add(step.relation); else if (step && step.rule) collect(step.rule, targetSet); } } if (rule.type === 'relational_comparator') { if (rule.left?.valueRelation) targetSet.add(rule.left.valueRelation); if (rule.right?.valueRelation) targetSet.add(rule.right.valueRelation); if (rule.left?.rule) collect(rule.left.rule, targetSet); if (rule.right?.rule) collect(rule.right.rule, targetSet); } const logicalKeys = ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless']; for (const key of logicalKeys) { const node = rule[key]; const ruleList = node?.rules || node?.union?.rules || node?.intersection?.rules; if (Array.isArray(ruleList)) { for (const child of ruleList) collect(child, targetSet); } if (node?.rule) collect(node.rule, targetSet); if (node?.direct) collect(node.direct, targetSet); } }; const collectWithKeys = (rule, keys, targetSet) => { if (!rule || typeof rule !== 'object') return; for (const key of keys) { const node = rule[key]; const ruleList = node?.rules || node?.union?.rules || node?.intersection?.rules; if (Array.isArray(ruleList)) { for (const child of ruleList) collect(child, targetSet); } if (node?.rule) collect(node.rule, targetSet); } }; if (ruleConfig.type === 'logical') { if (ruleConfig.never) collect(ruleConfig.never, dependsByLevel.never); if (ruleConfig.always) collect(ruleConfig.always, dependsByLevel.always); if (ruleConfig.requires) collect(ruleConfig.requires, dependsByLevel.requires); if (ruleConfig.when) collect(ruleConfig.when, dependsByLevel.when); if (ruleConfig.unless) collect(ruleConfig.unless, dependsByLevel.unless); collectWithKeys(ruleConfig, ['union', 'intersection', 'exclusion'], dependsByLevel.ordinary); } else { collect(ruleConfig, dependsOn); } for (const level of Object.keys(dependsByLevel)) { for (const rel of dependsByLevel[level]) { dependsOn.add(rel); } } const orderedDependsOn = Array.from(dependsOn); if (orderedDependsOn.length > 0) { ruleConfig.dependsOn = orderedDependsOn; } if (Object.values(dependsByLevel).some(set => set.size > 0)) { ruleConfig.dependsByLevel = { never: Array.from(dependsByLevel.never), always: Array.from(dependsByLevel.always), requires: Array.from(dependsByLevel.requires), when: Array.from(dependsByLevel.when), unless: Array.from(dependsByLevel.unless), ordinary: Array.from(dependsByLevel.ordinary) }; } if (orderedDependsOn.length > 0) { for (const rel of orderedDependsOn) { let entry = this.dependencyIndex.get(rel); if (!entry) { entry = { all: new Set(), byLevel: { never: new Set(), always: new Set(), requires: new Set(), when: new Set(), unless: new Set(), ordinary: new Set() } }; this.dependencyIndex.set(rel, entry); } entry.all.add(relationName); if (ruleConfig.dependsByLevel) { for (const level of Object.keys(entry.byLevel)) { if (ruleConfig.dependsByLevel[level]?.includes(rel)) { entry.byLevel[level].add(relationName); } } } } } } getDependencyIndex() { return this.dependencyIndex; } /** * Build rule configuration from evidence * @param {EvidenceNode} evidence - Evidence to build config for * @returns {Object|null} Rule configuration or null */ buildRuleConfig(evidence) { if (!evidence.body || !evidence.body.statements) { this.errors.push(`Evidence ${evidence.name} has no body`); return null; } const statements = evidence.body.statements; // Handle single statement evidence if (statements.length === 1) { return this.buildSingleStatementRule(statements[0], evidence); } // Handle multiple statements with logical operators return this.buildLogicalRule(statements, evidence); } /** * Build rule configuration for a single statement * @param {BaseNode} statement - Statement to build rule for * @param {Object} evidence - Evidence definition (params inform pattern/operand lowering) * @returns {Object|null} Rule configuration or null */ buildSingleStatementRule(statement, evidence) { switch (statement.type) { case 'DirectEvidence': return this.buildDirectRule(statement, evidence); case 'PatternMatch': return this.buildPatternMatchRule(statement, evidence); case 'DefeasibleLogic': return this.buildDefeasibleRule(statement, evidence); case 'Fusion': return this.buildFusionRule(statement); case 'PredicateCall': return this.buildPredicateRule(statement, evidence); case 'UnaryExpression': return this.buildUnaryRule(statement, evidence); case 'BinaryExpression': // Top-level comparator — emit a relational_comparator rule. RF-24 closure. return this.buildRuleFromExpressionNode(statement, evidence); case 'Expression': // Handle expressions that might be predicate calls if (statement.type === 'PredicateCall') { return this.buildPredicateRule(statement, evidence); } return this.buildRuleFromExpressionNode(statement, evidence); default: this.errors.push(`Unsupported statement type: ${statement.type}`); return null; } } /** * Build rule for unary expression (NOT) * @param {Object} expression - Unary expression * @param {Object} evidence - Evidence definition (threaded through so a * unary inner predicate like NOT banned(user) keeps its _subjectAsObject * rewrite; without it the unary fact would be checked on the evidence's * OBJECT node instead of the subject, silently negating the wrong fact). * @returns {Object|null} Rule configuration or null */ buildUnaryRule(expression, evidence) { if (expression.operator !== 'NOT') { this.errors.push(`Unsupported unary operator: ${expression.operator}`); return null; } const innerRule = this.buildRuleFromExpression(expression.operand, evidence); if (!innerRule) { return null; } // NOT x: evaluation will negate the inner rule's possibility (1 - poss) return { type: 'logical', intersection: { rules: [innerRule], aggregator: 'min', negate: true } }; } /** * Build logical rule configuration for multiple statements * @param {BaseNode[]} statements - Statements to combine * @returns {Object|null} Rule configuration or null */ buildLogicalRule(statements, evidence) { const defeasible = statements.filter(s => s && s.type === 'DefeasibleLogic'); const others = statements.filter(s => s && s.type !== 'DefeasibleLogic'); // Defeasible levels (NEVER / REQUIRES / ALWAYS / WHEN / UNLESS) form ONE // five-level hierarchy (ADR-000), not separate ANDed rules. A standalone // NEVER-only rule contributes 0 whether or not it fires, so ANDing the // levels separately would always yield 0. Merge all defeasible statements // into a single config; any non-defeasible statements become the base // grant (when) that the defeaters and requirements gate. if (defeasible.length > 0) { return this.buildMergedDefeasibleRule(defeasible, others, evidence); } const rules = []; others.forEach(statement => { const rule = this.buildSingleStatementRule(statement, evidence); if (rule) { rules.push(rule); } }); if (rules.length === 0) { this.errors.push('No valid rules found in evidence body'); return null; } if (rules.length === 1) { return rules[0]; } // Combine rules with intersection (AND) logic — REBAC default. // Use explicit fusion max { ... } in DSL for OR semantics. return { type: 'logical', intersection: { rules: rules, aggregator: 'min' } }; } /** * Merge defeasible-level statements into a single five-level rule config. * The ADR-000 hierarchy is never > requires > strict (always) > when > unless; * each level accumulates its conditions and the whole thing evaluates as one * defeasible rule rather than a conjunction of level-only rules. */ buildMergedDefeasibleRule(defeasibleStatements, otherStatements, evidence) { const neverRules = []; const alwaysRules = []; const requiresRules = []; const whenRules = []; const unlessRules = []; for (const st of defeasibleStatements) { const condition = this.buildRuleFromExpression(st.condition, evidence); const defeater = st.defeater ? this.buildRuleFromExpression(st.defeater, evidence) : null; switch (st.logicType) { case 'NEVER': if (condition) neverRules.push(condition); break; case 'ALWAYS': if (condition) alwaysRules.push(condition); break; case 'REQUIRES': if (condition) requiresRules.push(condition); break; case 'WHEN': if (condition) whenRules.push(condition); if (defeater) unlessRules.push(defeater); break; case 'UNLESS': if (condition) unlessRules.push(condition); break; default: this.errors.push(`Unsupported defeasible logic type: ${st.logicType}`); } } // Non-defeasible statements in the same body act as the base grant // (when) that NEVER/REQUIRES/UNLESS gate. if (otherStatements.length > 0) { const baseRules = otherStatements .map(s => this.buildSingleStatementRule(s, evidence)) .filter(Boolean); if (baseRules.length === 1) { whenRules.push(baseRules[0]); } else if (baseRules.length > 1) { whenRules.push({ type: 'logical', intersection: { rules: baseRules, aggregator: 'min' } }); } } const rule = { type: 'logical' }; if (neverRules.length > 0) { rule.never = { union: { rules: neverRules, aggregator: 'max' } }; } if (requiresRules.length > 0) { rule.requires = { union: { rules: requiresRules, aggregator: 'min' } }; } if (alwaysRules.length > 0) { rule.always = { direct: alwaysRules.length === 1 ? alwaysRules[0] : { type: 'logical', intersection: { rules: alwaysRules, aggregator: 'min' } }, aggregator: 'min' }; } if (whenRules.length > 0) { rule.when = { intersection: { rules: whenRules, aggregator: 'min' } }; } if (unlessRules.length > 0) { rule.unless = { union: { rules: unlessRules, aggregator: 'max' } }; } return rule; } /** * Build direct rule configuration * @param {DirectEvidenceNode} directEvidence - Direct evidence statement * @returns {Object|null} Rule configuration or null */ buildDirectRule(directEvidence, evidence) { if (!directEvidence.predicate) { this.errors.push('Direct evidence must have a predicate'); return null; } const predicate = directEvidence.predicate; const relation = predicate.name; // A reference to a `BEHAVES AS transitive` fact resolves to closure. const transitiveDepth = this.transitiveFacts.get(relation); if (transitiveDepth !== undefined) { return this._buildTransitiveRule(relation, transitiveDepth, predicate.arguments || [], evidence); } const rule = { type: 'direct', relation: relation, reverse: false }; // Unary predicate calls check the relation as a self-edge on the call's // subject entity (the graph stores unary facts as self-edges). The subject // entity may be the evidence's SUBJECT or its OBJECT parameter — mark the // matching rewrite flag. const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; const argName = a => a && (a.name !== undefined ? a.name : a.value); const args = predicate.arguments || []; if (objectVar !== undefined) { const hasObjectArg = args.some(a => argName(a) === objectVar); if (args.length === 1 && argName(args[0]) === objectVar) { rule._subjectIsObject = true; } else if (!hasObjectArg) { rule._subjectAsObject = true; } } // A literal value in the object position (balance(user, 5)) is a VALUE // constraint, not a node key: the rule only grants when the matched edge // carries exactly that value. Without this gate a value-carrying fact // would match ANY edge regardless of its amount (silent over-grant). this._applyExpectedValue(rule, args); return rule; } /** * Build pattern match rule configuration * @param {PatternMatchNode} patternMatch - Pattern match statement * @returns {Object|null} Rule configuration or null */ buildPatternMatchRule(patternMatch, evidence) { if (!patternMatch.predicate) { this.errors.push('Pattern match must have a predicate'); return null; } const predicate = patternMatch.predicate; const relation = predicate.name; // Structural classification (ADR-000 §Mapping to Engine Rule Types): the // two-hop pattern "P(args) { Q(args) }" binds an intermediate via a Wildcard. // The predicate whose args include the OBJECT parameter is the object-side // hop. Object-side = outer → tuple_to_userset (user → computed → intermediate // → tupleset → object). Object-side = inner → chain (user → outer → // intermediate → inner → object). This mirrors Zanzibar's tuple-to-userset // vs. two-hop path semantics and fixes the previous heuristic that routed // every outer-wildcard pattern to chain (owner(*g, doc) { member_of(user, g) } // was emitted as a chain and could never match). const inner = this._singleInnerPredicate(patternMatch); const evidenceParams = (evidence && evidence.params) || []; const userVar = evidenceParams[0] && evidenceParams[0].name; const objectVar = evidenceParams[1] && evidenceParams[1].name; // Nested PatternMatch bodies ("P(user, *a) { Q(a, *b) { R(b, doc) } }") are // fixed-length multi-hop PATHS — a chain whose steps are the flattened // predicate sequence [P, Q, R], not transitive-closure multi_hop over one // relation. Chain handles N steps; multi_hop only walks a single relation. if (this._isNestedPattern(patternMatch)) { return this.buildNestedChainRule(patternMatch, inner); } if (inner) { const argName = a => a && (a.name !== undefined ? a.name : a.value); const outerHasObject = objectVar !== undefined && (predicate.args || []).some(a => argName(a) === objectVar); const innerHasObject = objectVar !== undefined && (inner.args || []).some(a => argName(a) === objectVar); if (outerHasObject && !innerHasObject) { return this.buildTupleToUsersetRule(patternMatch, inner); } if (innerHasObject && !outerHasObject) { return this.buildChainRule(patternMatch, inner); } } // Fallback: membership/hierarchy naming hints (evidence params unavailable // or both predicates reference the object — keep legacy behavior). if (this.isMembershipPredicate(predicate)) { return this.buildTupleToUsersetRule(patternMatch, inner); } if (this.isHierarchyPredicate(predicate)) { return this.buildParentRule(patternMatch); } if (this._isChainPattern(patternMatch)) { return this.buildChainRule(patternMatch, inner); } return this.buildMultiHopRule(patternMatch); } /** * Extract the single inner PredicateCall of a PatternMatch body (null if the * body has multiple statements, is nested, or is wrapped in logic operators). */ _singleInnerPredicate(patternMatch) { if (!patternMatch.body || !Array.isArray(patternMatch.body.statements)) return null; const stmts = patternMatch.body.statements; if (stmts.length !== 1) return null; if (stmts[0].type === 'PredicateCall') return stmts[0]; return null; } /** * True when the PatternMatch body is itself a nested PatternMatch * ("P(user, *a) { Q(a, *b) { R(b, doc) } }") — a multi-hop path. */ _isNestedPattern(patternMatch) { if (!patternMatch.body || !Array.isArray(patternMatch.body.statements)) return false; const stmts = patternMatch.body.statements; return stmts.length === 1 && stmts[0].type === 'PatternMatch'; } /** * Flatten a nested PatternMatch into its linear predicate sequence * [P, Q, ..., R] where R is the object-side hop. */ _flattenPatternSteps(patternMatch, acc = []) { const predicate = patternMatch.predicate; if (!predicate || !predicate.name) return acc; acc.push(predicate.name); if (patternMatch.body && Array.isArray(patternMatch.body.statements) && patternMatch.body.statements.length === 1) { const child = patternMatch.body.statements[0]; if (child && child.type === 'PredicateCall' && child.name) { acc.push(child.name); } else if (child && child.type === 'PatternMatch') { this._flattenPatternSteps(child, acc); } } return acc; } /** * Build a chain rule from a nested (multi-hop path) PatternMatch. * "P(user, *a) { Q(a, *b) { R(b, doc) } }" → { type: 'chain', steps: [P, Q, R] }. */ buildNestedChainRule(patternMatch) { const steps = this._flattenPatternSteps(patternMatch); if (steps.length < 2) { this.errors.push('Nested pattern match must yield at least two steps'); return null; } return { type: 'chain', steps, aggregator: 'max', collectValues: true, maxDepth: patternMatch.limit || null }; } /** * Detect the ChainRule shape: a PatternMatch whose body contains exactly one * PredicateCall and uses a Wildcard arg to bind the intermediate. The predicate * name itself is NOT in the membership/hierarchy lists (those map to TUS/Parent). */ _isChainPattern(patternMatch) { if (!patternMatch.body || !Array.isArray(patternMatch.body.statements)) return false; const stmts = patternMatch.body.statements; if (stmts.length !== 1) return false; if (stmts[0].type !== 'PredicateCall') return false; const predicate = patternMatch.predicate; if (!predicate || !Array.isArray(predicate.args)) return false; // Must use at least one Wildcard (*var) to bind the intermediate return predicate.args.some(arg => arg && arg.type === 'Wildcard'); } /** * Build chain rule configuration (ADR-000 ChainRule). * Compiles "works_in(user, *d) { has_access(d, doc) }" into * { type: 'chain', steps: ['works_in', 'has_access'] } * The intermediate wildcard binds the two predicates' arguments. Step order is * [userSide, objectSide]: the outer predicate connects user → intermediate, * the inner predicate connects intermediate → object. */ buildChainRule(patternMatch, inner) { const steps = []; steps.push(patternMatch.predicate.name); const innerPredicate = inner || (patternMatch.body.statements[0]); if (innerPredicate && innerPredicate.type === 'PredicateCall' && innerPredicate.name) { steps.push(innerPredicate.name); } return { type: 'chain', steps, aggregator: 'max', collectValues: true, // Carry the pattern's `limit N` as a max depth so a self-referential // chain step can be unrolled into bounded transitive closure. maxDepth: patternMatch.limit || null }; } /** * Build tuple-to-userset rule configuration (ADR-000 TupleToUsersetRule). * Compiles "owner(*g, doc) { member_of(user, g) }" into * { * type: 'tuple_to_userset', * tuplesetRelation: 'owner', // object-side hop: intermediate → object * computedRelation: 'member_of', // user-side hop: user → intermediate * tuplesetDirection: 'in', // intermediates hold the tupleset edge TO the object * reverse: false * } * The tupleset edge direction follows the wildcard position in the outer * predicate: wildcard as first arg (owner(*g, doc)) means the intermediate is * the edge source ('in' — fetch edges with dst = object); wildcard as second * arg (owner(doc, *g)) means the object is the source ('out'). */ buildTupleToUsersetRule(patternMatch, inner) { const predicate = patternMatch.predicate; const relation = predicate.name; const computedRelation = inner && inner.name ? inner.name : relation; const wildcardIndex = (predicate.args || []).findIndex(a => a && a.type === 'Wildcard'); return { type: 'tuple_to_userset', tuplesetRelation: relation, computedRelation, tuplesetDirection: wildcardIndex === 0 ? 'in' : 'out', reverse: false, earlyExitThreshold: 0.95, maxIntermediates: patternMatch.limit || 10 }; } /** * Build parent rule configuration * @param {PatternMatchNode} patternMatch - Pattern match statement * @returns {Object|null} Rule configuration or null */ buildParentRule(patternMatch) { const predicate = patternMatch.predicate; const relation = predicate.name; return { type: 'parent', parentRelation: 'parent', // Default, could be inferred from context relation: relation, reverse: false, aggregator: 'max' }; } /** * Build multi-hop rule configuration * @param {PatternMatchNode} patternMatch - Pattern match statement * @returns {Object|null} Rule configuration or null */ buildMultiHopRule(patternMatch) { const predicate = patternMatch.predicate; const relation = predicate.name; return { type: 'multi_hop', relation: relation, maxDepth: 3, pathAggregation: 'max', reverse: false, fallbackToBasicPaths: true, collectValues: false }; } /** * Build defeasible rule configuration * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildDefeasibleRule(defeasibleLogic, evidence) { const logicType = defeasibleLogic.logicType; if (logicType === 'NEVER') { return this.buildNeverRule(defeasibleLogic, evidence); } else if (logicType === 'ALWAYS') { return this.buildStrictRule(defeasibleLogic, evidence); } else if (logicType === 'WHEN') { return this.buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence); } else if (logicType === 'UNLESS') { return this.buildDefeaterRule(defeasibleLogic, evidence); } else if (logicType === 'REQUIRES') { return this.buildRequirementRule(defeasibleLogic, evidence); } this.errors.push(`Unsupported defeasible logic type: ${logicType}`); return null; } /** * Build NEVER rule configuration (absolute denial) * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildNeverRule(defeasibleLogic, evidence) { const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', never: { union: { rules: [condition], aggregator: 'max' } } }; } /** * Build strict rule configuration * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildStrictRule(defeasibleLogic, evidence) { const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', always: { direct: condition, aggregator: 'min' } }; } /** * Build defeasible rule with defeater * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence) { const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); const defeater = this.buildRuleFromExpression(defeasibleLogic.defeater, evidence); const rule = { type: 'logical', when: { intersection: { rules: [condition], aggregator: 'min' } } }; if (defeater) { rule.unless = { union: { rules: [defeater], aggregator: 'max' } }; } return rule; } /** * Build defeater rule configuration * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildDefeaterRule(defeasibleLogic, evidence) { const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', unless: { union: { rules: [condition], aggregator: 'max' } } }; } /** * Build requirement rule configuration * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ buildRequirementRule(defeasibleLogic, evidence) { const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', requires: { union: { rules: [condition], aggregator: 'min' } } }; } /** * Build fusion rule configuration * @param {FusionNode} fusion - Fusion statement * @returns {Object|null} Rule configuration or null */ buildFusionRule(fusion) { const rules = []; (fusion.expressions || fusion.evidence || []).forEach(evidence => { const rule = this.buildRuleFromExpression(evidence); if (rule) { rules.push(rule); } }); if (rules.length === 0) { this.errors.push('Fusion has no valid evidence'); return null; } if (fusion.weights && fusion.strategy !== 'custom') { this.errors.push(`Fusion weights require custom strategy, got: ${fusion.strategy}`); return null; } if (fusion.strategy === 'custom') { if (!fusion.weights || fusion.weights.length === 0) { this.errors.push('Custom fusion requires weights'); return null; } const total = fusion.weights.reduce((sum, w) => sum + w, 0); if (Math.abs(total - 1.0) > 1e-6) { this.errors.push('Custom fusion weights must sum to 1.0'); return null; } } const union = { rules: rules, aggregator: fusion.strategy }; if (fusion.weights && fusion.weights.length > 0) { union.owaWeights = fusion.weights; } return { type: 'logical', union }; } /** * Build rule from expression * @param {BaseNode} expression - Expression to build rule from * @returns {Object|null} Rule configuration or null */ buildRuleFromExpression(expression, evidence) { if (!expression) { return null; } if (expression.type === 'Predicate') { return this.buildDirectRuleFromPredicate(expression, evidence); } else if (expression.type === 'Expression') { return this.buildRuleFromExpressionNode(expression, evidence); } else if (expression.type === 'PredicateCall') { return this.buildPredicateRule(expression, evidence); } else if (expression.type === 'UnaryExpression') { return this.buildUnaryRule(expression, evidence); } else if (expression.type === 'BinaryExpression') { return this.buildRuleFromExpressionNode(expression, evidence); } this.errors.push(`Unsupported expression type: ${expression.type}`); return null; } /** * Build direct rule from predicate * @param {PredicateNode} predicate - Predicate to build rule from * @returns {Object|null} Rule configuration or null */ buildDirectRuleFromPredicate(predicate, evidence) { // A reference to a `BEHAVES AS transitive` fact resolves to closure. const transitiveDepth = this.transitiveFacts.get(predicate.name); if (transitiveDepth !== undefined) { return this._buildTransitiveRule(predicate.name, transitiveDepth, predicate.args || [], evidence); } const rule = { type: 'direct', relation: predicate.name, reverse: false }; const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; if (objectVar !== undefined && !(predicate.args || []).some(a => a && a.type === 'Variable' && a.name === objectVar)) { rule._subjectAsObject = true; } // Value constraint: a literal in the object position (balance(user, 5)). this._applyExpectedValue(rule, predicate.args || []); return rule; } /** * Annotate a direct rule with `expectedValue` when its object-position * argument is a literal. The engine only grants the rule if the matched * edge's `value` field equals this literal — without the gate a * value-carrying fact would match any edge of the same relation, silently * over-granting (e.g. balance(user, 5) matching a value-3 edge). */ _applyExpectedValue(rule, args) { if (rule && args && args.length >= 2) { const objectArg = args[1]; if (objectArg && objectArg.type === 'Literal' && objectArg.value !== undefined) { rule.expectedValue = objectArg.value; } } return rule; } /** * Build a bounded transitive-closure rule for a `BEHAVES AS transitive` * fact reference. Applies the same subject/object rewrite flags as the * direct-rule builders so a unary or object-var reference still targets the * correct nodes. */ _buildTransitiveRule(relation, maxDepth, args, evidence) { const rule = { type: 'multi_hop', relation: relation, maxDepth: maxDepth, pathAggregation: 'max', reverse: false, fallbackToBasicPaths: true, collectValues: false }; const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; const argName = a => a && (a.name !== undefined ? a.name : a.value); if (objectVar !== undefined) { const hasObjectArg = args.some(a => a && a.type === 'Variable' && a.name === objectVar); if (args.length === 1 && argName(args[0]) === objectVar) { rule._subjectIsObject = true; } else if (!hasObjectArg) { rule._subjectAsObject = true; } } return rule; } _expandPredicate() { // Replaced by resolveEvidenceReferences() (the compile-time evidence // composition pass). Predicate references are now emitted as direct rules // and inlined during resolution, which also handles forward references and // preserves the correct _subjectAsObject scoping. } /** * Evidence composition pass. Every rule that references a DERIVED evidence * (e.g. `WHEN can_read(user, doc)` where can_read is itself an evidence) is * rewritten to inline that evidence's own config. This runs after all * evidence configs are generated, so forward references resolve; cycles are * detected and reported. The engine therefore evaluates a fully-resolved, * acyclic config tree — no runtime sub-query traversal is needed. */ resolveEvidenceReferences() { for (const name of this.evidenceNames) { if (!this.generatedRules.has(name)) continue; let config = this.generatedRules.get(name); // Bounded self-recursion (transitive closure): an evidence whose config // contains a chain step referencing ITSELF is unrolled into a union of // bounded paths — base, hop+base, hop²+base, …, hop^N+base — where `hop` // is the recursive chain's steps before the self-reference and the depth // N comes from the pattern's `limit N` (or the compiler default). const selfRef = this._findSelfReference(config, name); if (selfRef) { const depth = selfRef.limit ?? this.maxRecursionDepth; const unrolled = this._unrollRecursiveEvidence(name, config, selfRef.hop, depth); if (unrolled) { config = unrolled; this.generatedRules.set(name, config); } } const stack = new Set([name]); const resolved = this._resolveRule(config, stack); this.generatedRules.set(name, resolved); this._annotateDependencies(name, resolved); } } /** * Find the first chain step within `config` that references `name` (a * self-reference). Returns { hop, limit } where hop is the chain's steps * before the self-reference and limit is the chain's declared max depth. * Returns null when there is no self-reference. */ _findSelfReference(config, name) { let found = null; const walk = (rule) => { if (!rule || typeof rule !== 'object' || found) return; if (rule.type === 'chain' && Array.isArray(rule.steps)) { const idx = rule.steps.findIndex(s => (typeof s === 'string' ? s : s && s.relation) === name); if (idx >= 0) { const lim = rule.maxDepth; const limit = lim && typeof lim === 'object' ? lim.value : lim; found = { hop: rule.steps.slice(0, idx), limit: Number.isFinite(limit) ? limit : null }; return; } } for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless']) { const node = rule[key]; if (!node) continue; if (Array.isArray(node.rules)) for (const c of node.rules) walk(c); if (Array.isArray(node.union?.rules)) for (const c of node.union.rules) walk(c); if (Array.isArray(node.intersection?.rules)) for (const c of node.intersection.rules) walk(c); if (node.direct) walk(node.direct); if (node.rule) walk(node.rule); } }; walk(config); return found; } /** * Unroll a self-recursive evidence into a bounded transitive closure. * The recursive chain is removed from the config; the remainder is the base. * Result: union([base, hop+base, hop²+base, …, hop^depth+base]) where the * base is verified as a condition step at each path's terminal node. */ _unrollRecursiveEvidence(name, config, hop, depth) { if (hop.length === 0) { this.errors.push(`Recursive evidence '${name}' has an empty recursion hop (no steps before the self-reference).`); return null; } const base = this._extractBase(config, name); if (!base) { this.errors.push(`Recursive evidence '${name}' has no base case — pure recursion cannot grant. Add a non-recursive statement.`); return null; } const rules = [this._deepCloneRule(base)]; for (let d = 1; d <= depth; d++) { const steps = []; for (let h = 0; h < d; h++) steps.push(...hop.map(s => this._deepCloneRule(s))); steps.push({ rule: this._deepCloneRule(base), conditionStep: true }); rules.push({ type: 'chain', steps, aggregator: 'max', collectValues: true }); } return { type: 'logical', union: { rules, aggregator: 'max' } }; } /** * Remove the recursive chain (the chain containing a self-reference) from an * evidence config and return the remainder as the base case. Returns null if * there is no base (pure recursion). */ _extractBase(config, name) { if (config.type === 'chain') { const hasSelf = (config.steps || []).some(s => (typeof s === 'string' ? s : s && s.relation) === name); return hasSelf ? null : this._deepCloneRule(config); } if (config.type === 'logical' && config.intersection) { const remaining = (config.intersection.rules || []).filter(r => { // keep rules that are not (or do not contain) the recursive chain return !this._containsSelfReference(r, name); }); if (remaining.length === 0) return null; if (remaining.length === 1) return this._deepCloneRule(remaining[0]); return { type: 'logical', intersection: { rules: remaining.map(r => this._deepCloneRule(r)), aggregator: config.intersection.aggregator || 'min' } }; } return this._containsSelfReference(config, name) ? null : this._deepCloneRule(config); } _containsSelfReference(rule, name) { return this._findSelfReference(rule, name) !== null; } /** * Recursively rewrite a rule tree, inlining references to derived evidence * configs. `stack` holds the evidence names currently being expanded so a * cyclic reference (A → B → A) is detected and reported. */ _resolveRule(rule, stack) { if (!rule || typeof rule !== 'object') return rule; if (Array.isArray(rule)) return rule.map(r => this._resolveRule(r, stack)); // Direct rule referencing a derived evidence → inline its resolved config. if (rule.type === 'direct' && rule.relation) { const ref = rule.relation; if (this.evidenceNames.has(ref)) { const referencedConfig = this.generatedRules.get(ref); if (referencedConfig) { if (stack.has(ref)) { this.errors.push(`Cyclic evidence reference involving '${ref}'. Evidence composition must be acyclic.`); return rule; } const refStack = new Set(stack); refStack.add(ref); const resolvedRef = this._resolveRule(referencedConfig, refStack); if (resolvedRef) { const clone = this._deepCloneRule(resolvedRef); if (rule._subjectAsObject) clone._subjectAsObject = true; return clone; } } } return rule; } // Recurse into logical / defeasible / nested containers: rule-lists // (union/intersection/exclusion/never/requires/when/unless .rules) and // single nested rules (always.direct, comparator operands). const out = { ...rule }; for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless', 'direct', 'rule']) { const node = out[key]; if (!node || typeof node !== 'object') continue; if (Array.isArray(node)) { out[key] = node.map(r => this._resolveRule(r, stack)); continue; } const next = { ...node }; if (Array.isArray(next.rules)) { next.rules = next.rules.map(r => this._resolveRule(r, stack)); } if (next.union && Array.isArray(next.union.rules)) { next.union = { ...next.union, rules: next.union.rules.map(r => this._resolveRule(r, stack)) }; } if (next.intersection && Array.isArray(next.intersection.rules)) { next.intersection = { ...next.intersection, rules: next.intersection.rules.map(r => this._resolveRule(r, stack)) }; } if (next.direct && typeof next.direct === 'object') { next.direct = this._resolveRule(next.direct, stack); } if (next.rule && typeof next.rule === 'object') { next.rule = this._resolveRule(next.rule, stack); } out[key] = next; } if (out.type === 'relational_comparator') { if (out.left?.rule) out.left = { ...out.left, rule: this._resolveRule(out.left.rule, stack) }; if (out.right?.rule) out.right = { ...out.right, rule: this._resolveRule(out.right.rule, stack) }; } // Chain steps may reference a derived evidence; expand those steps // (direct evidence → underlying relation, chain evidence → spliced steps). if (rule.type === 'chain' && Array.isArray(out.steps)) { out.steps = this._expandChainSteps(out.steps, stack); } return out; } /** * Expand chain steps that reference a derived evidence: * - direct evidence → rename the step to the underlying relation * (member_of(user,*g){ group_read(g,doc) } where group_read = can_view * becomes step 'can_view'); * - chain evidence → splice its steps into this chain (flattening) * (a step that is itself a sub-path becomes its steps, preserving the * linear source→…→object traversal); * - logical / defeasible / comparator evidence → only expressible as a * FINAL condition-gated step (the object is known, so the engine can * verify the condition at (intermediate, object) instead of traversing * an edge). Emitted as a `{ rule: }` step the ChainRule * evaluates as a condition hop. Non-final such steps are a compile * error: a condition cannot discover intermediate nodes. */ _expandChainSteps(steps, stack) { const out = []; for (let idx = 0; idx < steps.length; idx++) { const step = steps[idx]; const stepName = typeof step === 'string' ? step : step.relation; if (stepName && this.evidenceNames.has(stepName)) { if (stack.has(stepName)) { this.errors.push(`Cyclic evidence reference involving '${stepName}'. Evidence composition must be acyclic.`); out.push(step); continue; } const referencedConfig = this.generatedRules.get(stepName); if (referencedConfig) { const refStack = new Set(stack); refStack.add(stepName); const resolved = this._resolveRule(referencedConfig, refStack); if (resolved.type === 'direct' && resolved.relation && resolved.relation !== stepName) { out.push(typeof step === 'string' ? resolved.relation : { ...step, relation: resolved.relation }); continue; } if (resolved.type === 'chain' && Array.isArray(resolved.steps)) { out.push(...this._expandChainSteps(resolved.steps, refStack)); continue; } if (resolved.type === 'relational_comparator' && idx !== steps.length - 1) { // A comparator compares values at (src, candidate) but provides no // candidate set — it cannot enumerate intermediate nodes, so only // a FINAL comparator step (verified at the known object) lowers. this.errors.push(`Chain step '${stepName}' references a comparator evidence at a non-final position. ` + 'Comparators can only be the final chain step (the object is known); intermediate positions are not enumerable.'); out.push(step); continue; } // Condition step: inline the evidence's config as a rule step. As the // FINAL step the engine verifies it at (intermediate, object); as an // INTERMEDIATE step the engine EXPANDS it from the current node // (rule-based reachability) and continues from each discovered node. out.push({ rule: this._deepCloneRule(resolved), conditionStep: true }); continue; } } out.push(step); } return out; } _deepCloneRule(rule) { try { return structuredClone(rule); } catch { return JSON.parse(JSON.stringify(rule)); } } /** * Build rule from expression node * @param {ExpressionNode} expression - Expression to build rule from * @returns {Object|null} Rule configuration or null */ buildRuleFromExpressionNode(expression, evidence) { if (expression.type === 'AttributeAccess') { return this.buildAttributeRule(expression); } else if (expression.type === 'PredicateCall') { return this.buildPredicateRule(expression); } else if (expression.type === 'BinaryExpression' && expression.operator === 'within') { return this.buildWithinRule(expression); } else if (expression.type === 'BinaryExpression' && this._isComparatorOperator(expression.operator)) { // ADR-000 RelationalComparatorRule shape: "userRisk(u) <= riskLimit(r)". // Route BinaryExpression with comparator operators here so the // evaluator can run a fuzzy interval comparison instead of treating // them as logical truth values. RF-24 closure. return this.buildRelationalComparatorRule(expression, evidence); } this.errors.push(`Unsupported expression type: ${expression.type}`); return null; } /** * Detect comparator operators per ADR-000. These are the operators that map * to RelationalComparatorRule (vs. the boolean operators like `&&`/`||` which * keep going through LogicalOperators). */ _isComparatorOperator(operator) { return operator === '>' || operator === '>=' || operator === '<' || operator === '<=' || operator === '==' || operator === '!='; } /** * Build relational comparator rule configuration (ADR-000 RelationalComparatorRule). * Compiles "personAge(p) >= docMinAge(d)" into * { * type: 'relational_comparator', * left: { rule: , extractValue: true }, * right: { rule: , extractValue: true }, * comparator: '>=', * marginOfSafety: 1.0, * fallbackBehavior: 'deny', * minRulePossibility: 0 * } */ buildRelationalComparatorRule(binaryExpression, evidence) { const comparator = binaryExpression.operator; const left = this._buildComparatorOperand(binaryExpression.left, evidence); const right = this._buildComparatorOperand(binaryExpression.right, evidence); if (!left || !right) { this.errors.push(`Comparator operands must resolve to predicate calls (operator=${comparator})`); return null; } return { type: 'relational_comparator', left, right, comparator, marginOfSafety: 1.0, fallbackBehavior: 'deny', minRulePossibility: 0 }; } /** * Lower a comparator operand to a core-evaluable config. The operand's `rule` * MUST be a real rule configuration (the engine's RuleEvaluator only accepts * configs — raw AST nodes evaluate to 0). Per the core's operand contract: * * userRisk(user) → { rule: { type: 'direct', relation: 'userRisk' }, extractValue: true } (user perspective, auto) * riskLimit(doc) → { rule: { type: 'direct', relation: 'riskLimit' }, extractValue: true, evaluateFrom: 'object' } * * The evaluateFrom side is derived from the evidence parameter positions: * params[0] is the subject (user), params[1] is the object. A predicate call * whose first arg is the object variable reads its value from the object * perspective; anything else defaults to the user perspective. * * Literal value args (userRisk(user, 5)) annotate the operand with * `expectedValue` — the declared value the caller expects the relation to * carry. The engine compares resolved relation values; the DSLRuntime wrapper * may enforce expectedValue as an additional gate. */ _buildComparatorOperand(side, evidence) { if (!side) return null; if (side.type === 'PredicateCall') { const operand = { rule: { type: 'direct', relation: side.name, reverse: false }, extractValue: true, evaluatorFrom: 'auto', valueRelation: side.name }; const evidenceParams = (evidence && evidence.params) || []; const userVar = evidenceParams[0] && evidenceParams[0].name; const objectVar = evidenceParams[1] && evidenceParams[1].name; const firstArg = (side.args || [])[0]; const firstArgName = firstArg && (firstArg.name !== undefined ? firstArg.name : firstArg.value); if (objectVar !== undefined && firstArgName === objectVar) { operand.evaluateFrom = 'object'; } const literalArg = (side.args || []).find(a => a && a.type === 'Literal'); if (literalArg) { operand.expectedValue = literalArg.value; } return operand; } if (side.type === 'AttributeAccess') { // Attribute paths cannot lower to a relation config — the engine's // operand machinery reads relation `value` fields, not node attributes. // Reject loudly instead of emitting an unevaluable rule. this.errors.push('Comparator operands cannot be attribute accesses (use a value-carrying relation instead)'); return null; } return null; } /** * Build attribute rule configuration * @param {ExpressionNode} expression - Attribute expression * @returns {Object|null} Rule configuration or null */ buildAttributeRule(expression) { const attributePath = expression.getAttributePath(); return { type: 'direct', relation: attributePath, reverse: false }; } /** * Build function rule configuration * @param {ExpressionNode} expression - Function expression * @returns {Object|null} Rule configuration or null */ buildPredicateRule(expression, evidence) { const predicateName = expression.name; if (expression.challenge) { return this.buildChallengeRule(expression, null); } // A reference to a `BEHAVES AS transitive` fact resolves to bounded // transitive closure, not a direct edge lookup. const transitiveDepth = this.transitiveFacts.get(predicateName); if (transitiveDepth !== undefined) { return this._buildTransitiveRule(predicateName, transitiveDepth, expression.args || [], evidence); } const rule = { type: 'direct', relation: predicateName, reverse: false }; // Unary predicate calls check the relation as a self-edge on the call's // subject entity (the graph stores unary facts as self-edges). The subject // entity may be the evidence's SUBJECT or its OBJECT parameter: // banned(user) in can_open(user, doc) -> self-edge on the user // trusted(other) in peer_trusted(user, other) -> self-edge on the other // Mark _subjectAsObject (subject-as-object on the subject entity) or // _subjectIsObject (the subject entity IS the object parameter) so the // engine rewrites the pair accordingly. const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; const argName = a => a && (a.name !== undefined ? a.name : a.value); if (objectVar !== undefined) { const args = expression.args || []; const hasObjectArg = args.some(a => a && a.type === 'Variable' && a.name === objectVar); if (args.length === 1 && argName(args[0]) === objectVar) { rule._subjectIsObject = true; } else if (!hasObjectArg) { rule._subjectAsObject = true; } } // Value constraint: a literal in the object position (balance(user, 5)) // is a VALUE gate, not a node key — the rule only grants when the matched // edge carries exactly that value. this._applyExpectedValue(rule, expression.args || []); return rule; } buildWithinRule(expression) { const left = expression.left; const right = expression.right; if (left && left.type === 'PredicateCall' && left.challenge) { this.errors.push('within must be applied to a challenge proof field, e.g. !mfa(user).issued_at within 10m'); return null; } if (left && left.type === 'AttributeAccess' && left.object?.type === 'PredicateCall' && left.object?.challenge) { if (left.attribute !== 'issued_at' && left.attribute !== 'issuedAt') { this.errors.push('only issued_at is supported for challenge recency checks'); return null; } return this.buildChallengeRule(left.object, right); } this.errors.push('within operator currently only supported with challenge predicates'); return null; } buildChallengeRule(expression, duration) { const predicateName = expression.name; const subject = this._resolveChallengeSubject(expression.args || []); const withinMs = duration ? this._durationToMs(duration) : null; return { type: 'challenge', challenge: predicateName, subject, ...(withinMs !== null && { withinMs }) }; } _resolveChallengeSubject(args) { if (!args || !args.length) return 'user'; const first = args[0]; if (first?.type === 'Variable') { if (first.name === 'object') return 'object'; if (first.name === 'session') return 'session'; return 'user'; } return 'user'; } _durationToMs(duration) { if (!duration || duration.value === undefined) return null; const raw = typeof duration.value === 'string' ? duration.value : String(duration.value); const unit = duration.unit || raw.slice(-1); const numeric = parseFloat(raw); if (!Number.isFinite(numeric)) return null; switch (unit) { case 's': return numeric * 1000; case 'm': return numeric * 60 * 1000; case 'h': return numeric * 60 * 60 * 1000; case 'd': return numeric * 24 * 60 * 60 * 1000; case 'w': return numeric * 7 * 24 * 60 * 60 * 1000; default: return null; } } /** * Build measure rule configuration * @param {MeasureNode} measure - Measure to build rule for * @returns {Object|null} Rule configuration or null */ buildMeasureRuleConfig(measure) { if (!measure.body) { this.errors.push(`Measure ${measure.name} has no body`); return null; } // Measures typically generate computed rules return { type: 'computed', relation: measure.name }; } /** * Check if predicate is membership-based * @param {PredicateNode} predicate - Predicate to check * @returns {boolean} True if membership predicate */ isMembershipPredicate(predicate) { const membershipPredicates = ['member', 'member_of', 'belongs_to', 'is_member']; return membershipPredicates.includes(predicate.name.toLowerCase()); } /** * Check if predicate is hierarchy-based * @param {PredicateNode} predicate - Predicate to check * @returns {boolean} True if hierarchy predicate */ isHierarchyPredicate(predicate) { const hierarchyPredicates = ['parent', 'parent_of', 'child', 'child_of', 'ancestor', 'descendant']; return hierarchyPredicates.includes(predicate.name.toLowerCase()); } /** * Apply generated rules to arbiter */ applyRulesToArbiter() { if (!this.arbiter || typeof this.arbiter.setRelationConfig !== 'function') { // Skip applying rules if arbiter is not available or doesn't support setRelationConfig return; } // Recompile hygiene (per program scope): relations installed by a previous // compile of THIS scope but absent from the current program are stale — // remove their configs from every cache and index so a revoked relation // stops granting immediately. Relations belonging to other scopes // (compileMultiple coexistence) are left intact. const scope = this._currentScope || 'default'; const previously = this._installedByScope.get(scope) || new Set(); for (const name of previously) { if (!this.generatedRules.has(name)) { this._uninstallRelation(name); } } this.generatedRules.forEach((config, relation) => { try { this.arbiter.setRelationConfig(relation, config); } catch (error) { this.errors.push(`Failed to set relation config for ${relation}: ${error.message}`); } }); if (typeof this.arbiter.registerDependencyIndex === 'function') { this.arbiter.registerDependencyIndex(this.dependencyIndex); } this._installedByScope.set(scope, new Set(this.generatedRules.keys())); } /** * Remove a relation's config and cached state from the arbiter. Mirrors the * invalidation that setRelationConfig performs, applied to deletion. */ _uninstallRelation(name) { const arb = this.arbiter; if (!arb) return; if (arb.relationConfigs && typeof arb.relationConfigs.delete === 'function') { arb.relationConfigs.delete(name); } const analysis = arb.graphManager && arb.graphManager.analysis; if (analysis && analysis.relationConfigs && typeof analysis.relationConfigs.delete === 'function') { analysis.relationConfigs.delete(name); } if (typeof arb._invalidateDirectCheckCache === 'function') { arb._invalidateDirectCheckCache(null, name, null); } if (typeof arb.invalidateRuleResultCacheByRelation === 'function') { arb.invalidateRuleResultCacheByRelation(name); } if (arb.authChecker && typeof arb.authChecker.invalidateRuleCaches === 'function') { arb.authChecker.invalidateRuleCaches(name); } } /** * Get generated rules * @returns {Map} Map of generated rules */ getGeneratedRules() { return this.generatedRules; } /** * Get generation errors * @returns {string[]} Array of error messages */ getErrors() { return this.errors; } }