PR S: Machine-form obligation layer (terms-as-data) (#23)
Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
This commit was merged in pull request #23.
This commit is contained in:
153
orchestrator/src/services/obligations/evaluator.ts
Normal file
153
orchestrator/src/services/obligations/evaluator.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Minimal, self-contained condition evaluator for the obligation
|
||||
* layer.
|
||||
*
|
||||
* A sibling Rules Engine (PR P) ships a more general DSL that this
|
||||
* evaluator is a subset of; keeping a local copy lets the obligation
|
||||
* layer be merged independently. The two surfaces share the same
|
||||
* Condition shape so clauses authored for one run on the other.
|
||||
*
|
||||
* Key guarantees:
|
||||
* - Closed operator set (no eval, no code execution).
|
||||
* - Deterministic: given the same (condition, context), always
|
||||
* returns the same boolean.
|
||||
* - Dotted + indexed path resolution so clauses can assert on
|
||||
* deeply nested context objects (e.g. "plan.steps[0].amount").
|
||||
*/
|
||||
|
||||
export type Operator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "exists"
|
||||
| "matches"
|
||||
| "length_gte"
|
||||
| "length_lte";
|
||||
|
||||
export interface LeafCondition {
|
||||
path: string;
|
||||
op: Operator;
|
||||
value?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
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 function resolvePath(
|
||||
path: string,
|
||||
context: Record<string, unknown>,
|
||||
): unknown {
|
||||
// Handles both "a.b.c" and "a.b[0].c"
|
||||
const tokens = path
|
||||
.replace(/\[(\d+)\]/g, ".$1")
|
||||
.split(".")
|
||||
.filter(Boolean);
|
||||
let cur: unknown = context;
|
||||
for (const t of tokens) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (Array.isArray(cur)) {
|
||||
const idx = Number(t);
|
||||
cur = Number.isNaN(idx) ? undefined : cur[idx];
|
||||
} else if (typeof cur === "object") {
|
||||
cur = (cur as Record<string, unknown>)[t];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function isLeaf(c: Condition): c is LeafCondition {
|
||||
return (c as LeafCondition).path !== undefined;
|
||||
}
|
||||
|
||||
function isAnd(c: Condition): c is AndCondition {
|
||||
return Array.isArray((c as AndCondition).all);
|
||||
}
|
||||
|
||||
function isOr(c: Condition): c is OrCondition {
|
||||
return Array.isArray((c as OrCondition).any);
|
||||
}
|
||||
|
||||
function isNot(c: Condition): c is NotCondition {
|
||||
return (c as NotCondition).not !== undefined;
|
||||
}
|
||||
|
||||
export function evaluateCondition(
|
||||
condition: Condition,
|
||||
context: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (isAnd(condition)) {
|
||||
return condition.all.every((c) => evaluateCondition(c, context));
|
||||
}
|
||||
if (isOr(condition)) {
|
||||
return condition.any.some((c) => evaluateCondition(c, context));
|
||||
}
|
||||
if (isNot(condition)) {
|
||||
return !evaluateCondition(condition.not, context);
|
||||
}
|
||||
if (!isLeaf(condition)) return false;
|
||||
|
||||
const actual = resolvePath(condition.path, context);
|
||||
const expected = condition.value;
|
||||
switch (condition.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);
|
||||
case "not_in":
|
||||
return Array.isArray(expected) && !expected.includes(actual);
|
||||
case "exists":
|
||||
return actual !== undefined && actual !== null;
|
||||
case "matches":
|
||||
return (
|
||||
typeof actual === "string" &&
|
||||
typeof expected === "string" &&
|
||||
new RegExp(expected).test(actual)
|
||||
);
|
||||
case "length_gte":
|
||||
return (
|
||||
typeof expected === "number" &&
|
||||
(Array.isArray(actual) || typeof actual === "string") &&
|
||||
(actual as unknown[] | string).length >= expected
|
||||
);
|
||||
case "length_lte":
|
||||
return (
|
||||
typeof expected === "number" &&
|
||||
(Array.isArray(actual) || typeof actual === "string") &&
|
||||
(actual as unknown[] | string).length <= expected
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
320
orchestrator/src/services/obligations/index.ts
Normal file
320
orchestrator/src/services/obligations/index.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Machine-form obligation layer — entry point.
|
||||
*
|
||||
* See ./types.ts for the architectural shape; this module exposes:
|
||||
* - canonicalize / hashObligationTerms (deterministic identity)
|
||||
* - validateObligationTerms (shape check)
|
||||
* - evaluateObligationTerms (run commit/abort/unwind
|
||||
* clauses against a context
|
||||
* via the PR P rules engine)
|
||||
* - buildIssueInstrumentObligation (helper that derives a
|
||||
* sensible default obligation
|
||||
* shape from a plan's
|
||||
* instrument terms)
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { evaluateCondition } from "./evaluator";
|
||||
import type { InstrumentTerms } from "../../types/plan";
|
||||
import type {
|
||||
AuthorizedParticipant,
|
||||
Consideration,
|
||||
EvaluationResult,
|
||||
GoverningDocument,
|
||||
ObligationClause,
|
||||
ObligationEvaluation,
|
||||
ObligationTerms,
|
||||
} from "./types";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
/**
|
||||
* Deterministic canonical JSON encoding: object keys sorted
|
||||
* lexicographically at every depth, arrays preserved, no whitespace.
|
||||
*
|
||||
* This is what `hashObligationTerms()` hashes, so two obligations
|
||||
* with identical semantic content always hash to the same value
|
||||
* regardless of key insertion order.
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
return JSON.stringify(sortValue(value));
|
||||
}
|
||||
|
||||
function sortValue(v: unknown): unknown {
|
||||
if (v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map((x) => sortValue(x));
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
||||
out[k] = sortValue((v as Record<string, unknown>)[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 of the canonical obligation terms, hex-encoded without
|
||||
* 0x prefix. Matches the formatting convention used by
|
||||
* `InstrumentTerms.templateHash`.
|
||||
*/
|
||||
export function hashObligationTerms(terms: ObligationTerms): string {
|
||||
return createHash("sha256").update(canonicalize(terms)).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape validation. Returns a list of human-readable problems; empty
|
||||
* list means the object conforms to `ObligationTerms`.
|
||||
*
|
||||
* Intentionally cheap (no JSON-Schema runtime) — the TypeScript type
|
||||
* plus these assertions catch the bulk of real-world mistakes.
|
||||
*/
|
||||
export function validateObligationTerms(
|
||||
input: unknown,
|
||||
): { ok: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
if (!input || typeof input !== "object") {
|
||||
return { ok: false, errors: ["obligation terms must be an object"] };
|
||||
}
|
||||
const t = input as Partial<ObligationTerms>;
|
||||
|
||||
if (t.version !== "1.0") errors.push("version must be \"1.0\"");
|
||||
|
||||
if (!t.consideration || typeof t.consideration !== "object") {
|
||||
errors.push("consideration missing");
|
||||
} else {
|
||||
const c = t.consideration as Partial<Consideration>;
|
||||
if (!c.payor) errors.push("consideration.payor required");
|
||||
if (!c.payee) errors.push("consideration.payee required");
|
||||
if (!c.currency || !/^[A-Z]{3}$/.test(c.currency))
|
||||
errors.push("consideration.currency must be ISO-4217 (3 uppercase letters)");
|
||||
if (typeof c.amount !== "number" || !(c.amount > 0))
|
||||
errors.push("consideration.amount must be a positive number");
|
||||
}
|
||||
|
||||
for (const arrKey of [
|
||||
"validIssuance",
|
||||
"validPayment",
|
||||
"commit",
|
||||
"abort",
|
||||
"unwind",
|
||||
] as const) {
|
||||
const arr = t[arrKey];
|
||||
if (!Array.isArray(arr)) {
|
||||
errors.push(`${arrKey} must be an array`);
|
||||
continue;
|
||||
}
|
||||
arr.forEach((clause, i) => {
|
||||
if (!clause || typeof clause !== "object") {
|
||||
errors.push(`${arrKey}[${i}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const c = clause as Partial<ObligationClause>;
|
||||
if (!c.id) errors.push(`${arrKey}[${i}].id required`);
|
||||
if (!c.description) errors.push(`${arrKey}[${i}].description required`);
|
||||
if (!c.assert) errors.push(`${arrKey}[${i}].assert required`);
|
||||
if (c.binds && !["instrument", "payment", "both"].includes(c.binds))
|
||||
errors.push(`${arrKey}[${i}].binds must be instrument|payment|both`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.authorizedParticipants)) {
|
||||
errors.push("authorizedParticipants must be an array");
|
||||
} else {
|
||||
t.authorizedParticipants.forEach((p, i) => {
|
||||
const pp = p as Partial<AuthorizedParticipant>;
|
||||
if (!pp.role) errors.push(`authorizedParticipants[${i}].role required`);
|
||||
if (!pp.actorId)
|
||||
errors.push(`authorizedParticipants[${i}].actorId required`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.governingDocuments) || t.governingDocuments.length === 0) {
|
||||
errors.push("governingDocuments must be a non-empty array");
|
||||
} else {
|
||||
t.governingDocuments.forEach((d, i) => {
|
||||
const dd = d as Partial<GoverningDocument>;
|
||||
if (!dd.templateRef)
|
||||
errors.push(`governingDocuments[${i}].templateRef required`);
|
||||
if (!dd.templateHash || !/^[0-9a-fA-F]{64}$/.test(dd.templateHash))
|
||||
errors.push(`governingDocuments[${i}].templateHash must be hex SHA-256`);
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a set of obligation clauses against a live context.
|
||||
*
|
||||
* `context` typically contains the plan, execution state, event chain,
|
||||
* and bank/DLT dispatch evidence — whatever the clauses assert against.
|
||||
*
|
||||
* A failure short-circuits nothing; all clauses are evaluated so the
|
||||
* caller can surface the full list of unmet conditions (arch §12.2).
|
||||
*/
|
||||
export function evaluateClauses(
|
||||
clauses: ObligationClause[],
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
const results: EvaluationResult[] = clauses.map((clause) => {
|
||||
let ok = false;
|
||||
let failureReason: string | undefined;
|
||||
try {
|
||||
ok = evaluateCondition(clause.assert, context);
|
||||
if (!ok) failureReason = "assert condition returned false";
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
failureReason =
|
||||
err instanceof Error ? err.message : "unknown evaluator error";
|
||||
}
|
||||
return {
|
||||
clauseId: clause.id,
|
||||
description: clause.description,
|
||||
ok,
|
||||
...(failureReason ? { failureReason } : {}),
|
||||
};
|
||||
});
|
||||
return { ok: results.every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate specifically the commit clauses. Convenience for the
|
||||
* transition coordinator (arch §9.2).
|
||||
*/
|
||||
export function evaluateCommit(
|
||||
terms: ObligationTerms,
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
return evaluateClauses(terms.commit, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate specifically the abort clauses (arch §9.3). A true result
|
||||
* here means the transaction MUST abort.
|
||||
*/
|
||||
export function evaluateAbort(
|
||||
terms: ObligationTerms,
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
const ev = evaluateClauses(terms.abort, context);
|
||||
// Semantically an abort clause that *asserts true* means the abort
|
||||
// condition has been hit, so `ok=true` in the evaluation result ==
|
||||
// "abort required". Callers consume this as a boolean trigger.
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a default obligation-terms object from an issueInstrument
|
||||
* step's instrument terms. Useful for plans that haven't supplied an
|
||||
* explicit obligation block — gives them a reasonable starting point
|
||||
* that matches the template's commit/abort semantics.
|
||||
*/
|
||||
export function buildIssueInstrumentObligation(input: {
|
||||
instrument: InstrumentTerms;
|
||||
payor: string;
|
||||
payee: string;
|
||||
authorizedParticipants: AuthorizedParticipant[];
|
||||
governingDocumentTitle?: string;
|
||||
}): ObligationTerms {
|
||||
const { instrument, payor, payee, authorizedParticipants } = input;
|
||||
|
||||
const commit: ObligationClause[] = [
|
||||
{
|
||||
id: "commit.dlt_tx_hash",
|
||||
description: "DLT anchor transaction hash is present and valid",
|
||||
binds: "both",
|
||||
assert: {
|
||||
path: "dlt.tx_hash",
|
||||
op: "matches",
|
||||
value: "^0x[0-9a-fA-F]{64}$",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "commit.bank_iso_message_id",
|
||||
description: "Bank leg has produced an ISO-20022 message id",
|
||||
binds: "instrument",
|
||||
assert: { path: "bank.iso_message_id", op: "exists" },
|
||||
},
|
||||
{
|
||||
id: "commit.state_is_validating",
|
||||
description: "Transaction must be in VALIDATING when commit fires",
|
||||
binds: "both",
|
||||
assert: { path: "state", op: "eq", value: "VALIDATING" },
|
||||
},
|
||||
];
|
||||
|
||||
const abort: ObligationClause[] = [
|
||||
{
|
||||
id: "abort.exception_raised",
|
||||
description: "At least one active exception blocks commit",
|
||||
binds: "both",
|
||||
assert: { path: "exceptions.active", op: "length_gte", value: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const unwind: ObligationClause[] = [
|
||||
{
|
||||
id: "unwind.payment_failed_only",
|
||||
description:
|
||||
"Unwind applies only when the payment leg failed AFTER the "
|
||||
+ "instrument was dispatched (MT760 is irrevocable under UCP 600).",
|
||||
binds: "payment",
|
||||
assert: {
|
||||
all: [
|
||||
{ path: "instrument.dispatched", op: "eq", value: true },
|
||||
{ path: "payment.failed", op: "eq", value: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const validIssuance: ObligationClause[] = [
|
||||
{
|
||||
id: "issuance.template_hash_matches",
|
||||
description: "Dispatched instrument text hashes to the agreed template",
|
||||
binds: "instrument",
|
||||
assert: {
|
||||
path: "instrument.template_hash",
|
||||
op: "eq",
|
||||
value: instrument.templateHash,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const validPayment: ObligationClause[] = [
|
||||
{
|
||||
id: "payment.amount_matches",
|
||||
description: "Payment amount equals the instrument face value",
|
||||
binds: "payment",
|
||||
assert: { path: "payment.amount", op: "eq", value: instrument.amount },
|
||||
},
|
||||
{
|
||||
id: "payment.currency_matches",
|
||||
description: "Payment currency equals the instrument currency",
|
||||
binds: "payment",
|
||||
assert: { path: "payment.currency", op: "eq", value: instrument.currency },
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
consideration: {
|
||||
payor,
|
||||
payee,
|
||||
currency: instrument.currency,
|
||||
amount: instrument.amount,
|
||||
},
|
||||
validIssuance,
|
||||
validPayment,
|
||||
commit,
|
||||
abort,
|
||||
unwind,
|
||||
authorizedParticipants,
|
||||
governingDocuments: [
|
||||
{
|
||||
templateRef: instrument.templateRef,
|
||||
templateHash: instrument.templateHash,
|
||||
title: input.governingDocumentTitle,
|
||||
governingLaw: instrument.governingLaw,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
135
orchestrator/src/services/obligations/types.ts
Normal file
135
orchestrator/src/services/obligations/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Machine-form obligation layer (gap-analysis v2 §4.1 partial).
|
||||
*
|
||||
* Architecture §4.1 "Legal / Obligation Layer" describes what the
|
||||
* transaction's terms must express: consideration, commit conditions,
|
||||
* abort conditions, unwind conditions, authorized-participant matrix,
|
||||
* and a reference to governing documents.
|
||||
*
|
||||
* Until now a Plan only stored a `templateHash` — a hash reference
|
||||
* to an off-chain text. That satisfies tamper-evidence but is not
|
||||
* machine-enforceable: the orchestrator can't tell whether a given
|
||||
* execution context *satisfies* the terms without a human reading
|
||||
* the underlying PDF.
|
||||
*
|
||||
* This module makes the obligation layer first-class data:
|
||||
*
|
||||
* - Strongly typed shape for the six architectural sub-objects
|
||||
* (consideration, validIssuance, validPayment, commit, abort,
|
||||
* unwind, authorizedParticipants, governingDocuments).
|
||||
* - Canonicalisation + SHA-256 hash (deterministic, replayable).
|
||||
* - Executable assertions built on the PR P Rules Engine DSL so
|
||||
* commit/abort/unwind conditions can be checked automatically
|
||||
* against a live context.
|
||||
*
|
||||
* Binds to the existing `InstrumentTerms.templateHash` field: an
|
||||
* ObligationTerms instance records the governing-document hash as
|
||||
* one of its `governingDocuments[]` entries, closing the loop from
|
||||
* "which document governs this plan" to "what does that document
|
||||
* require, expressed as machine-checkable predicates".
|
||||
*/
|
||||
|
||||
import type { Condition } from "./evaluator";
|
||||
|
||||
/**
|
||||
* Commercial and legal meaning of the transaction (arch §4.1).
|
||||
*/
|
||||
export interface Consideration {
|
||||
/** Who pays and what. */
|
||||
payor: string;
|
||||
payee: string;
|
||||
/** ISO-4217 currency code. */
|
||||
currency: string;
|
||||
/** Positive amount in major units (e.g. 100.00 USD = 100). */
|
||||
amount: number;
|
||||
/** Optional free-form description of the consideration. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role entry on the authorized-participant matrix. Roles match the
|
||||
* SoD set used by middleware/apiKeyAuth (PR M): coordinator, approver,
|
||||
* releaser, validator, exception_manager, operator.
|
||||
*/
|
||||
export interface AuthorizedParticipant {
|
||||
role:
|
||||
| "coordinator"
|
||||
| "approver"
|
||||
| "releaser"
|
||||
| "validator"
|
||||
| "exception_manager"
|
||||
| "operator";
|
||||
/** Free-form identifier — an actor id, API-key id, or wallet address. */
|
||||
actorId: string;
|
||||
/** Optional display name. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Governing-document reference: template id + integrity hash of the
|
||||
* agreed text (see InstrumentTerms.templateHash).
|
||||
*/
|
||||
export interface GoverningDocument {
|
||||
/** Stable template identifier (e.g. "emirates-islamic-sblc-v3"). */
|
||||
templateRef: string;
|
||||
/** Hex SHA-256 of the canonical agreed text, without 0x prefix. */
|
||||
templateHash: string;
|
||||
/** Optional human-readable title. */
|
||||
title?: string;
|
||||
/** Optional ruleset the template is governed under. */
|
||||
governingLaw?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single machine-enforceable clause. The `assert` field is a
|
||||
* rulesEngine Condition so the obligation layer can reuse the
|
||||
* evaluator from PR P.
|
||||
*/
|
||||
export interface ObligationClause {
|
||||
id: string;
|
||||
description: string;
|
||||
/** Rules-engine condition that must hold for the clause to be satisfied. */
|
||||
assert: Condition;
|
||||
/** Explicitly surface which side of the transaction the clause binds. */
|
||||
binds: "instrument" | "payment" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level obligation-terms object.
|
||||
*
|
||||
* Canonicalisation:
|
||||
* - Keys are sorted lexicographically via `canonicalize()`.
|
||||
* - `terms_hash` = SHA-256 of the canonical JSON string.
|
||||
*
|
||||
* The hash is the identity of the obligation: two plans with the
|
||||
* same hash have identical machine-enforceable terms.
|
||||
*/
|
||||
export interface ObligationTerms {
|
||||
/** Schema version — bump on any breaking shape change. */
|
||||
version: "1.0";
|
||||
consideration: Consideration;
|
||||
/** Clauses that define what "valid issuance" means (arch §4.1). */
|
||||
validIssuance: ObligationClause[];
|
||||
/** Clauses that define what "valid payment" means (arch §4.1). */
|
||||
validPayment: ObligationClause[];
|
||||
/** Commit criteria (arch §9.2). */
|
||||
commit: ObligationClause[];
|
||||
/** Abort criteria (arch §9.3). */
|
||||
abort: ObligationClause[];
|
||||
/** Unwind procedures (arch §8 UNWIND_PENDING). */
|
||||
unwind: ObligationClause[];
|
||||
authorizedParticipants: AuthorizedParticipant[];
|
||||
governingDocuments: GoverningDocument[];
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
clauseId: string;
|
||||
description: string;
|
||||
ok: boolean;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
export interface ObligationEvaluation {
|
||||
ok: boolean;
|
||||
results: EvaluationResult[];
|
||||
}
|
||||
284
orchestrator/tests/unit/obligations.test.ts
Normal file
284
orchestrator/tests/unit/obligations.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
canonicalize,
|
||||
hashObligationTerms,
|
||||
validateObligationTerms,
|
||||
evaluateClauses,
|
||||
evaluateCommit,
|
||||
evaluateAbort,
|
||||
buildIssueInstrumentObligation,
|
||||
type ObligationTerms,
|
||||
} from "../../src/services/obligations";
|
||||
import { evaluateCondition, resolvePath } from "../../src/services/obligations/evaluator";
|
||||
|
||||
describe("Obligation layer (gap-analysis v2 §4.1)", () => {
|
||||
const instrument = {
|
||||
applicant: "ACME Corp",
|
||||
issuingBankBIC: "CHASUS33",
|
||||
beneficiaryBankBIC: "EBILAEAD",
|
||||
beneficiaryName: "Acme Beneficiary Ltd",
|
||||
beneficiaryAccount: "AE070331234567890123456",
|
||||
amount: 1_000_000,
|
||||
currency: "USD",
|
||||
tenor: "1Y",
|
||||
expiryDate: "2026-12-31",
|
||||
placeOfPresentation: "Dubai",
|
||||
governingLaw: "URDG 758",
|
||||
templateRef: "emirates-islamic-sblc-v3",
|
||||
templateHash:
|
||||
"a".repeat(64),
|
||||
};
|
||||
|
||||
const authorizedParticipants = [
|
||||
{ role: "coordinator" as const, actorId: "actor-1" },
|
||||
{ role: "approver" as const, actorId: "actor-2" },
|
||||
{ role: "releaser" as const, actorId: "actor-3" },
|
||||
{ role: "validator" as const, actorId: "actor-4" },
|
||||
{ role: "exception_manager" as const, actorId: "actor-5" },
|
||||
];
|
||||
|
||||
describe("canonicalize()", () => {
|
||||
it("sorts object keys at every depth", () => {
|
||||
const a = canonicalize({ b: 1, a: { d: 2, c: 3 } });
|
||||
const b = canonicalize({ a: { c: 3, d: 2 }, b: 1 });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('{"a":{"c":3,"d":2},"b":1}');
|
||||
});
|
||||
|
||||
it("preserves array order", () => {
|
||||
expect(canonicalize({ x: [3, 1, 2] })).toBe('{"x":[3,1,2]}');
|
||||
});
|
||||
|
||||
it("handles null and nested arrays of objects", () => {
|
||||
expect(
|
||||
canonicalize({ a: null, b: [{ y: 2, x: 1 }, { z: 3 }] }),
|
||||
).toBe('{"a":null,"b":[{"x":1,"y":2},{"z":3}]}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashObligationTerms()", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "ACME Corp",
|
||||
payee: "Acme Beneficiary Ltd",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
it("produces a 64-char hex hash", () => {
|
||||
expect(hashObligationTerms(terms)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("is insensitive to key ordering", () => {
|
||||
const shuffled: ObligationTerms = {
|
||||
...terms,
|
||||
consideration: {
|
||||
payee: terms.consideration.payee,
|
||||
currency: terms.consideration.currency,
|
||||
amount: terms.consideration.amount,
|
||||
payor: terms.consideration.payor,
|
||||
},
|
||||
};
|
||||
expect(hashObligationTerms(shuffled)).toBe(hashObligationTerms(terms));
|
||||
});
|
||||
|
||||
it("changes when any field mutates", () => {
|
||||
const mutated: ObligationTerms = {
|
||||
...terms,
|
||||
consideration: { ...terms.consideration, amount: 999 },
|
||||
};
|
||||
expect(hashObligationTerms(mutated)).not.toBe(hashObligationTerms(terms));
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateObligationTerms()", () => {
|
||||
const valid = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
it("accepts a well-formed obligation", () => {
|
||||
expect(validateObligationTerms(valid).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateObligationTerms(null).ok).toBe(false);
|
||||
expect(validateObligationTerms("nope").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("flags missing consideration fields", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
consideration: { payor: "A", payee: "B", currency: "usd", amount: -5 },
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("ISO-4217"),
|
||||
expect.stringContaining("amount"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags bad template hash", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
governingDocuments: [
|
||||
{ templateRef: "t", templateHash: "not-a-hash" },
|
||||
],
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.some((e) => e.includes("hex SHA-256"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags empty authorizedParticipants[].role", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
authorizedParticipants: [{ actorId: "x" }],
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluator", () => {
|
||||
it("resolvePath handles dotted + indexed paths", () => {
|
||||
const ctx = { plan: { steps: [{ type: "pay" }, { type: "issueInstrument" }] } };
|
||||
expect(resolvePath("plan.steps[1].type", ctx)).toBe("issueInstrument");
|
||||
expect(resolvePath("plan.missing.x", ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("evaluates all/any/not combinators", () => {
|
||||
const ctx = { a: 1, b: 2 };
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
all: [
|
||||
{ path: "a", op: "eq", value: 1 },
|
||||
{ path: "b", op: "gt", value: 1 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
any: [
|
||||
{ path: "a", op: "eq", value: 99 },
|
||||
{ path: "b", op: "gt", value: 1 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ not: { path: "a", op: "eq", value: 2 } }, ctx),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches regex operator safely (no eval)", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
||||
{ h: "0xbeef" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
||||
{ h: "0xBEEFG" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateClauses / evaluateCommit / evaluateAbort", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "ACME Corp",
|
||||
payee: "Acme Beneficiary Ltd",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
const passingCtx = {
|
||||
state: "VALIDATING",
|
||||
dlt: { tx_hash: "0x" + "b".repeat(64) },
|
||||
bank: { iso_message_id: "MSG-1" },
|
||||
exceptions: { active: [] },
|
||||
instrument: { template_hash: instrument.templateHash, dispatched: true },
|
||||
payment: {
|
||||
amount: instrument.amount,
|
||||
currency: instrument.currency,
|
||||
failed: false,
|
||||
},
|
||||
};
|
||||
|
||||
it("evaluateCommit returns ok=true when all commit clauses pass", () => {
|
||||
const r = evaluateCommit(terms, passingCtx);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.results.every((x) => x.ok)).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluateCommit returns ok=false with per-clause reasons on failure", () => {
|
||||
const badCtx = { ...passingCtx, dlt: { tx_hash: "not-hex" } };
|
||||
const r = evaluateCommit(terms, badCtx);
|
||||
expect(r.ok).toBe(false);
|
||||
const failing = r.results.find((x) => !x.ok);
|
||||
expect(failing?.clauseId).toBe("commit.dlt_tx_hash");
|
||||
expect(failing?.failureReason).toBeTruthy();
|
||||
});
|
||||
|
||||
it("evaluateAbort fires when an active exception exists", () => {
|
||||
const ctx = {
|
||||
...passingCtx,
|
||||
exceptions: { active: [{ kind: "timeout" }] },
|
||||
};
|
||||
const r = evaluateAbort(terms, ctx);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.results.find((x) => x.clauseId === "abort.exception_raised")?.ok).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("evaluateClauses surfaces evaluator errors without throwing", () => {
|
||||
const bogus = [
|
||||
{
|
||||
id: "bogus",
|
||||
description: "bad regex",
|
||||
binds: "both" as const,
|
||||
assert: { path: "h", op: "matches" as const, value: "[" }, // invalid regex
|
||||
},
|
||||
];
|
||||
const r = evaluateClauses(bogus, { h: "x" });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.results[0].failureReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIssueInstrumentObligation()", () => {
|
||||
it("binds the instrument template hash into governingDocuments", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
expect(terms.governingDocuments[0].templateHash).toBe(instrument.templateHash);
|
||||
expect(terms.governingDocuments[0].governingLaw).toBe("URDG 758");
|
||||
});
|
||||
|
||||
it("validates cleanly", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
expect(validateObligationTerms(terms).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user