Compare commits

..

1 Commits

Author SHA1 Message Date
Devin
72ff0e4cc0 Pluggable Rules Engine with JSON DSL
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 7s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 23s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 3s
Closes gap-analysis v2 §5.2 partial (Rules Engine was hardcoded).

- services/rulesEngine.ts — declarative JSON DSL with a closed
  operator set (eq/neq/gt/gte/lt/lte/in/not_in/exists/matches/
  length_gte/length_lte) + AND/OR/NOT combinators. No eval, no
  runtime code injection. Dotted + indexed path resolver.
- evaluate(ruleSet, context) returns {ok, failures}; 'error'
  severity blocks, 'warn' is reported but non-blocking. 'when'
  clauses gate a rule (e.g. only check compliance.kyc if the
  compliance block is present at all).
- Built-in rule sets mirror the pre-DSL hardcoded checks:
    preconditions.builtin — plan + pay step + participants + KYC
    commit.builtin        — dlt tx hash + bank iso msg id +
                            state=VALIDATING + no exceptions (arch §9.2)
- Pluggable: RULES_FILE env points at a JSON map overriding any
  built-in by id. Silent fall-through to built-ins on error.
- 16 unit tests across operators, combinators, severity semantics,
  'when' gating, built-in rule sets, and loader behaviour.
- Full suite 96/96 green; tsc --noEmit clean.
2026-04-22 18:28:26 +00:00
8 changed files with 563 additions and 350 deletions

View File

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

View File

@@ -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`,
);
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 };
}
/**

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

View 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([]);
});
});

View File

@@ -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/);
});
});