diff --git a/orchestrator/src/services/obligations/evaluator.ts b/orchestrator/src/services/obligations/evaluator.ts new file mode 100644 index 0000000..7f59b76 --- /dev/null +++ b/orchestrator/src/services/obligations/evaluator.ts @@ -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, +): 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)[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, +): 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; + } +} diff --git a/orchestrator/src/services/obligations/index.ts b/orchestrator/src/services/obligations/index.ts new file mode 100644 index 0000000..fd61617 --- /dev/null +++ b/orchestrator/src/services/obligations/index.ts @@ -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 = {}; + for (const k of Object.keys(v as Record).sort()) { + out[k] = sortValue((v as Record)[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; + + 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; + 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; + 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; + 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; + 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, +): 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, +): 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, +): 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, + }, + ], + }; +} diff --git a/orchestrator/src/services/obligations/types.ts b/orchestrator/src/services/obligations/types.ts new file mode 100644 index 0000000..e3d0e74 --- /dev/null +++ b/orchestrator/src/services/obligations/types.ts @@ -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[]; +} diff --git a/orchestrator/tests/unit/obligations.test.ts b/orchestrator/tests/unit/obligations.test.ts new file mode 100644 index 0000000..ab9c945 --- /dev/null +++ b/orchestrator/tests/unit/obligations.test.ts @@ -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); + }); + }); +});