/** * Pluggable Rules Engine (arch §5.2 Rules Engine; gap v2 §5.2 partial). * * Before this PR, business rules were hardcoded at the call sites * (e.g. "plan must have a pay step" baked into iso20022.ts, SoD * matrix hard-coded in transactionState.ts). This module introduces * a minimal, declarative JSON DSL so that ruleSets can be loaded * from env (RULES_FILE) or swapped per-environment. * * Design principles * ----------------- * - No eval. The evaluator is a small recursive switch over a * closed operator set — no runtime code injection. * - Pure, deterministic, side-effect free. Evaluation order is * explicit so the engine can be reasoned about and replayed. * - Context is a flat name → value map. Callers project whatever * shape they need ({plan, state, compliance, participants}). * - Failures are collected, not thrown. The caller decides whether * a single failure aborts, or whether to accumulate and report. */ import { readFileSync } from "fs"; /** Supported primitive operators. */ export type Operator = | "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "not_in" | "exists" | "matches" // regex | "length_gte" | "length_lte"; /** Leaf condition — references a context path against a literal. */ export interface LeafCondition { path: string; // dotted path into the context object op: Operator; value?: unknown; // not required for `exists` /** Optional human label for failure messages. */ message?: string; } /** Combinator — AND / OR / NOT over child conditions. */ export interface AndCondition { all: Condition[]; message?: string; } export interface OrCondition { any: Condition[]; message?: string; } export interface NotCondition { not: Condition; message?: string; } export type Condition = LeafCondition | AndCondition | OrCondition | NotCondition; export interface Rule { id: string; description?: string; when?: Condition; // precondition — rule only fires when `when` is true assert: Condition; // the rule passes when `assert` evaluates true /** Optional severity for reporting: "error" (default) blocks, "warn" does not. */ severity?: "error" | "warn"; } export interface RuleSet { id: string; version?: string; rules: Rule[]; } export interface RuleFailure { ruleId: string; severity: "error" | "warn"; message: string; path?: string; } export interface EvaluationResult { ok: boolean; failures: RuleFailure[]; } /* ----------------------------------------------------------------- * Dotted-path resolver. Supports a.b.c and a.b[0].c. * --------------------------------------------------------------- */ function getPath(ctx: unknown, path: string): unknown { if (!path) return ctx; const parts = path .replace(/\[(\d+)\]/g, ".$1") .split(".") .filter(Boolean); let cur: unknown = ctx; for (const p of parts) { if (cur === null || cur === undefined) return undefined; if (typeof cur === "object") { cur = (cur as Record)[p]; } else { return undefined; } } return cur; } /* ----------------------------------------------------------------- * Operator evaluation. Pure — no throws. * --------------------------------------------------------------- */ function evalOp(op: Operator, actual: unknown, expected: unknown): boolean { switch (op) { case "eq": return actual === expected; case "neq": return actual !== expected; case "gt": return typeof actual === "number" && typeof expected === "number" && actual > expected; case "gte": return typeof actual === "number" && typeof expected === "number" && actual >= expected; case "lt": return typeof actual === "number" && typeof expected === "number" && actual < expected; case "lte": return typeof actual === "number" && typeof expected === "number" && actual <= expected; case "in": return Array.isArray(expected) && expected.includes(actual as never); case "not_in": return Array.isArray(expected) && !expected.includes(actual as never); case "exists": return actual !== undefined && actual !== null; case "matches": if (typeof actual !== "string" || typeof expected !== "string") return false; try { return new RegExp(expected).test(actual); } catch { return false; } case "length_gte": if (!Array.isArray(actual) && typeof actual !== "string") return false; return (actual as { length: number }).length >= (expected as number); case "length_lte": if (!Array.isArray(actual) && typeof actual !== "string") return false; return (actual as { length: number }).length <= (expected as number); default: return false; } } function isLeaf(c: Condition): c is LeafCondition { return (c as LeafCondition).op !== undefined && (c as LeafCondition).path !== undefined; } export function evaluateCondition( condition: Condition, context: Record, ): boolean { if (isLeaf(condition)) { const actual = getPath(context, condition.path); return evalOp(condition.op, actual, condition.value); } if ("all" in condition) { return condition.all.every((c) => evaluateCondition(c, context)); } if ("any" in condition) { return condition.any.some((c) => evaluateCondition(c, context)); } if ("not" in condition) { return !evaluateCondition(condition.not, context); } return false; } /* ----------------------------------------------------------------- * Public evaluate(): runs the full rule set and collects failures. * --------------------------------------------------------------- */ export function evaluate( ruleSet: RuleSet, context: Record, ): EvaluationResult { const failures: RuleFailure[] = []; for (const rule of ruleSet.rules) { if (rule.when && !evaluateCondition(rule.when, context)) continue; const passed = evaluateCondition(rule.assert, context); if (!passed) { failures.push({ ruleId: rule.id, severity: rule.severity ?? "error", message: rule.description ?? `rule ${rule.id} failed`, path: isLeaf(rule.assert) ? rule.assert.path : undefined, }); } } const blocking = failures.filter((f) => f.severity === "error"); return { ok: blocking.length === 0, failures }; } /* ----------------------------------------------------------------- * Built-in rule sets. These mirror the pre-DSL hardcoded checks so * callers can migrate incrementally. * --------------------------------------------------------------- */ /** Preconditions check — arch §8 PRECONDITIONS_PENDING -> READY_FOR_PREPARE. */ export const BUILTIN_PRECONDITIONS: RuleSet = { id: "preconditions.builtin", version: "1", rules: [ { id: "plan.exists", description: "plan must be present on the context", assert: { path: "plan", op: "exists" }, }, { id: "plan.steps.non_empty", description: "plan must contain at least one step", assert: { path: "plan.steps", op: "length_gte", value: 1 }, }, { id: "plan.pay_step_present", description: "plan must contain at least one pay step (ISO-20022 envelope)", assert: { any: [ { path: "plan.steps[0].type", op: "eq", value: "pay" }, { path: "plan.steps[1].type", op: "eq", value: "pay" }, { path: "plan.steps[2].type", op: "eq", value: "pay" }, { path: "plan.steps[3].type", op: "eq", value: "pay" }, ], }, }, { id: "participants.at_least_one", description: "participant registry must not be empty", assert: { path: "participants", op: "length_gte", value: 1 }, }, { id: "compliance.kyc_ok", description: "compliance KYC status must be ok", when: { path: "compliance", op: "exists" }, assert: { path: "compliance.kyc", op: "eq", value: "ok" }, }, ], }; /** Commit rule — arch §9.2. */ export const BUILTIN_COMMIT: RuleSet = { id: "commit.builtin", version: "1", rules: [ { id: "dlt.tx_hash", description: "DLT leg must produce a 0x + 64-hex tx hash", assert: { path: "dlt.txHash", op: "matches", value: "^0x[0-9a-fA-F]{64}$" }, }, { id: "bank.iso_message_id", description: "bank leg must produce a non-empty ISO message id", assert: { path: "bank.isoMessageId", op: "exists" }, }, { id: "state.is_validating", description: "commit is only valid from VALIDATING", assert: { path: "state", op: "eq", value: "VALIDATING" }, }, { id: "no_exception_holds", description: "no exception may be outstanding", assert: { path: "exceptions.active", op: "length_lte", value: 0 }, }, ], }; /* ----------------------------------------------------------------- * Loader: RULES_FILE env points at a JSON file containing a map * {ruleSetId: RuleSet}. Falls back to built-ins on any error. * --------------------------------------------------------------- */ let cachedOverrides: Record | undefined; export function getRuleSet(id: string): RuleSet { if (cachedOverrides === undefined) { cachedOverrides = {}; const path = process.env.RULES_FILE; if (path) { try { const raw = readFileSync(path, "utf8"); const parsed = JSON.parse(raw) as Record; if (parsed && typeof parsed === "object") cachedOverrides = parsed; } catch { // leave empty — silent fall-through to built-ins } } } if (cachedOverrides[id]) return cachedOverrides[id]; if (id === BUILTIN_PRECONDITIONS.id) return BUILTIN_PRECONDITIONS; if (id === BUILTIN_COMMIT.id) return BUILTIN_COMMIT; return { id, rules: [] }; } export function __resetRulesCacheForTests(): void { cachedOverrides = undefined; }