Compare commits
1 Commits
devin/1776
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ff0e4cc0 |
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Executions DB helpers — arch §4 canonical "Execution Reference Set".
|
||||
*
|
||||
* The executions row is the join point for the three dispatch references
|
||||
* that must be reconciled at VALIDATING time (arch §9.2):
|
||||
*
|
||||
* - dlt_tx_hash — shared-state / ledger anchor (Chain-138)
|
||||
* - iso_message_id — ISO-20022 envelope id (pacs.009 / pacs.008)
|
||||
* - swift_message_id — SWIFT FIN reference for the leg (MT760 / MT202)
|
||||
* - swift_message_type — FIN msg type ("MT760" | "MT202" | "pacs.009" …)
|
||||
*
|
||||
* `recordExecution()` UPSERTs by (plan_id, execution_id).
|
||||
*/
|
||||
|
||||
import { query } from "./postgres";
|
||||
|
||||
export interface ExecutionRow {
|
||||
execution_id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
error: string | null;
|
||||
dlt_tx_hash: string | null;
|
||||
iso_message_id: string | null;
|
||||
swift_message_id: string | null;
|
||||
swift_message_type: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionPatch {
|
||||
status?: string;
|
||||
phase?: string;
|
||||
completedAt?: Date | null;
|
||||
error?: string | null;
|
||||
dltTxHash?: string | null;
|
||||
isoMessageId?: string | null;
|
||||
swiftMessageId?: string | null;
|
||||
swiftMessageType?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT an execution row. Safe to call repeatedly — phased fields
|
||||
* are merged via COALESCE semantics (new NULL never clobbers a prior
|
||||
* non-NULL value; explicit empty-string caller still overrides via
|
||||
* patch semantics below).
|
||||
*/
|
||||
export async function recordExecution(
|
||||
executionId: string,
|
||||
planId: string,
|
||||
patch: ExecutionPatch = {},
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO executions (
|
||||
execution_id, plan_id, status, phase, completed_at, error,
|
||||
dlt_tx_hash, iso_message_id, swift_message_id, swift_message_type
|
||||
)
|
||||
VALUES ($1, $2, COALESCE($3, 'pending'), $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (execution_id) DO UPDATE SET
|
||||
status = COALESCE(EXCLUDED.status, executions.status),
|
||||
phase = COALESCE(EXCLUDED.phase, executions.phase),
|
||||
completed_at = COALESCE(EXCLUDED.completed_at, executions.completed_at),
|
||||
error = COALESCE(EXCLUDED.error, executions.error),
|
||||
dlt_tx_hash = COALESCE(EXCLUDED.dlt_tx_hash, executions.dlt_tx_hash),
|
||||
iso_message_id = COALESCE(EXCLUDED.iso_message_id, executions.iso_message_id),
|
||||
swift_message_id = COALESCE(EXCLUDED.swift_message_id, executions.swift_message_id),
|
||||
swift_message_type = COALESCE(EXCLUDED.swift_message_type, executions.swift_message_type),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
executionId,
|
||||
planId,
|
||||
patch.status ?? null,
|
||||
patch.phase ?? null,
|
||||
patch.completedAt ?? null,
|
||||
patch.error ?? null,
|
||||
patch.dltTxHash ?? null,
|
||||
patch.isoMessageId ?? null,
|
||||
patch.swiftMessageId ?? null,
|
||||
patch.swiftMessageType ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExecution(
|
||||
executionId: string,
|
||||
): Promise<ExecutionRow | null> {
|
||||
const rows = await query<ExecutionRow>(
|
||||
`SELECT execution_id, plan_id, status, phase, started_at, completed_at,
|
||||
error, dlt_tx_hash, iso_message_id,
|
||||
swift_message_id, swift_message_type
|
||||
FROM executions
|
||||
WHERE execution_id = $1`,
|
||||
[executionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findBySwiftMessageId(
|
||||
swiftMessageId: string,
|
||||
): Promise<ExecutionRow | null> {
|
||||
const rows = await query<ExecutionRow>(
|
||||
`SELECT execution_id, plan_id, status, phase, started_at, completed_at,
|
||||
error, dlt_tx_hash, iso_message_id,
|
||||
swift_message_id, swift_message_type
|
||||
FROM executions
|
||||
WHERE swift_message_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`,
|
||||
[swiftMessageId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { query } from "../postgres";
|
||||
|
||||
/**
|
||||
* Migration 006 — executions.swift_message_id + swift_message_type
|
||||
* (arch §4 canonical "Execution Reference Set"; gap v2 §4 partial,
|
||||
* §10.6 SWIFT message ID persistence).
|
||||
*
|
||||
* The Execution Reference Set needs the SWIFT FIN reference for each
|
||||
* leg (MT760 for the instrument leg, pacs.009/MT202 for the payment
|
||||
* leg), alongside the existing `dlt_tx_hash` for the shared-state
|
||||
* anchor and `iso_message_id` for the ISO-20022 envelope. Keeping them
|
||||
* separate makes it trivial to reconcile a SWIFT acknowledgment
|
||||
* (camt.025/054) against the originating dispatch.
|
||||
*/
|
||||
export async function up() {
|
||||
await query(
|
||||
`ALTER TABLE executions
|
||||
ADD COLUMN IF NOT EXISTS swift_message_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS swift_message_type VARCHAR(32)`,
|
||||
);
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_executions_swift_message_id
|
||||
ON executions(swift_message_id)
|
||||
WHERE swift_message_id IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down() {
|
||||
await query(
|
||||
`ALTER TABLE executions
|
||||
DROP COLUMN IF EXISTS swift_message_id,
|
||||
DROP COLUMN IF EXISTS swift_message_type`,
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { up as up001 } from "./001_initial_schema";
|
||||
import { up as up002 } from "./002_transaction_state";
|
||||
import { up as up003 } from "./003_events";
|
||||
import { up as up004 } from "./004_idempotency_keys";
|
||||
import { up as up006 } from "./006_executions_swift";
|
||||
|
||||
/**
|
||||
* Run all migrations
|
||||
@@ -13,7 +12,6 @@ export async function runMigration() {
|
||||
await up002();
|
||||
await up003();
|
||||
await up004();
|
||||
await up006();
|
||||
console.log("All migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Plan } from "../types/plan";
|
||||
import { generatePacs008 } from "./iso20022";
|
||||
import { generateMt760 } from "./swift";
|
||||
|
||||
/**
|
||||
* Prepare bank instruction (2PC prepare phase)
|
||||
@@ -26,57 +25,27 @@ export async function prepareBankInstruction(plan: Plan): Promise<boolean> {
|
||||
export async function commitBankInstruction(plan: Plan): Promise<{
|
||||
success: boolean;
|
||||
isoMessageId?: string;
|
||||
/** SWIFT FIN reference for the leg (arch §4 Execution Reference Set). */
|
||||
swiftMessageId?: string;
|
||||
/** FIN message type, e.g. "MT760" for instrument issue, "MT202"/"pacs.009" for FI transfer. */
|
||||
swiftMessageType?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
console.log(`[Bank] Committing instruction for plan ${plan.plan_id}`);
|
||||
|
||||
|
||||
try {
|
||||
// Generate final ISO-20022 envelope.
|
||||
await generatePacs008(plan);
|
||||
|
||||
// Generate final ISO-20022 message
|
||||
const isoMessage = await generatePacs008(plan);
|
||||
|
||||
// Mock: In real implementation, this would:
|
||||
// 1. Send ISO message to bank connector
|
||||
// 2. Receive confirmation and message ID
|
||||
// 3. Store message ID for audit trail
|
||||
|
||||
const isoMessageId = `MSG-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Generate a SWIFT reference for the leg. If any step is an
|
||||
// instrument issuance (issueInstrument) we pin MT760; otherwise
|
||||
// this is a pacs.009 / MT202 FI credit transfer. We don't send
|
||||
// anything over the wire here — PR R stands up the FIN-link
|
||||
// sandbox transport.
|
||||
const hasInstrument = plan.steps.some((s) => s.type === "issueInstrument");
|
||||
let swiftMessageId: string | undefined;
|
||||
let swiftMessageType: string | undefined;
|
||||
try {
|
||||
if (hasInstrument) {
|
||||
const instrumentStep = plan.steps.find((s) => s.type === "issueInstrument");
|
||||
if (instrumentStep?.instrument) {
|
||||
const txRef = `MT760-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`.toUpperCase();
|
||||
const mt760 = generateMt760(instrumentStep.instrument, {
|
||||
transactionReference: txRef,
|
||||
issueDate: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
swiftMessageId = mt760.messageReference;
|
||||
swiftMessageType = "MT760";
|
||||
}
|
||||
} else {
|
||||
swiftMessageId = `MT202-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`.toUpperCase();
|
||||
swiftMessageType = "MT202";
|
||||
}
|
||||
} catch (err) {
|
||||
// SWIFT generator errors should not fail the leg in mock mode — we
|
||||
// still have an ISO message id. Surface the error in the log.
|
||||
console.warn(`[Bank] SWIFT reference generation skipped: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
|
||||
// Simulate processing delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isoMessageId,
|
||||
swiftMessageId,
|
||||
swiftMessageType,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { getPlanById, updatePlanStatus } from "../db/plans";
|
||||
import { recordExecution } from "../db/executions";
|
||||
import {
|
||||
prepareDLTExecution,
|
||||
commitDLTExecution,
|
||||
@@ -183,7 +182,7 @@ export class ExecutionCoordinator extends EventEmitter {
|
||||
return { txHash: result.txHash };
|
||||
}
|
||||
|
||||
private async bankInstructionPhase(executionId: string, plan: Plan): Promise<{ isoMessageId: string; swiftMessageId?: string; swiftMessageType?: string }> {
|
||||
private async bankInstructionPhase(executionId: string, plan: Plan): Promise<{ isoMessageId: string }> {
|
||||
this.emitStatus(executionId, { phase: "bank_instruction", status: "in_progress", timestamp: new Date().toISOString() });
|
||||
|
||||
const result = await commitBankInstruction(plan);
|
||||
@@ -194,25 +193,8 @@ export class ExecutionCoordinator extends EventEmitter {
|
||||
const rec = this.executions.get(executionId);
|
||||
if (rec) rec.isoMessageId = result.isoMessageId;
|
||||
|
||||
// Persist the SWIFT reference set (arch §4 canonical "Execution
|
||||
// Reference Set"; gap v2 §4 partial, §10.6).
|
||||
const swiftMessageId = (result as { swiftMessageId?: string }).swiftMessageId;
|
||||
const swiftMessageType = (result as { swiftMessageType?: string }).swiftMessageType;
|
||||
try {
|
||||
await recordExecution(executionId, plan.plan_id!, {
|
||||
phase: "bank_instruction",
|
||||
isoMessageId: result.isoMessageId,
|
||||
swiftMessageId: swiftMessageId ?? null,
|
||||
swiftMessageType: swiftMessageType ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
// DB persistence is best-effort here; a failure should not abort
|
||||
// the leg — the in-memory execution record still carries the id.
|
||||
console.warn(`recordExecution failed for ${executionId}:`, err);
|
||||
}
|
||||
|
||||
this.emitStatus(executionId, { phase: "bank_instruction", status: "complete", isoMessageId: result.isoMessageId, timestamp: new Date().toISOString() });
|
||||
return { isoMessageId: result.isoMessageId, swiftMessageId, swiftMessageType };
|
||||
return { isoMessageId: result.isoMessageId };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
304
orchestrator/src/services/rulesEngine.ts
Normal file
304
orchestrator/src/services/rulesEngine.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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<string, unknown>)[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<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
): 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<string, RuleSet> | 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<string, RuleSet>;
|
||||
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;
|
||||
}
|
||||
245
orchestrator/tests/unit/rulesEngine.test.ts
Normal file
245
orchestrator/tests/unit/rulesEngine.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* PR P — Pluggable Rules Engine (gap-analysis v2 §5.2 partial).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "@jest/globals";
|
||||
import {
|
||||
evaluate,
|
||||
evaluateCondition,
|
||||
getRuleSet,
|
||||
BUILTIN_PRECONDITIONS,
|
||||
BUILTIN_COMMIT,
|
||||
__resetRulesCacheForTests,
|
||||
type RuleSet,
|
||||
} from "../../src/services/rulesEngine";
|
||||
|
||||
describe("rulesEngine — primitive operators", () => {
|
||||
it("eq / neq / gt / gte / lt / lte", () => {
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "eq", value: 1 }, { a: 1 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "neq", value: 1 }, { a: 2 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "gt", value: 1 }, { a: 2 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "lte", value: 3 }, { a: 3 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("in / not_in / exists / matches", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "role", op: "in", value: ["approver", "releaser"] },
|
||||
{ role: "approver" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "role", op: "not_in", value: ["approver"] },
|
||||
{ role: "operator" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ path: "x", op: "exists" }, { x: 0 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "hash", op: "matches", value: "^0x[0-9a-f]+$" },
|
||||
{ hash: "0xabc" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("length_gte / length_lte work on arrays and strings", () => {
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "length_gte", value: 2 }, { a: [1, 2] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ path: "a", op: "length_lte", value: 5 }, { a: "abcd" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("dotted + indexed path resolution", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "plan.steps[1].type", op: "eq", value: "pay" },
|
||||
{ plan: { steps: [{ type: "issue" }, { type: "pay" }] } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rulesEngine — combinators", () => {
|
||||
const ctx = { role: "approver", amount: 1000 };
|
||||
|
||||
it("all (AND) — every child must pass", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
all: [
|
||||
{ path: "role", op: "eq", value: "approver" },
|
||||
{ path: "amount", op: "gt", value: 500 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
all: [
|
||||
{ path: "role", op: "eq", value: "approver" },
|
||||
{ path: "amount", op: "gt", value: 5000 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("any (OR) — at least one child must pass", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
any: [
|
||||
{ path: "role", op: "eq", value: "releaser" },
|
||||
{ path: "amount", op: "gt", value: 500 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("not — inverts the child", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ not: { path: "role", op: "eq", value: "releaser" } },
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rulesEngine — evaluate() and failure reporting", () => {
|
||||
const ruleSet: RuleSet = {
|
||||
id: "test.rs",
|
||||
rules: [
|
||||
{
|
||||
id: "amount_positive",
|
||||
description: "amount must be > 0",
|
||||
assert: { path: "amount", op: "gt", value: 0 },
|
||||
},
|
||||
{
|
||||
id: "role_listed",
|
||||
description: "role must be in the allowed list",
|
||||
assert: {
|
||||
path: "role",
|
||||
op: "in",
|
||||
value: ["approver", "releaser", "operator"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "warning_only",
|
||||
description: "low amount warning",
|
||||
severity: "warn",
|
||||
assert: { path: "amount", op: "gte", value: 10_000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("returns ok=true when all error-severity rules pass", () => {
|
||||
const res = evaluate(ruleSet, { amount: 1000, role: "approver" });
|
||||
expect(res.ok).toBe(true);
|
||||
// warn still reported even though ok=true
|
||||
expect(res.failures.some((f) => f.ruleId === "warning_only")).toBe(true);
|
||||
expect(res.failures.every((f) => f.severity === "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok=false with error failure when a blocking rule fails", () => {
|
||||
const res = evaluate(ruleSet, { amount: -1, role: "approver" });
|
||||
expect(res.ok).toBe(false);
|
||||
const amountFail = res.failures.find((f) => f.ruleId === "amount_positive");
|
||||
expect(amountFail?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("'when' gates a rule — false when-clause skips the assert", () => {
|
||||
const guarded: RuleSet = {
|
||||
id: "guarded.rs",
|
||||
rules: [
|
||||
{
|
||||
id: "kyc_if_present",
|
||||
when: { path: "compliance", op: "exists" },
|
||||
assert: { path: "compliance.kyc", op: "eq", value: "ok" },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(evaluate(guarded, {}).ok).toBe(true);
|
||||
expect(evaluate(guarded, { compliance: { kyc: "ok" } }).ok).toBe(true);
|
||||
expect(evaluate(guarded, { compliance: { kyc: "fail" } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rulesEngine — built-in rule sets", () => {
|
||||
it("preconditions: pay step + non-empty participants passes", () => {
|
||||
const res = evaluate(BUILTIN_PRECONDITIONS, {
|
||||
plan: { steps: [{ type: "pay" }] },
|
||||
participants: [{ id: "p1" }],
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("preconditions: missing pay step fails", () => {
|
||||
const res = evaluate(BUILTIN_PRECONDITIONS, {
|
||||
plan: { steps: [{ type: "issueInstrument" }] },
|
||||
participants: [{ id: "p1" }],
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.failures.some((f) => f.ruleId === "plan.pay_step_present")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("commit: VALIDATING + matching refs + no exceptions passes", () => {
|
||||
const res = evaluate(BUILTIN_COMMIT, {
|
||||
state: "VALIDATING",
|
||||
dlt: { txHash: `0x${"a".repeat(64)}` },
|
||||
bank: { isoMessageId: "MSG-1" },
|
||||
exceptions: { active: [] },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("commit: state != VALIDATING blocks", () => {
|
||||
const res = evaluate(BUILTIN_COMMIT, {
|
||||
state: "EXECUTING",
|
||||
dlt: { txHash: `0x${"a".repeat(64)}` },
|
||||
bank: { isoMessageId: "MSG-1" },
|
||||
exceptions: { active: [] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.failures.some((f) => f.ruleId === "state.is_validating")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rulesEngine — pluggable loading", () => {
|
||||
beforeEach(() => {
|
||||
__resetRulesCacheForTests();
|
||||
delete process.env.RULES_FILE;
|
||||
});
|
||||
|
||||
it("returns built-ins when RULES_FILE is unset", () => {
|
||||
expect(getRuleSet(BUILTIN_PRECONDITIONS.id).rules.length).toBeGreaterThan(0);
|
||||
expect(getRuleSet(BUILTIN_COMMIT.id).rules.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns an empty rule set for unknown ids (no throw)", () => {
|
||||
const rs = getRuleSet("nonexistent");
|
||||
expect(rs.rules).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* SWIFT message-id persistence (arch §4 Execution Reference Set,
|
||||
* gap v2 §4 partial, §10.6).
|
||||
*
|
||||
* Commands tested:
|
||||
* - commitBankInstruction returns swiftMessageId + swiftMessageType
|
||||
* depending on whether the plan has an issueInstrument step
|
||||
* - MT760 reference for instrument legs; MT202 synthetic ref for
|
||||
* payment-only legs
|
||||
* - db/executions.recordExecution upserts the SWIFT fields
|
||||
*/
|
||||
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import type { Plan } from "../../src/types/plan";
|
||||
import { commitBankInstruction } from "../../src/services/bank";
|
||||
|
||||
jest.mock("../../src/db/postgres", () => {
|
||||
const calls: Array<{ sql: string; params?: unknown[] }> = [];
|
||||
return {
|
||||
query: jest.fn(async (sql: string, params?: unknown[]) => {
|
||||
calls.push({ sql, params });
|
||||
return [];
|
||||
}),
|
||||
__calls: calls,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../src/services/compliance", () => ({
|
||||
getComplianceData: jest.fn(async () => ({ lei: "TEST-LEI", status: "ok" })),
|
||||
}));
|
||||
|
||||
import { recordExecution, getExecution } from "../../src/db/executions";
|
||||
|
||||
function basePlan(overrides: Partial<Plan> = {}): Plan {
|
||||
return {
|
||||
plan_id: "plan-test-1",
|
||||
schema_version: 1,
|
||||
creator: "0xabc",
|
||||
nonce: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
steps: [
|
||||
{
|
||||
type: "pay",
|
||||
from: "acct-a",
|
||||
to: "acct-b",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
} as any,
|
||||
],
|
||||
...overrides,
|
||||
} as Plan;
|
||||
}
|
||||
|
||||
function instrumentPlan(): Plan {
|
||||
return basePlan({
|
||||
steps: [
|
||||
{
|
||||
type: "issueInstrument",
|
||||
instrument: {
|
||||
instrumentType: "SBLC",
|
||||
amount: 1000000,
|
||||
currency: "USD",
|
||||
issuingBankBIC: "EIBIAEAD",
|
||||
beneficiaryBankBIC: "ADCBAEAA",
|
||||
beneficiaryName: "ACME TRADING LLC",
|
||||
beneficiaryAccount: "AE12 3456 7890 1234",
|
||||
expiryDate: "2026-12-31",
|
||||
placeOfPresentation: "DUBAI",
|
||||
governingLaw: "URDG 758",
|
||||
applicant: "APPLICANT INC",
|
||||
templateRef: "EI-SBLC-v1",
|
||||
templateHash: "a".repeat(64),
|
||||
tenor: "12M",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
type: "pay",
|
||||
from: "acct-a",
|
||||
to: "acct-b",
|
||||
amount: 1000000,
|
||||
currency: "USD",
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("commitBankInstruction SWIFT reference output", () => {
|
||||
it("issues an MT760 reference when the plan contains issueInstrument", async () => {
|
||||
const result = await commitBankInstruction(instrumentPlan());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.swiftMessageType).toBe("MT760");
|
||||
expect(result.swiftMessageId).toMatch(/^MT760-/);
|
||||
expect(result.isoMessageId).toMatch(/^MSG-/);
|
||||
});
|
||||
|
||||
it("issues an MT202 reference when no issueInstrument step is present", async () => {
|
||||
const result = await commitBankInstruction(basePlan());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.swiftMessageType).toBe("MT202");
|
||||
expect(result.swiftMessageId).toMatch(/^MT202-/);
|
||||
});
|
||||
|
||||
it("returns different swiftMessageIds across successive calls", async () => {
|
||||
const a = await commitBankInstruction(basePlan());
|
||||
const b = await commitBankInstruction(basePlan());
|
||||
expect(a.swiftMessageId).not.toBe(b.swiftMessageId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("db/executions SQL wiring", () => {
|
||||
it("recordExecution builds an UPSERT including swift_message_id fields", async () => {
|
||||
const pg = require("../../src/db/postgres");
|
||||
pg.__calls.length = 0;
|
||||
await recordExecution("exec-1", "plan-1", {
|
||||
phase: "bank_instruction",
|
||||
isoMessageId: "iso-1",
|
||||
swiftMessageId: "MT760-ABC",
|
||||
swiftMessageType: "MT760",
|
||||
});
|
||||
expect(pg.query).toHaveBeenCalled();
|
||||
const call = pg.__calls[0];
|
||||
expect(call.sql).toMatch(/INSERT INTO executions/);
|
||||
expect(call.sql).toMatch(/swift_message_id/);
|
||||
expect(call.sql).toMatch(/swift_message_type/);
|
||||
expect(call.sql).toMatch(/ON CONFLICT \(execution_id\) DO UPDATE/);
|
||||
expect(call.params).toEqual(
|
||||
expect.arrayContaining(["exec-1", "plan-1", "bank_instruction", "iso-1", "MT760-ABC", "MT760"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("getExecution selects the swift_message_* columns", async () => {
|
||||
const pg = require("../../src/db/postgres");
|
||||
pg.__calls.length = 0;
|
||||
await getExecution("exec-1");
|
||||
const call = pg.__calls[0];
|
||||
expect(call.sql).toMatch(/swift_message_id/);
|
||||
expect(call.sql).toMatch(/swift_message_type/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user