Files
evidence-dsl/src/generator/RuleGenerator.js
T

1439 lines
50 KiB
JavaScript
Raw Normal View History

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) {
this.arbiter = arbiter;
this.generatedRules = new Map();
this.errors = [];
this.dependencyIndex = new Map();
this.evidenceNames = new Set();
}
/**
* 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) {
this.errors = [];
this.generatedRules.clear();
this.dependencyIndex.clear();
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);
});
// 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);
this.generatedRules.set(name, {
type: 'direct',
relation: name,
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]
)
)
});
}
_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);
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
* @returns {Object|null} Rule configuration or null
*/
buildUnaryRule(expression) {
if (expression.operator !== 'NOT') {
this.errors.push(`Unsupported unary operator: ${expression.operator}`);
return null;
}
const innerRule = this.buildRuleFromExpression(expression.operand);
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;
const rule = {
type: 'direct',
relation: relation,
reverse: false
};
// Subject-scoped (unary) call: the predicate call's args omit the
// evidence's object parameter (user_risk(user, 1) inside a binary
// evidence) → check the relation on the subject itself.
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 && !(predicate.arguments || []).some(a => argName(a) === objectVar)) {
rule._subjectAsObject = true;
}
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
};
}
/**
* 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
};
}
/**
* 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);
} 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) {
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;
}
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;
const stack = new Set([name]);
const resolved = this._resolveRule(this.generatedRules.get(name), stack);
this.generatedRules.set(name, resolved);
this._annotateDependencies(name, resolved);
}
}
/**
* 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 isLast = idx === steps.length - 1;
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 (isLast) {
// Condition-gated final hop: inline the evidence's config as a
// rule step the engine evaluates at (intermediate, object).
out.push({ rule: this._deepCloneRule(resolved), conditionStep: true });
continue;
}
this.errors.push(`Chain step '${stepName}' references an evidence with type '${resolved.type || 'logical'}'. ` +
'Only the final chain step may reference a defeasible/logical evidence (a condition-gated hop); intermediate steps must be edge traversals.');
out.push(step);
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);
}
const rule = {
type: 'direct',
relation: predicateName,
reverse: false
};
// Subject-scoped (unary) predicate call: the call's variable args omit the
// evidence's object parameter (banned(user) inside can_open(user, doc)).
// Mark _subjectAsObject so the engine checks the relation on the subject
// itself — the unary fact's self-edge — instead of (subject, object).
const evidenceParams = (evidence && evidence.params) || [];
const objectVar = evidenceParams[1] && evidenceParams[1].name;
if (objectVar !== undefined) {
const hasObjectArg = (expression.args || []).some(a =>
a && a.type === 'Variable' && a.name === objectVar);
if (!hasObjectArg) {
rule._subjectAsObject = true;
}
}
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;
}
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);
}
}
/**
* 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;
}
}