Files
evidence-dsl/src/generator/RuleGenerator.js
T
John Dvorak 2a7f4c315b
CI / test (push) Successful in 20s
CI / publish (push) Has been skipped
feat: sources wired as recency-gated injectables; transitive closure, NOT scoping, recompile-scope uninstall; targeted parse errors
BEHAVES AS transitive now emits bounded multi_hop configs (direct checks and
evidence references), fixing a silent no-op. NOT builds keep _subjectAsObject
scoping so unary predicates negate the right node, and value-typed evidence
objects gate by exact edge value. Recompiling a scope uninstalls its stale
relation configs (compileMultiple coexistence preserved). Sources become
injectable relations honored by requiredFacts with a within-X recency gate.
Duplicate definition fields and three common declaration mistakes (within on a
fact, two BEHAVES clauses, limit on a non-pattern body) now produce targeted
errors. Provider edges referencing unknown nodes are warned and dropped.
2026-08-03 20:27:13 -07:00

1757 lines
64 KiB
JavaScript

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: <config> }` 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: <predicate-call>, extractValue: true },
* right: { rule: <predicate-call>, 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;
}
}