Files
CurrenciCombo/orchestrator/src/services/obligations/index.ts
nsatoshi b77ebce497
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
PR S: Machine-form obligation layer (terms-as-data) (#23)
2026-04-22 20:30:32 +00:00

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,
},
],
};
}