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
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
],
|
|
};
|
|
}
|