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:
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user