2026-08-03 08:48:39 -07:00
|
|
|
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();
|
2026-08-03 11:17:30 -07:00
|
|
|
this.evidenceNames = new Set();
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
// 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();
|
|
|
|
|
|
2026-08-03 08:48:39 -07:00
|
|
|
// 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;
|
2026-08-03 11:17:30 -07:00
|
|
|
this.evidenceNames.add(relationName);
|
2026-08-03 08:48:39 -07:00
|
|
|
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);
|
2026-08-03 12:08:54 -07:00
|
|
|
else if (step && step.rule) collect(step.rule, targetSet);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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);
|
2026-08-03 11:17:30 -07:00
|
|
|
if (node?.direct) collect(node.direct, targetSet);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildSingleStatementRule(statements[0], evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle multiple statements with logical operators
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildLogicalRule(statements, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build rule configuration for a single statement
|
|
|
|
|
* @param {BaseNode} statement - Statement to build rule for
|
2026-08-03 10:58:29 -07:00
|
|
|
* @param {Object} evidence - Evidence definition (params inform pattern/operand lowering)
|
2026-08-03 08:48:39 -07:00
|
|
|
* @returns {Object|null} Rule configuration or null
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildSingleStatementRule(statement, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
switch (statement.type) {
|
|
|
|
|
case 'DirectEvidence':
|
2026-08-03 11:17:30 -07:00
|
|
|
return this.buildDirectRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
case 'PatternMatch':
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildPatternMatchRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
case 'DefeasibleLogic':
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildDefeasibleRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
case 'Fusion':
|
|
|
|
|
return this.buildFusionRule(statement);
|
|
|
|
|
case 'PredicateCall':
|
2026-08-03 11:17:30 -07:00
|
|
|
return this.buildPredicateRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
case 'UnaryExpression':
|
|
|
|
|
return this.buildUnaryRule(statement);
|
|
|
|
|
case 'BinaryExpression':
|
|
|
|
|
// Top-level comparator — emit a relational_comparator rule. RF-24 closure.
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildRuleFromExpressionNode(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
case 'Expression':
|
|
|
|
|
// Handle expressions that might be predicate calls
|
|
|
|
|
if (statement.type === 'PredicateCall') {
|
2026-08-03 11:17:30 -07:00
|
|
|
return this.buildPredicateRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildRuleFromExpressionNode(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 08:48:39 -07:00
|
|
|
const rules = [];
|
2026-08-03 10:58:29 -07:00
|
|
|
|
|
|
|
|
others.forEach(statement => {
|
|
|
|
|
const rule = this.buildSingleStatementRule(statement, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 10:58:29 -07:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 08:48:39 -07:00
|
|
|
/**
|
|
|
|
|
* Build direct rule configuration
|
|
|
|
|
* @param {DirectEvidenceNode} directEvidence - Direct evidence statement
|
|
|
|
|
* @returns {Object|null} Rule configuration or null
|
|
|
|
|
*/
|
2026-08-03 11:17:30 -07:00
|
|
|
buildDirectRule(directEvidence, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
if (!directEvidence.predicate) {
|
|
|
|
|
this.errors.push('Direct evidence must have a predicate');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const predicate = directEvidence.predicate;
|
|
|
|
|
const relation = predicate.name;
|
2026-08-03 11:17:30 -07:00
|
|
|
|
|
|
|
|
const rule = {
|
2026-08-03 08:48:39 -07:00
|
|
|
type: 'direct',
|
|
|
|
|
relation: relation,
|
|
|
|
|
reverse: false
|
|
|
|
|
};
|
2026-08-03 11:17:30 -07:00
|
|
|
|
|
|
|
|
// 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;
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build pattern match rule configuration
|
|
|
|
|
* @param {PatternMatchNode} patternMatch - Pattern match statement
|
|
|
|
|
* @returns {Object|null} Rule configuration or null
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildPatternMatchRule(patternMatch, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
if (!patternMatch.predicate) {
|
|
|
|
|
this.errors.push('Pattern match must have a predicate');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const predicate = patternMatch.predicate;
|
|
|
|
|
const relation = predicate.name;
|
|
|
|
|
|
2026-08-03 10:58:29 -07:00
|
|
|
// 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).
|
2026-08-03 08:48:39 -07:00
|
|
|
if (this.isMembershipPredicate(predicate)) {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildTupleToUsersetRule(patternMatch, inner);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
if (this.isHierarchyPredicate(predicate)) {
|
|
|
|
|
return this.buildParentRule(patternMatch);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this._isChainPattern(patternMatch)) {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildChainRule(patternMatch, inner);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.buildMultiHopRule(patternMatch);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 10:58:29 -07:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 08:48:39 -07:00
|
|
|
/**
|
|
|
|
|
* 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).
|
2026-08-03 10:58:29 -07:00
|
|
|
* Compiles "works_in(user, *d) { has_access(d, doc) }" into
|
2026-08-03 08:48:39 -07:00
|
|
|
* { type: 'chain', steps: ['works_in', 'has_access'] }
|
2026-08-03 10:58:29 -07:00
|
|
|
* 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.
|
2026-08-03 08:48:39 -07:00
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildChainRule(patternMatch, inner) {
|
2026-08-03 08:48:39 -07:00
|
|
|
const steps = [];
|
|
|
|
|
steps.push(patternMatch.predicate.name);
|
|
|
|
|
|
2026-08-03 10:58:29 -07:00
|
|
|
const innerPredicate = inner || (patternMatch.body.statements[0]);
|
|
|
|
|
if (innerPredicate && innerPredicate.type === 'PredicateCall' && innerPredicate.name) {
|
|
|
|
|
steps.push(innerPredicate.name);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'chain',
|
|
|
|
|
steps,
|
|
|
|
|
aggregator: 'max',
|
|
|
|
|
collectValues: true
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-03 10:58:29 -07:00
|
|
|
* 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').
|
2026-08-03 08:48:39 -07:00
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildTupleToUsersetRule(patternMatch, inner) {
|
2026-08-03 08:48:39 -07:00
|
|
|
const predicate = patternMatch.predicate;
|
|
|
|
|
const relation = predicate.name;
|
2026-08-03 10:58:29 -07:00
|
|
|
const computedRelation = inner && inner.name ? inner.name : relation;
|
|
|
|
|
const wildcardIndex = (predicate.args || []).findIndex(a => a && a.type === 'Wildcard');
|
|
|
|
|
|
2026-08-03 08:48:39 -07:00
|
|
|
return {
|
|
|
|
|
type: 'tuple_to_userset',
|
2026-08-03 10:58:29 -07:00
|
|
|
tuplesetRelation: relation,
|
|
|
|
|
computedRelation,
|
|
|
|
|
tuplesetDirection: wildcardIndex === 0 ? 'in' : 'out',
|
2026-08-03 08:48:39 -07:00
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildDefeasibleRule(defeasibleLogic, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
const logicType = defeasibleLogic.logicType;
|
|
|
|
|
|
|
|
|
|
if (logicType === 'NEVER') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildNeverRule(defeasibleLogic, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (logicType === 'ALWAYS') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildStrictRule(defeasibleLogic, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (logicType === 'WHEN') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (logicType === 'UNLESS') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildDefeaterRule(defeasibleLogic, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (logicType === 'REQUIRES') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildRequirementRule(defeasibleLogic, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildNeverRule(defeasibleLogic, evidence) {
|
|
|
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildStrictRule(defeasibleLogic, evidence) {
|
|
|
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence) {
|
|
|
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence);
|
|
|
|
|
const defeater = this.buildRuleFromExpression(defeasibleLogic.defeater, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildDefeaterRule(defeasibleLogic, evidence) {
|
|
|
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildRequirementRule(defeasibleLogic, evidence) {
|
|
|
|
|
const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildRuleFromExpression(expression, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
if (!expression) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expression.type === 'Predicate') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildDirectRuleFromPredicate(expression, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (expression.type === 'Expression') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildRuleFromExpressionNode(expression, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (expression.type === 'PredicateCall') {
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildPredicateRule(expression, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
} else if (expression.type === 'UnaryExpression') {
|
|
|
|
|
return this.buildUnaryRule(expression);
|
2026-08-03 10:58:29 -07:00
|
|
|
} else if (expression.type === 'BinaryExpression') {
|
|
|
|
|
return this.buildRuleFromExpressionNode(expression, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildDirectRuleFromPredicate(predicate, evidence) {
|
|
|
|
|
const rule = {
|
2026-08-03 08:48:39 -07:00
|
|
|
type: 'direct',
|
|
|
|
|
relation: predicate.name,
|
|
|
|
|
reverse: false
|
|
|
|
|
};
|
2026-08-03 10:58:29 -07:00
|
|
|
|
|
|
|
|
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;
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
_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.
|
|
|
|
|
}
|
2026-08-03 08:48:39 -07:00
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-03 08:48:39 -07:00
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2026-08-03 08:48:39 -07:00
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
// 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) };
|
|
|
|
|
}
|
2026-08-03 11:34:52 -07:00
|
|
|
// 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);
|
2026-08-03 12:08:54 -07:00
|
|
|
* - 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.
|
2026-08-03 11:34:52 -07:00
|
|
|
*/
|
|
|
|
|
_expandChainSteps(steps, stack) {
|
|
|
|
|
const out = [];
|
2026-08-03 12:08:54 -07:00
|
|
|
for (let idx = 0; idx < steps.length; idx++) {
|
|
|
|
|
const step = steps[idx];
|
|
|
|
|
const isLast = idx === steps.length - 1;
|
2026-08-03 11:34:52 -07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-03 12:08:54 -07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-03 11:34:52 -07:00
|
|
|
this.errors.push(`Chain step '${stepName}' references an evidence with type '${resolved.type || 'logical'}'. ` +
|
2026-08-03 12:08:54 -07:00
|
|
|
'Only the final chain step may reference a defeasible/logical evidence (a condition-gated hop); intermediate steps must be edge traversals.');
|
2026-08-03 11:34:52 -07:00
|
|
|
out.push(step);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.push(step);
|
|
|
|
|
}
|
2026-08-03 11:17:30 -07:00
|
|
|
return out;
|
|
|
|
|
}
|
2026-08-03 08:48:39 -07:00
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
_deepCloneRule(rule) {
|
|
|
|
|
try {
|
|
|
|
|
return structuredClone(rule);
|
|
|
|
|
} catch {
|
|
|
|
|
return JSON.parse(JSON.stringify(rule));
|
|
|
|
|
}
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build rule from expression node
|
|
|
|
|
* @param {ExpressionNode} expression - Expression to build rule from
|
|
|
|
|
* @returns {Object|null} Rule configuration or null
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildRuleFromExpressionNode(expression, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
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.
|
2026-08-03 10:58:29 -07:00
|
|
|
return this.buildRelationalComparatorRule(expression, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
* }
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildRelationalComparatorRule(binaryExpression, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
const comparator = binaryExpression.operator;
|
2026-08-03 10:58:29 -07:00
|
|
|
const left = this._buildComparatorOperand(binaryExpression.left, evidence);
|
|
|
|
|
const right = this._buildComparatorOperand(binaryExpression.right, evidence);
|
2026-08-03 08:48:39 -07:00
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-03 10:58:29 -07:00
|
|
|
* 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.
|
2026-08-03 08:48:39 -07:00
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
_buildComparatorOperand(side, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
if (!side) return null;
|
|
|
|
|
if (side.type === 'PredicateCall') {
|
2026-08-03 10:58:29 -07:00
|
|
|
const operand = {
|
|
|
|
|
rule: { type: 'direct', relation: side.name, reverse: false },
|
2026-08-03 08:48:39 -07:00
|
|
|
extractValue: true,
|
2026-08-03 10:58:29 -07:00
|
|
|
evaluatorFrom: 'auto',
|
|
|
|
|
valueRelation: side.name
|
2026-08-03 08:48:39 -07:00
|
|
|
};
|
2026-08-03 10:58:29 -07:00
|
|
|
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;
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
if (side.type === 'AttributeAccess') {
|
2026-08-03 10:58:29 -07:00
|
|
|
// 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;
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-08-03 10:58:29 -07:00
|
|
|
buildPredicateRule(expression, evidence) {
|
2026-08-03 08:48:39 -07:00
|
|
|
const predicateName = expression.name;
|
|
|
|
|
if (expression.challenge) {
|
|
|
|
|
return this.buildChallengeRule(expression, null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 10:58:29 -07:00
|
|
|
const rule = {
|
2026-08-03 08:48:39 -07:00
|
|
|
type: 'direct',
|
|
|
|
|
relation: predicateName,
|
|
|
|
|
reverse: false
|
|
|
|
|
};
|
2026-08-03 10:58:29 -07:00
|
|
|
|
|
|
|
|
// 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;
|
2026-08-03 08:48:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|