Files
evidence-dsl/src/generator/RuleGenerator.js
T
John Dvorak ae21605fb7
CI / test (push) Successful in 11s
CI / publish (push) Successful in 9s
evidence-dsl: extract Evidence DSL v2 compiler from @arbiter/core
The Evidence DSL (ADR-000) is a thin declarative layer that compiles to
engine rule types. It has zero runtime coupling to the core engine
(DSLCompiler takes an arbiter as a duck-typed argument; the only shared
code was the ip-utils helpers, now local). Extracting it into its own
package keeps the core artifact free of the DSL surface.

- @arbiter/evidence-dsl depends on @arbiter/core (config formats are the
  compilation target)
- deep-path exports for the compiler, parser, generator, validation,
  and built-in functions (the surface the core's DSL tests consume)
- tests moved alongside; generate-parser script + peggy devDep local
- CI: test on push, publish on v* tags
2026-08-03 08:48:39 -07:00

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;
}
}