1040 lines
32 KiB
JavaScript
1040 lines
32 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) {
|
||
|
|
this.arbiter = arbiter;
|
||
|
|
this.generatedRules = new Map();
|
||
|
|
this.errors = [];
|
||
|
|
this.dependencyIndex = new Map();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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;
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle multiple statements with logical operators
|
||
|
|
return this.buildLogicalRule(statements);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build rule configuration for a single statement
|
||
|
|
* @param {BaseNode} statement - Statement to build rule for
|
||
|
|
* @returns {Object|null} Rule configuration or null
|
||
|
|
*/
|
||
|
|
buildSingleStatementRule(statement) {
|
||
|
|
switch (statement.type) {
|
||
|
|
case 'DirectEvidence':
|
||
|
|
return this.buildDirectRule(statement);
|
||
|
|
case 'PatternMatch':
|
||
|
|
return this.buildPatternMatchRule(statement);
|
||
|
|
case 'DefeasibleLogic':
|
||
|
|
return this.buildDefeasibleRule(statement);
|
||
|
|
case 'Fusion':
|
||
|
|
return this.buildFusionRule(statement);
|
||
|
|
case 'PredicateCall':
|
||
|
|
return this.buildPredicateRule(statement);
|
||
|
|
case 'UnaryExpression':
|
||
|
|
return this.buildUnaryRule(statement);
|
||
|
|
case 'BinaryExpression':
|
||
|
|
// Top-level comparator — emit a relational_comparator rule. RF-24 closure.
|
||
|
|
return this.buildRuleFromExpressionNode(statement);
|
||
|
|
case 'Expression':
|
||
|
|
// Handle expressions that might be predicate calls
|
||
|
|
if (statement.type === 'PredicateCall') {
|
||
|
|
return this.buildPredicateRule(statement);
|
||
|
|
}
|
||
|
|
return this.buildRuleFromExpressionNode(statement);
|
||
|
|
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) {
|
||
|
|
const rules = [];
|
||
|
|
|
||
|
|
statements.forEach(statement => {
|
||
|
|
const rule = this.buildSingleStatementRule(statement);
|
||
|
|
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'
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build direct rule configuration
|
||
|
|
* @param {DirectEvidenceNode} directEvidence - Direct evidence statement
|
||
|
|
* @returns {Object|null} Rule configuration or null
|
||
|
|
*/
|
||
|
|
buildDirectRule(directEvidence) {
|
||
|
|
if (!directEvidence.predicate) {
|
||
|
|
this.errors.push('Direct evidence must have a predicate');
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const predicate = directEvidence.predicate;
|
||
|
|
const relation = predicate.name;
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'direct',
|
||
|
|
relation: relation,
|
||
|
|
reverse: false
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build pattern match rule configuration
|
||
|
|
* @param {PatternMatchNode} patternMatch - Pattern match statement
|
||
|
|
* @returns {Object|null} Rule configuration or null
|
||
|
|
*/
|
||
|
|
buildPatternMatchRule(patternMatch) {
|
||
|
|
if (!patternMatch.predicate) {
|
||
|
|
this.errors.push('Pattern match must have a predicate');
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const predicate = patternMatch.predicate;
|
||
|
|
const relation = predicate.name;
|
||
|
|
|
||
|
|
// Membership/hierarchy predicates map to TupleToUsersetRule / ParentRule
|
||
|
|
// regardless of body shape — those have priority over chain detection.
|
||
|
|
if (this.isMembershipPredicate(predicate)) {
|
||
|
|
return this.buildTupleToUsersetRule(patternMatch);
|
||
|
|
}
|
||
|
|
if (this.isHierarchyPredicate(predicate)) {
|
||
|
|
return this.buildParentRule(patternMatch);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chain detection: ADR-000 ChainRule shape is "works_in(p, *d) { has_access(d, r) }".
|
||
|
|
// The outer PatternMatch has a Wildcard binding, and its body contains a single
|
||
|
|
// PredicateCall (no DefeasibleLogic wrapping, no nested PatternMatch). Treat that
|
||
|
|
// as a chain: two-hop traversal through the wildcard intermediate. RF-24 closure
|
||
|
|
// (parallel to RF-22/RF-23 — DSL→engine mapping gap surfaced by rigor coverage).
|
||
|
|
if (this._isChainPattern(patternMatch)) {
|
||
|
|
return this.buildChainRule(patternMatch);
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.buildMultiHopRule(patternMatch);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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(p, *d) { has_access(d, doc) }" into
|
||
|
|
* { type: 'chain', steps: ['works_in', 'has_access'] }
|
||
|
|
* The intermediate wildcard binds the two predicates' arguments.
|
||
|
|
*/
|
||
|
|
buildChainRule(patternMatch) {
|
||
|
|
const steps = [];
|
||
|
|
steps.push(patternMatch.predicate.name);
|
||
|
|
|
||
|
|
const inner = patternMatch.body.statements[0];
|
||
|
|
if (inner && inner.type === 'PredicateCall' && inner.name) {
|
||
|
|
steps.push(inner.name);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'chain',
|
||
|
|
steps,
|
||
|
|
aggregator: 'max',
|
||
|
|
collectValues: true
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build tuple-to-userset rule configuration
|
||
|
|
* @param {PatternMatchNode} patternMatch - Pattern match statement
|
||
|
|
* @returns {Object|null} Rule configuration or null
|
||
|
|
*/
|
||
|
|
buildTupleToUsersetRule(patternMatch) {
|
||
|
|
const predicate = patternMatch.predicate;
|
||
|
|
const relation = predicate.name;
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'tuple_to_userset',
|
||
|
|
tuplesetRelation: 'owner', // Default, could be inferred from context
|
||
|
|
computedRelation: relation,
|
||
|
|
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) {
|
||
|
|
const logicType = defeasibleLogic.logicType;
|
||
|
|
|
||
|
|
if (logicType === 'NEVER') {
|
||
|
|
return this.buildNeverRule(defeasibleLogic);
|
||
|
|
} else if (logicType === 'ALWAYS') {
|
||
|
|
return this.buildStrictRule(defeasibleLogic);
|
||
|
|
} else if (logicType === 'WHEN') {
|
||
|
|
return this.buildDefeasibleRuleWithDefeater(defeasibleLogic);
|
||
|
|
} else if (logicType === 'UNLESS') {
|
||
|
|
return this.buildDefeaterRule(defeasibleLogic);
|
||
|
|
} else if (logicType === 'REQUIRES') {
|
||
|
|
return this.buildRequirementRule(defeasibleLogic);
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition);
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition);
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition);
|
||
|
|
const defeater = this.buildRuleFromExpression(defeasibleLogic.defeater);
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition);
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition);
|
||
|
|
|
||
|
|
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) {
|
||
|
|
if (!expression) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (expression.type === 'Predicate') {
|
||
|
|
return this.buildDirectRuleFromPredicate(expression);
|
||
|
|
} else if (expression.type === 'Expression') {
|
||
|
|
return this.buildRuleFromExpressionNode(expression);
|
||
|
|
} else if (expression.type === 'PredicateCall') {
|
||
|
|
return this.buildPredicateRule(expression);
|
||
|
|
} else if (expression.type === 'UnaryExpression') {
|
||
|
|
return this.buildUnaryRule(expression);
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const expanded = this._expandPredicate(predicate.name);
|
||
|
|
if (expanded) return expanded;
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'direct',
|
||
|
|
relation: predicate.name,
|
||
|
|
reverse: false
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_expandPredicate(predicateName) {
|
||
|
|
const existingConfig = this.generatedRules.get(predicateName) || this.arbiter?.relationConfigs?.get(predicateName);
|
||
|
|
if (!existingConfig) return null;
|
||
|
|
if (!existingConfig.union && !existingConfig.intersection && !existingConfig.exclusion) return null;
|
||
|
|
|
||
|
|
const logicalKey = existingConfig.union ? 'union' : existingConfig.intersection ? 'intersection' : 'exclusion';
|
||
|
|
const subRules = Array.isArray(existingConfig[logicalKey]?.rules)
|
||
|
|
? existingConfig[logicalKey].rules
|
||
|
|
: Array.isArray(existingConfig[logicalKey]) ? existingConfig[logicalKey] : [];
|
||
|
|
|
||
|
|
if (subRules.length === 0) return null;
|
||
|
|
|
||
|
|
const expandedRules = subRules.map(r => {
|
||
|
|
if (r && r.type === 'direct') return { type: 'direct', relation: r.relation, reverse: !!r.reverse };
|
||
|
|
if (typeof r === 'string') return { type: 'direct', relation: r, reverse: false };
|
||
|
|
return null;
|
||
|
|
}).filter(Boolean);
|
||
|
|
|
||
|
|
if (expandedRules.length === 0) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'logical',
|
||
|
|
[logicalKey]: {
|
||
|
|
rules: expandedRules,
|
||
|
|
aggregator: existingConfig[logicalKey]?.aggregator || 'min'
|
||
|
|
},
|
||
|
|
// Flag to tell the evaluator: this expanded sub-predicate is unary —
|
||
|
|
// use the subject as the object instead of inheriting the parent's object.
|
||
|
|
_subjectAsObject: true
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build rule from expression node
|
||
|
|
* @param {ExpressionNode} expression - Expression to build rule from
|
||
|
|
* @returns {Object|null} Rule configuration or null
|
||
|
|
*/
|
||
|
|
buildRuleFromExpressionNode(expression) {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const comparator = binaryExpression.operator;
|
||
|
|
const left = this._buildComparatorOperand(binaryExpression.left);
|
||
|
|
const right = this._buildComparatorOperand(binaryExpression.right);
|
||
|
|
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
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wrap a BinaryExpression side into a relational_comparator operand. The
|
||
|
|
* operand's `rule` field is the original predicate call (preserving reference
|
||
|
|
* semantics so the inner rule's evaluator can resolve its values). `extractValue`
|
||
|
|
* tells the evaluator to read the relation's `value` field rather than its
|
||
|
|
* `possibility`, which is what `personAge(p)` / `docMinAge(d)` semantics require.
|
||
|
|
*/
|
||
|
|
_buildComparatorOperand(side) {
|
||
|
|
if (!side) return null;
|
||
|
|
if (side.type === 'PredicateCall') {
|
||
|
|
return {
|
||
|
|
rule: side,
|
||
|
|
extractValue: true,
|
||
|
|
evaluatorFrom: 'auto'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (side.type === 'AttributeAccess') {
|
||
|
|
// user.age — treat the attribute path as a "measure" reference
|
||
|
|
return {
|
||
|
|
rule: side,
|
||
|
|
extractValue: true,
|
||
|
|
evaluatorFrom: 'auto',
|
||
|
|
attributePath: side.getAttributePath ? side.getAttributePath() : 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) {
|
||
|
|
const predicateName = expression.name;
|
||
|
|
if (expression.challenge) {
|
||
|
|
return this.buildChallengeRule(expression, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Expand composite (logical) predicate references into their direct
|
||
|
|
// leaf components so the optimizer can flatten to a correct direct_list.
|
||
|
|
const expanded = this._expandPredicate(predicateName);
|
||
|
|
if (expanded) return expanded;
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: 'direct',
|
||
|
|
relation: predicateName,
|
||
|
|
reverse: false
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|