@hyperscale0/hsx 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AUTHORS +8 -0
  2. package/CHANGELOG.md +59 -0
  3. package/LICENSE +661 -0
  4. package/LICENSING.md +52 -0
  5. package/README.md +170 -0
  6. package/SECURITY.md +47 -0
  7. package/TRADEMARKS.md +35 -0
  8. package/bin/hsx.ts +15 -0
  9. package/dist/bin/hsx.d.ts +7 -0
  10. package/dist/bin/hsx.d.ts.map +1 -0
  11. package/dist/bin/hsx.js +14 -0
  12. package/dist/bin/hsx.js.map +1 -0
  13. package/dist/src/ast.d.ts +172 -0
  14. package/dist/src/ast.d.ts.map +1 -0
  15. package/dist/src/ast.js +22 -0
  16. package/dist/src/ast.js.map +1 -0
  17. package/dist/src/check.d.ts +11 -0
  18. package/dist/src/check.d.ts.map +1 -0
  19. package/dist/src/check.js +1214 -0
  20. package/dist/src/check.js.map +1 -0
  21. package/dist/src/cli.d.ts +20 -0
  22. package/dist/src/cli.d.ts.map +1 -0
  23. package/dist/src/cli.js +137 -0
  24. package/dist/src/cli.js.map +1 -0
  25. package/dist/src/compile.d.ts +39 -0
  26. package/dist/src/compile.d.ts.map +1 -0
  27. package/dist/src/compile.js +59 -0
  28. package/dist/src/compile.js.map +1 -0
  29. package/dist/src/index.d.ts +9 -0
  30. package/dist/src/index.d.ts.map +1 -0
  31. package/dist/src/index.js +7 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/lex.d.ts +23 -0
  34. package/dist/src/lex.d.ts.map +1 -0
  35. package/dist/src/lex.js +125 -0
  36. package/dist/src/lex.js.map +1 -0
  37. package/dist/src/lower.d.ts +93 -0
  38. package/dist/src/lower.d.ts.map +1 -0
  39. package/dist/src/lower.js +2081 -0
  40. package/dist/src/lower.js.map +1 -0
  41. package/dist/src/model.d.ts +307 -0
  42. package/dist/src/model.d.ts.map +1 -0
  43. package/dist/src/model.js +15 -0
  44. package/dist/src/model.js.map +1 -0
  45. package/dist/src/parse.d.ts +19 -0
  46. package/dist/src/parse.d.ts.map +1 -0
  47. package/dist/src/parse.js +484 -0
  48. package/dist/src/parse.js.map +1 -0
  49. package/dist/src/version.d.ts +16 -0
  50. package/dist/src/version.d.ts.map +1 -0
  51. package/dist/src/version.js +16 -0
  52. package/dist/src/version.js.map +1 -0
  53. package/package.json +79 -0
  54. package/spec/hsx-ir.schema.json +522 -0
  55. package/src/ast.ts +231 -0
  56. package/src/check.ts +1699 -0
  57. package/src/cli.ts +173 -0
  58. package/src/compile.ts +98 -0
  59. package/src/index.ts +16 -0
  60. package/src/lex.ts +161 -0
  61. package/src/lower.ts +2619 -0
  62. package/src/model.ts +340 -0
  63. package/src/parse.ts +580 -0
  64. package/src/version.ts +17 -0
@@ -0,0 +1,2081 @@
1
+ /**
2
+ * The lowering: checked program model -> HSX-JSON IR + congruent Business
3
+ * Frame. This is where the piece choreography the Architect used to hand-author
4
+ * is EMITTED deterministically instead.
5
+ *
6
+ * Semantics fixed here, once, for every archetype:
7
+ *
8
+ * - Fees: a payer-side fee is a service charge ON TOP of the amount, moving
9
+ * payer -> platform directly and never entering custody; a payee-side fee
10
+ * (or a premium commission) is CARVED FROM the amount at release.
11
+ * - Partitions: whenever an amount splits, it is partitioned into the finest
12
+ * common refinement of every exit, each piece its own required money field
13
+ * funded and debited under that exact name, the shape the independent
14
+ * checker's terminal-escrow analysis can prove conserving. Every partition
15
+ * is also declared on the noun, so create admission refuses pieces that do
16
+ * not sum to their total.
17
+ * - Integer minor-unit arithmetic: each piece is floor(amount * bps / 10000);
18
+ * the division remainder goes to the FIRST piece unless a split names its
19
+ * `remainder_to` recipient.
20
+ * - Schedules are finite by construction: a literal anchor count unrolls into
21
+ * one due-driven verb per anchor, each its own idempotent lifecycle step.
22
+ * - Metered usage never accrues custody: each usage charge IS the ledger
23
+ * transfer, so emission and ledger cannot diverge; the period close makes
24
+ * further charges unreachable.
25
+ * - Deposits are reservations: placed as a hold, then posted to the holder
26
+ * (claim) or voided back to the payer (return); the hold pairing law
27
+ * accounts for the full amount on both exits.
28
+ *
29
+ * The lowering never shares code with the checker that verifies its output;
30
+ * that independence is the safety argument of the whole compiler.
31
+ */
32
+ import { HSX_IR_VERSION } from "./version.js";
33
+ /**
34
+ * The most money events one program may mint. The Business Frame contract caps
35
+ * its moneyEvents array at the same number, and a runtime spec pins the two
36
+ * against each other, so neither can drift alone. Every installment anchor,
37
+ * fee leg, cancellation leg, abandonment refund, and forward counts one.
38
+ */
39
+ export const MONEY_EVENT_BUDGET = 14;
40
+ const TOTAL_BPS = 10000n;
41
+ /**
42
+ * Split a minor-unit amount across pieces by exact basis points. Floors every
43
+ * piece and gives the division remainder to the piece at `remainderIndex`
44
+ * (the first by default), so the piece amounts always sum exactly.
45
+ */
46
+ export function pieceAmounts(pieces, amountMinor, remainderIndex = 0) {
47
+ if (amountMinor < 0n)
48
+ throw new Error("amount must be non-negative");
49
+ const floors = pieces.map((piece) => (amountMinor * BigInt(piece.bps)) / TOTAL_BPS);
50
+ const distributed = floors.reduce((sum, value) => sum + value, 0n);
51
+ if (floors.length > 0) {
52
+ const target = remainderIndex < floors.length ? remainderIndex : 0;
53
+ floors[target] = floors[target] + (amountMinor - distributed);
54
+ }
55
+ return floors;
56
+ }
57
+ /**
58
+ * Frame keys carry a 40-char snake_case budget, set by the Business Frame
59
+ * contract's key text. Composed keys include model-authored names (ports,
60
+ * parties, meters) with no length bound of their own, so an overlong
61
+ * composition clamps to a 33-char prefix plus a stable 6-char hash of the
62
+ * full name. Deterministic and idempotent: equal compositions stay equal, so
63
+ * a rule's gatesEvent keeps matching its money event's key.
64
+ */
65
+ /**
66
+ * Frame prose fields (headline, summary, design lines, event labels and
67
+ * triggers, rule details, amounts) share a 160-char schema budget, and
68
+ * their compositions embed model-authored names with no length bound of
69
+ * their own. One walker over the assembled frame clamps every prose string
70
+ * so the compiler can never emit a frame the platform schema rejects as an
71
+ * internal fault. Keys are snake_case identities, not prose: they clamp
72
+ * separately via frameKey and are never touched here.
73
+ */
74
+ const PROSE_BUDGET = 160;
75
+ const FRAME_PROSE_FIELDS = new Set([
76
+ "amount",
77
+ "design",
78
+ "detail",
79
+ "headline",
80
+ "label",
81
+ "summary",
82
+ "title",
83
+ "trigger",
84
+ "why",
85
+ ]);
86
+ function clampProseValue(value, field) {
87
+ if (typeof value === "string") {
88
+ return field !== undefined &&
89
+ FRAME_PROSE_FIELDS.has(field) &&
90
+ value.length > PROSE_BUDGET
91
+ ? `${value.slice(0, PROSE_BUDGET - 3)}...`
92
+ : value;
93
+ }
94
+ if (Array.isArray(value)) {
95
+ return value.map((item) => clampProseValue(item, field));
96
+ }
97
+ if (value !== null && typeof value === "object") {
98
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
99
+ key,
100
+ clampProseValue(child, key),
101
+ ]));
102
+ }
103
+ return value;
104
+ }
105
+ function clampFrameProse(frame) {
106
+ return clampProseValue(frame);
107
+ }
108
+ function frameKey(key) {
109
+ if (key.length <= 40)
110
+ return key;
111
+ let hash = 2166136261;
112
+ for (let index = 0; index < key.length; index += 1) {
113
+ hash = Math.imul(hash ^ key.charCodeAt(index), 16777619);
114
+ }
115
+ return `${key.slice(0, 33)}_${(hash >>> 0).toString(36).slice(0, 6)}`;
116
+ }
117
+ function mintEvent(spec) {
118
+ return {
119
+ allocationTotalBps: 0,
120
+ amount: spec.amount,
121
+ amountDependencies: [],
122
+ amountMode: "fixed",
123
+ amountSchedule: [],
124
+ distribution: "single",
125
+ fromActor: spec.fromActor,
126
+ key: frameKey(spec.key),
127
+ kind: spec.kind,
128
+ label: spec.trigger,
129
+ occurrence: spec.occurrence ?? "once",
130
+ timing: spec.timing ?? "on_lifecycle",
131
+ toActor: spec.toActor,
132
+ trigger: spec.trigger,
133
+ };
134
+ }
135
+ /** One lifecycle edge per verb, threading `from -> stem_1 -> ... -> to`. */
136
+ function chain(names, from, to, stateStem) {
137
+ return names.map((_, index) => ({
138
+ from: index === 0 ? from : `${stateStem}_${index}`,
139
+ to: index === names.length - 1 ? to : `${stateStem}_${index + 1}`,
140
+ }));
141
+ }
142
+ /** Refuse verb-name collisions before they silently overwrite each other. */
143
+ function verbNameIssues(settlementName, names, origin, issues) {
144
+ const seen = new Set();
145
+ for (const name of names) {
146
+ if (name === "create" || seen.has(name)) {
147
+ issues.push({
148
+ message: `settlement ${settlementName} generates two verbs named "${name}"; rename the colliding port`,
149
+ span: origin,
150
+ });
151
+ return false;
152
+ }
153
+ seen.add(name);
154
+ }
155
+ return true;
156
+ }
157
+ /**
158
+ * The partition clauses declared on the noun: create admission proves each
159
+ * sum exactly. Spread into the noun literal; empty when nothing partitions.
160
+ */
161
+ function partitionClause(total, pieces) {
162
+ return pieces.length >= 2 ? [{ pieces: [...pieces], total }] : [];
163
+ }
164
+ function partitionsSpread(clauses) {
165
+ return clauses.length > 0 ? { partitions: [...clauses] } : {};
166
+ }
167
+ function moneyFieldSpec(desc) {
168
+ return { desc, type: "money" };
169
+ }
170
+ function dateFieldSpec(desc) {
171
+ return { desc, type: "date" };
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // The whole-program lowering
175
+ export function lowerProgram(program) {
176
+ const issues = [];
177
+ const settlements = [];
178
+ const nouns = [];
179
+ const moneyEvents = [];
180
+ const rules = [];
181
+ const design = [];
182
+ const feeLines = [];
183
+ const mintedKeys = new Map();
184
+ const portsByName = new Map(program.ports.map((port) => [port.name, port]));
185
+ const portFor = (settlement, portName, origin) => {
186
+ const port = portsByName.get(portName);
187
+ if (!port) {
188
+ issues.push({
189
+ message: `settlement ${settlement.name} decides through an unknown port; the checker should have refused this program`,
190
+ span: origin,
191
+ });
192
+ }
193
+ return port;
194
+ };
195
+ // `advance { against: <hold>.release }` carves the hold's release, so the
196
+ // hold must know the funder's name before it lowers. The checker already
197
+ // proved each target is a held payment releasing to the financed party, and
198
+ // that no two advances draw against the same one.
199
+ const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
200
+ ? [[settlement.source.settlement, settlement.funder]]
201
+ : []));
202
+ for (const settlement of program.settlements) {
203
+ let lowered;
204
+ switch (settlement.archetype) {
205
+ case "held_payment": {
206
+ const port = portFor(settlement, settlement.release.port, settlement.release.origin);
207
+ if (!port)
208
+ continue;
209
+ lowered = lowerHeldPayment(settlement, port, carveFunderByHold.get(settlement.name), issues);
210
+ break;
211
+ }
212
+ case "premium_forward": {
213
+ const port = portFor(settlement, settlement.bind.port, settlement.bind.origin);
214
+ if (!port)
215
+ continue;
216
+ lowered = lowerPremiumForward(settlement, port, issues);
217
+ break;
218
+ }
219
+ case "deposit": {
220
+ const claim = portFor(settlement, settlement.claim.port, settlement.claim.origin);
221
+ const giveBack = portFor(settlement, settlement.return.port, settlement.return.origin);
222
+ if (!claim || !giveBack)
223
+ continue;
224
+ lowered = lowerDeposit(settlement, claim, giveBack, issues);
225
+ break;
226
+ }
227
+ case "instant_transfer":
228
+ lowered = lowerInstantTransfer(settlement);
229
+ break;
230
+ case "scheduled":
231
+ lowered = lowerScheduled(settlement);
232
+ break;
233
+ case "advance":
234
+ lowered = lowerAdvance(settlement);
235
+ break;
236
+ case "metered":
237
+ lowered = lowerMetered(settlement);
238
+ break;
239
+ case "pooled_split":
240
+ lowered = lowerPooledSplit(settlement);
241
+ break;
242
+ case "swap": {
243
+ const release = portFor(settlement, settlement.release.port, settlement.release.origin);
244
+ const dispute = settlement.dispute
245
+ ? portFor(settlement, settlement.dispute.port, settlement.dispute.origin)
246
+ : undefined;
247
+ if (!release || (settlement.dispute && !dispute))
248
+ continue;
249
+ lowered = lowerSwap(settlement, release, dispute);
250
+ break;
251
+ }
252
+ }
253
+ if (!lowered)
254
+ continue;
255
+ // Event and rule keys concatenate settlement names with generated stems,
256
+ // so two settlements can mint the same key (a + b_service_fee vs a_b +
257
+ // service_fee). The frame schema refuses duplicates wholesale, which
258
+ // would surface as an internal fault; refuse here at the source instead.
259
+ for (const minted of [...lowered.moneyEvents, ...lowered.rules]) {
260
+ const key = minted.key;
261
+ const owner = mintedKeys.get(key);
262
+ if (owner) {
263
+ issues.push({
264
+ message: `settlements ${owner} and ${settlement.name} both generate the internal key ${key}; rename one settlement (or its port or meter) so the generated keys stay distinct`,
265
+ span: settlement.origin,
266
+ });
267
+ }
268
+ mintedKeys.set(key, settlement.name);
269
+ }
270
+ settlements.push(lowered.settlement);
271
+ nouns.push(lowered.noun);
272
+ moneyEvents.push(...lowered.moneyEvents);
273
+ rules.push(...lowered.rules);
274
+ design.push(...lowered.design);
275
+ feeLines.push(...lowered.feeLines);
276
+ }
277
+ if (moneyEvents.length > MONEY_EVENT_BUDGET) {
278
+ issues.push({
279
+ message: `this program needs ${moneyEvents.length} money events but a Business Frame carries at most ${MONEY_EVENT_BUDGET}; simplify the fee or cancellation terms, or drop a settlement`,
280
+ span: program.settlements[0]?.origin ?? { end: 0, start: 0 },
281
+ });
282
+ }
283
+ if (issues.length > 0)
284
+ return { issues, ok: false };
285
+ const subjects = program.assets.map((asset) => ({
286
+ kind: asset.name,
287
+ title: titleize(asset.name),
288
+ value: "optional",
289
+ }));
290
+ const document = {
291
+ hsx: HSX_IR_VERSION,
292
+ nouns,
293
+ product: program.name,
294
+ ...(subjects.length > 0 ? { subjects } : {}),
295
+ title: program.title,
296
+ };
297
+ const roles = partyRoles(program.settlements);
298
+ const frame = {
299
+ actors: [
300
+ ...program.parties
301
+ .filter((party) => roles.has(party.name))
302
+ .map((party) => ({
303
+ key: party.name,
304
+ label: titleize(party.name),
305
+ maxCount: 1,
306
+ minCount: 1,
307
+ role: roles.get(party.name),
308
+ })),
309
+ {
310
+ key: "platform",
311
+ label: "Platform",
312
+ maxCount: 1,
313
+ minCount: 1,
314
+ role: "platform",
315
+ },
316
+ ],
317
+ confidence: "high",
318
+ conservationGroups: [],
319
+ design,
320
+ feePolicy: feeLines.length > 0 ? "defined" : "none",
321
+ fees: feeLines,
322
+ headline: program.title,
323
+ mechanics: mechanicsOf(program.settlements),
324
+ moneyEvents,
325
+ offPlatform: program.assets
326
+ .filter((asset) => asset.titleTransfer === "off_platform")
327
+ .map((asset) => ({
328
+ label: `${titleize(asset.name)} title transfer`,
329
+ why: `Ownership of the ${asset.name.replaceAll("_", " ")} changes hands outside the platform`,
330
+ })),
331
+ openQuestions: [],
332
+ rules,
333
+ subjects: program.assets.map((asset) => ({
334
+ kind: asset.name,
335
+ title: titleize(asset.name),
336
+ })),
337
+ summary: summarize(program),
338
+ };
339
+ return {
340
+ ok: true,
341
+ value: { document, frame: clampFrameProse(frame), settlements },
342
+ };
343
+ }
344
+ /** Frame actor role per party, with a fixed precedence when roles overlap. */
345
+ function partyRoles(settlements) {
346
+ const payers = new Set();
347
+ const beneficiaries = new Set();
348
+ const providers = new Set();
349
+ const holders = new Set();
350
+ for (const settlement of settlements) {
351
+ switch (settlement.archetype) {
352
+ case "held_payment":
353
+ case "instant_transfer":
354
+ case "scheduled":
355
+ case "metered":
356
+ payers.add(settlement.payer);
357
+ beneficiaries.add(settlement.payee);
358
+ break;
359
+ case "premium_forward":
360
+ payers.add(settlement.payer);
361
+ providers.add(settlement.carrier);
362
+ break;
363
+ case "deposit":
364
+ payers.add(settlement.payer);
365
+ holders.add(settlement.holder);
366
+ break;
367
+ case "advance":
368
+ payers.add(settlement.funder);
369
+ beneficiaries.add(settlement.advanced);
370
+ break;
371
+ case "pooled_split":
372
+ payers.add(settlement.payer);
373
+ for (const share of settlement.shares)
374
+ beneficiaries.add(share.to);
375
+ break;
376
+ case "swap":
377
+ payers.add(settlement.sides[0].party);
378
+ beneficiaries.add(settlement.sides[1].party);
379
+ break;
380
+ }
381
+ }
382
+ const roles = new Map();
383
+ const assign = (names, role) => {
384
+ for (const name of names)
385
+ if (!roles.has(name))
386
+ roles.set(name, role);
387
+ };
388
+ assign(payers, "payer");
389
+ assign(providers, "provider");
390
+ assign(holders, "holder");
391
+ assign(beneficiaries, "beneficiary");
392
+ return roles;
393
+ }
394
+ const ARCHETYPE_MECHANICS = {
395
+ advance: "credit",
396
+ deposit: "escrow",
397
+ held_payment: "escrow",
398
+ instant_transfer: "marketplace",
399
+ metered: "recurring_billing",
400
+ pooled_split: "marketplace",
401
+ premium_forward: "insurance",
402
+ scheduled: "recurring_billing",
403
+ swap: "escrow",
404
+ };
405
+ function mechanicsOf(settlements) {
406
+ const mechanics = new Set(settlements.map((settlement) => ARCHETYPE_MECHANICS[settlement.archetype]));
407
+ return mechanics.size > 0 ? [...mechanics] : ["escrow"];
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // swap: strict two-party, two-leg atomic custody
411
+ function lowerSwap(settlement, releasePort, disputePort) {
412
+ const noun = settlement.name;
413
+ const [sideA, sideB] = settlement.sides;
414
+ const window = settlement.dispute?.window;
415
+ const hasClawback = window !== undefined && window.days > 0;
416
+ const postRuleKey = frameKey(`${noun}_clawback_maturity`);
417
+ const events = [];
418
+ const event = (stem, kind, amount, fromActor, toActor, trigger) => {
419
+ const key = frameKey(`${noun}_${stem}`);
420
+ events.push(mintEvent({ amount, fromActor, key, kind, toActor, trigger }));
421
+ return key;
422
+ };
423
+ const sideAFundEvent = event("side_a_fund", "charge", `The full ${sideA.amount.name}`, sideA.party, "escrow", `Fund ${sideA.amount.name} into the shared trade escrow`);
424
+ const sideBFundEvent = event("side_b_fund", "charge", `The full ${sideB.amount.name}`, sideB.party, "escrow", `Fund ${sideB.amount.name} into the shared trade escrow`);
425
+ const sideAReleaseEvent = event("side_a_release", "payout", `The full ${sideA.amount.name}`, "escrow", sideB.party, `Release ${sideA.amount.name} across to ${sideB.party.replaceAll("_", " ")}`);
426
+ const sideBReleaseEvent = event("side_b_release", "payout", `The full ${sideB.amount.name}`, "escrow", sideA.party, `Release ${sideB.amount.name} across to ${sideA.party.replaceAll("_", " ")}`);
427
+ const sideACancelEvent = event("side_a_cancel", "refund", `The full ${sideA.amount.name}`, "escrow", sideA.party, `Return ${sideA.amount.name} to its original funder on cancellation`);
428
+ const sideBCancelEvent = event("side_b_cancel", "refund", `The full ${sideB.amount.name}`, "escrow", sideB.party, `Return ${sideB.amount.name} to its original funder on cancellation`);
429
+ const sideAClawbackEvent = hasClawback
430
+ ? event("side_a_clawback", "refund", `The full ${sideA.amount.name}`, "escrow", sideA.party, `Return ${sideA.amount.name} after the whole trade is disputed`)
431
+ : undefined;
432
+ const sideBClawbackEvent = hasClawback
433
+ ? event("side_b_clawback", "refund", `The full ${sideB.amount.name}`, "escrow", sideB.party, `Return ${sideB.amount.name} after the whole trade is disputed`)
434
+ : undefined;
435
+ const fields = {
436
+ [sideA.amount.name]: moneyFieldSpec(`Side A amount in ${sideA.amount.currency} minor units, held against the whole trade`),
437
+ [sideB.amount.name]: moneyFieldSpec(`Side B amount in ${sideB.amount.currency} minor units, held against the whole trade`),
438
+ ...(hasClawback
439
+ ? {
440
+ clawbackAt: {
441
+ type: "date?",
442
+ desc: `Machine-owned end of the ${window?.raw ?? "fixed"} whole-trade dispute window`,
443
+ },
444
+ }
445
+ : {}),
446
+ };
447
+ const fundMoves = [
448
+ {
449
+ amount: sideA.amount.name,
450
+ from: sideA.party,
451
+ key: "side_a_principal",
452
+ moneyEvent: sideAFundEvent,
453
+ operation: "create",
454
+ to: "escrow",
455
+ },
456
+ {
457
+ amount: sideB.amount.name,
458
+ from: sideB.party,
459
+ key: "side_b_principal",
460
+ moneyEvent: sideBFundEvent,
461
+ operation: "create",
462
+ to: "escrow",
463
+ },
464
+ ];
465
+ const feeLines = [];
466
+ for (const [index, side] of settlement.sides.entries()) {
467
+ if (!side.fee)
468
+ continue;
469
+ const field = side.fee.amount.name;
470
+ fields[field] = moneyFieldSpec(`Exact ${side.party.replaceAll("_", " ")} service fee in ${side.fee.amount.currency} minor units, charged on top and never held`);
471
+ const feeEvent = event(index === 0 ? "side_a_service_fee" : "side_b_service_fee", "charge", `The exact ${field}, on top`, side.party, "platform", `Collect the ${side.party.replaceAll("_", " ")} custody fee at funding`);
472
+ fundMoves.push({
473
+ amount: field,
474
+ from: side.party,
475
+ key: index === 0 ? "side_a_service_fee" : "side_b_service_fee",
476
+ moneyEvent: feeEvent,
477
+ operation: "create",
478
+ to: "platform",
479
+ });
480
+ feeLines.push({
481
+ label: `${titleize(side.party)} custody fee`,
482
+ on: `each funded ${noun.replaceAll("_", " ")}`,
483
+ structure: `Exact ${field}, on top`,
484
+ });
485
+ }
486
+ const releaseMoves = [
487
+ {
488
+ amount: sideA.amount.name,
489
+ from: "escrow",
490
+ key: "side_a",
491
+ moneyEvent: sideAReleaseEvent,
492
+ operation: hasClawback ? "reserve" : "create",
493
+ to: sideB.party,
494
+ },
495
+ {
496
+ amount: sideB.amount.name,
497
+ from: "escrow",
498
+ key: "side_b",
499
+ moneyEvent: sideBReleaseEvent,
500
+ operation: hasClawback ? "reserve" : "create",
501
+ to: sideA.party,
502
+ },
503
+ ];
504
+ const verbs = {
505
+ abandon: {
506
+ from: ["created"],
507
+ requiresDrainedAccount: { path: "refs.escrowAccountId" },
508
+ summary: "Abandon the trade before its atomic funding batch",
509
+ to: "abandoned",
510
+ },
511
+ cancel: {
512
+ from: ["funded"],
513
+ moves: [
514
+ {
515
+ amount: sideA.amount.name,
516
+ from: "escrow",
517
+ key: "side_a_refund",
518
+ moneyEvent: sideACancelEvent,
519
+ operation: "create",
520
+ to: sideA.party,
521
+ },
522
+ {
523
+ amount: sideB.amount.name,
524
+ from: "escrow",
525
+ key: "side_b_refund",
526
+ moneyEvent: sideBCancelEvent,
527
+ operation: "create",
528
+ to: sideB.party,
529
+ },
530
+ ],
531
+ summary: "Cancel and return both trade principals atomically",
532
+ to: "cancelled",
533
+ },
534
+ create: {
535
+ summary: `Create a ${titleize(noun).toLowerCase()} atomic trade`,
536
+ to: "created",
537
+ },
538
+ fund: {
539
+ from: ["created"],
540
+ moves: fundMoves,
541
+ summary: "Fund both trade sides and collect on-top fees atomically",
542
+ to: "funded",
543
+ },
544
+ release: {
545
+ from: ["funded"],
546
+ moves: releaseMoves,
547
+ port: { allowed: [...releasePort.allowed] },
548
+ ...(hasClawback
549
+ ? { setsAt: { field: "clawbackAt", offset: window?.raw } }
550
+ : {}),
551
+ summary: hasClawback
552
+ ? "Reserve both cross-payments for the whole-trade clawback window"
553
+ : "Post both cross-payments atomically",
554
+ to: hasClawback ? "released" : "settled",
555
+ },
556
+ };
557
+ if (hasClawback &&
558
+ settlement.dispute &&
559
+ disputePort &&
560
+ sideAClawbackEvent &&
561
+ sideBClawbackEvent) {
562
+ verbs.post = {
563
+ due: { field: "clawbackAt", rule: postRuleKey },
564
+ from: ["released"],
565
+ moves: [
566
+ { key: "side_a", operation: "post", reservation: "release_side_a" },
567
+ { key: "side_b", operation: "post", reservation: "release_side_b" },
568
+ ],
569
+ summary: "Post both trade reservations when the clawback window matures",
570
+ to: "settled",
571
+ };
572
+ verbs.dispute = {
573
+ deadline: { field: "clawbackAt" },
574
+ from: ["released"],
575
+ moves: [
576
+ {
577
+ key: "side_a_void",
578
+ operation: "void",
579
+ reason: "Whole trade disputed inside the clawback window",
580
+ reservation: "release_side_a",
581
+ },
582
+ {
583
+ key: "side_b_void",
584
+ operation: "void",
585
+ reason: "Whole trade disputed inside the clawback window",
586
+ reservation: "release_side_b",
587
+ },
588
+ {
589
+ amount: sideA.amount.name,
590
+ clawbackOf: "release_side_a",
591
+ from: "escrow",
592
+ key: "side_a_refund",
593
+ moneyEvent: sideAClawbackEvent,
594
+ operation: "create",
595
+ to: sideA.party,
596
+ },
597
+ {
598
+ amount: sideB.amount.name,
599
+ clawbackOf: "release_side_b",
600
+ from: "escrow",
601
+ key: "side_b_refund",
602
+ moneyEvent: sideBClawbackEvent,
603
+ operation: "create",
604
+ to: sideB.party,
605
+ },
606
+ ],
607
+ port: { allowed: [...disputePort.allowed] },
608
+ summary: "Void both reservations, then refund both principals atomically",
609
+ to: "clawed_back",
610
+ };
611
+ }
612
+ const rules = [
613
+ {
614
+ allowedActors: [...releasePort.allowed],
615
+ detail: `${releasePort.allowed.map(titleize).join(" or ")} confirms the whole exchange through ${releasePort.name}`,
616
+ dueDriven: false,
617
+ enforcement: "tenant_app",
618
+ gatesEvent: sideAReleaseEvent,
619
+ key: frameKey(`${noun}_${releasePort.name}_gate`),
620
+ kind: "release_condition",
621
+ label: `Whole trade released through ${releasePort.name}`,
622
+ tenantTunable: false,
623
+ },
624
+ ];
625
+ if (hasClawback && settlement.dispute && disputePort) {
626
+ rules.push({
627
+ allowedActors: [],
628
+ detail: `Both pending trade payouts post together at the immutable ${settlement.dispute.window.raw} cutoff`,
629
+ dueDriven: true,
630
+ enforcement: "platform",
631
+ gatesEvent: null,
632
+ key: postRuleKey,
633
+ kind: "deadline",
634
+ label: "Whole trade posts when its clawback window matures",
635
+ tenantTunable: false,
636
+ }, {
637
+ allowedActors: [...disputePort.allowed],
638
+ detail: `${disputePort.allowed.map(titleize).join(" or ")} may dispute only before the immutable cutoff`,
639
+ dueDriven: false,
640
+ enforcement: "tenant_app",
641
+ gatesEvent: sideAClawbackEvent ?? null,
642
+ key: frameKey(`${noun}_${disputePort.name}_gate`),
643
+ kind: "release_condition",
644
+ label: `Whole trade disputed through ${disputePort.name}`,
645
+ tenantTunable: false,
646
+ });
647
+ }
648
+ return {
649
+ design: [
650
+ `${noun}: exactly two parties, two same-currency principals, one escrow, and one linked batch per phase`,
651
+ hasClawback
652
+ ? `${noun}: ${window?.raw} whole-trade clawback; release reserves both legs, then exactly one grouped post or grouped void-and-refund wins`
653
+ : `${noun}: no clawback window; release posts both legs directly and exposes no pending or dispute surface`,
654
+ ],
655
+ feeLines,
656
+ moneyEvents: events,
657
+ noun: {
658
+ actors: {
659
+ [sideA.party]: "payer",
660
+ [sideB.party]: "beneficiary",
661
+ ...(feeLines.length > 0 ? { platform: "party" } : {}),
662
+ },
663
+ desc: `Atomic swap between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}; half-funded and half-released states do not exist`,
664
+ distinctParties: true,
665
+ escrow: true,
666
+ fields,
667
+ id: noun,
668
+ summary: `Two-party atomic trade between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}`,
669
+ title: titleize(noun),
670
+ verbs,
671
+ },
672
+ rules,
673
+ settlement: {
674
+ name: noun,
675
+ pieces: [
676
+ {
677
+ bps: 10_000,
678
+ cancelTo: sideA.party,
679
+ field: sideA.amount.name,
680
+ origin: sideA.amount.origin,
681
+ releaseTo: sideB.party,
682
+ },
683
+ {
684
+ bps: 10_000,
685
+ cancelTo: sideB.party,
686
+ field: sideB.amount.name,
687
+ origin: sideB.amount.origin,
688
+ releaseTo: sideA.party,
689
+ },
690
+ ],
691
+ },
692
+ };
693
+ }
694
+ // ---------------------------------------------------------------------------
695
+ // held_payment and premium_forward: the escrow-held family
696
+ function lowerHeldPayment(settlement, port,
697
+ /** The funder of the advance carving this hold's release, when one does. */
698
+ carveTo, issues) {
699
+ const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
700
+ const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
701
+ const held = lowerHeldFamily({
702
+ amount: settlement.amount,
703
+ carveTo,
704
+ deadlineField: settlement.releaseDeadlineField,
705
+ fundEventKind: "charge",
706
+ fundTrigger: (index, total) => `Fund piece ${index + 1} of ${total} into escrow`,
707
+ name: settlement.name,
708
+ onCancel: settlement.onCancel,
709
+ payee: settlement.payee,
710
+ payeeFeeBps: payeeFee?.bps ?? 0,
711
+ payer: settlement.payer,
712
+ payerFeeBps: payerFee?.bps,
713
+ port,
714
+ releaseWord: "release",
715
+ }, issues);
716
+ if (!held)
717
+ return undefined;
718
+ return {
719
+ ...held,
720
+ feeLines: [
721
+ ...(payerFee
722
+ ? [
723
+ {
724
+ label: `${titleize(settlement.payer)} service fee`,
725
+ on: `each funded ${settlement.name.replaceAll("_", " ")}`,
726
+ structure: `${formatBps(payerFee.bps)} of the ${settlement.amount.name}, on top`,
727
+ },
728
+ ]
729
+ : []),
730
+ ...(payeeFee
731
+ ? [
732
+ {
733
+ label: `${titleize(settlement.payee)} fee`,
734
+ on: `each released ${settlement.name.replaceAll("_", " ")}`,
735
+ structure: `${formatBps(payeeFee.bps)} of the ${settlement.amount.name}, deducted from the payout`,
736
+ },
737
+ ]
738
+ : []),
739
+ ],
740
+ noun: {
741
+ ...held.noun,
742
+ desc: `Held payment: the ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into this settlement's own escrow; ${port.allowed
743
+ .map((party) => party.replaceAll("_", " "))
744
+ .join(" or ")} confirms through ${port.name} to release${carveTo ? ` to the ${carveTo.replaceAll("_", " ")}, whose advance the ${settlement.payee.replaceAll("_", " ")} repays out of it` : ""}`,
745
+ summary: `Escrow-held payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
746
+ },
747
+ };
748
+ }
749
+ function lowerPremiumForward(settlement, port, issues) {
750
+ const held = lowerHeldFamily({
751
+ amount: settlement.amount,
752
+ // A premium is the carrier's, never the payer's receivable, so there is
753
+ // nothing here for an advance to draw against.
754
+ carveTo: undefined,
755
+ deadlineField: undefined,
756
+ fundEventKind: "premium",
757
+ fundTrigger: (index, total) => total === 1
758
+ ? "Collect the premium into escrow"
759
+ : `Collect premium piece ${index + 1} of ${total} into escrow`,
760
+ name: settlement.name,
761
+ onCancel: settlement.onCancel,
762
+ payee: settlement.carrier,
763
+ payeeFeeBps: settlement.commissionBps,
764
+ payer: settlement.payer,
765
+ payerFeeBps: undefined,
766
+ port,
767
+ releaseWord: "forward",
768
+ }, issues);
769
+ if (!held)
770
+ return undefined;
771
+ return {
772
+ ...held,
773
+ design: [
774
+ `${settlement.name}: premium forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on ${port.name}; ${formatBps(settlement.commissionBps)} commission retained by the platform`,
775
+ ],
776
+ feeLines: settlement.commissionBps > 0
777
+ ? [
778
+ {
779
+ label: "Platform commission",
780
+ on: `each bound ${settlement.name.replaceAll("_", " ")}`,
781
+ structure: `${formatBps(settlement.commissionBps)} of the ${settlement.amount.name}, deducted at forwarding`,
782
+ },
783
+ ]
784
+ : [],
785
+ noun: {
786
+ ...held.noun,
787
+ desc: `Premium forward: the ${settlement.payer.replaceAll("_", " ")} funds the ${settlement.amount.name} into this settlement's own escrow; binding through ${port.name} forwards it to the ${settlement.carrier.replaceAll("_", " ")} exactly once, minus the platform commission`,
788
+ summary: `Premium held for the ${settlement.carrier.replaceAll("_", " ")} until the policy binds`,
789
+ },
790
+ };
791
+ }
792
+ function lowerHeldFamily(params, issues) {
793
+ const amountName = params.amount.name;
794
+ // A single-piece partition would mint a piece field nothing ties to the
795
+ // gross amount (no partition clause is declarable over one piece), letting
796
+ // an instance store one gross and move another. When the amount never
797
+ // splits, the choreography moves the amount field ITSELF.
798
+ const rawPieces = partitionPieces(params);
799
+ const pieces = rawPieces.length === 1
800
+ ? rawPieces.map((piece) => ({ ...piece, field: amountName }))
801
+ : rawPieces;
802
+ const noun = params.name;
803
+ // Who the payee's share actually lands on. Every sentence about the release
804
+ // has to say this name, not the payee's, or the program would describe a
805
+ // payout it does not make.
806
+ const releaseTo = params.carveTo ?? params.payee;
807
+ const releaseToWords = releaseTo.replaceAll("_", " ");
808
+ const fields = {
809
+ [amountName]: moneyFieldSpec(pieces.length === 1
810
+ ? `The held amount in ${params.amount.currency} minor units, funded and paid out whole`
811
+ : `The gross held amount in ${params.amount.currency} minor units; the piece fields below partition it exactly`),
812
+ };
813
+ for (const [index, piece] of pieces.entries()) {
814
+ if (piece.field === amountName)
815
+ continue;
816
+ fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, params.amount.currency));
817
+ }
818
+ if (params.deadlineField) {
819
+ fields[params.deadlineField] = dateFieldSpec(`The date an undecided hold releases to the ${releaseToWords} on; ${params.port.name} and cancellation decide only before it`);
820
+ }
821
+ if (params.payerFeeBps !== undefined) {
822
+ fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(params.payerFeeBps)} of ${amountName}, the ${params.payer.replaceAll("_", " ")}-side service fee charged on top at funding; non-refundable`);
823
+ }
824
+ const fundVerbs = pieces.map((_, index) => `fund_piece_${index + 1}`);
825
+ if (params.payerFeeBps !== undefined)
826
+ fundVerbs.push("collect_service_fee");
827
+ const releaseVerbs = pieces.map((_, index) => index === 0 ? params.port.name : `${params.releaseWord}_piece_${index + 1}`);
828
+ const cancelVerbs = params.onCancel
829
+ ? pieces.map((_, index) => index === 0 ? "cancel" : `refund_piece_${index + 1}`)
830
+ : [];
831
+ // The anchor is the DEFAULT exit, not a second decider. It mints one more
832
+ // entry into the SAME release chain, so every piece drains through the
833
+ // verbs the port path already proves, and the port and the cancel keep
834
+ // their veto only until the date. Acting before it IS the veto.
835
+ const deadlineVerb = params.deadlineField
836
+ ? `${params.releaseWord}_on_deadline`
837
+ : undefined;
838
+ const deadlineRuleKey = frameKey(`${noun}_${params.releaseWord}_deadline`);
839
+ // Abandonment: the pre-funded exit. Custody exists only while the deal is
840
+ // still forming, so each intermediate funding state (funding_k holds pieces
841
+ // 1..k) unwinds piece by piece. Every unfund verb returns exactly the piece
842
+ // its funding verb moved, back to the payer, and `created` closes directly.
843
+ // The chain runs through its own abandoning_* states (never back into
844
+ // funding states) so the lifecycle stays acyclic and the terminal-escrow
845
+ // analysis keeps its exact custody tokens. The service fee moves only on the
846
+ // transition INTO funded (a completed collection), so abandonment never owes
847
+ // it, the on_cancel policy stays the sole exit from funded.
848
+ const fundingStateCount = fundVerbs.length - 1;
849
+ const unfundVerbs = Array.from({ length: fundingStateCount }, (_, index) => `unfund_piece_${index + 1}`);
850
+ if (!verbNameIssues(params.name, [
851
+ ...fundVerbs,
852
+ ...releaseVerbs,
853
+ ...(deadlineVerb ? [deadlineVerb] : []),
854
+ ...cancelVerbs,
855
+ "abandon",
856
+ ...unfundVerbs,
857
+ ], params.port.origin, issues)) {
858
+ return undefined;
859
+ }
860
+ const fundStates = chain(fundVerbs, "created", "funded", "funding");
861
+ const releaseStates = chain(releaseVerbs, "funded", "released", "releasing");
862
+ const cancelStates = chain(cancelVerbs, "funded", "cancelled", "cancelling");
863
+ const events = [];
864
+ const verbs = {};
865
+ // The budget counts money BEHAVIORS, not pieces: every piece verb sharing a
866
+ // phase and endpoint pair implements ONE frame event (occurrence repeatable
867
+ // when several piece verbs share it), so fee carving and cancellation splits
868
+ // never crowd a composite program out of the frame's event budget.
869
+ const fundEventKey = `${noun}_fund`;
870
+ events.push(mintEvent({
871
+ amount: pieces.length === 1
872
+ ? `The full ${amountName}`
873
+ : `The ${amountName}, funded piece by piece`,
874
+ fromActor: params.payer,
875
+ key: fundEventKey,
876
+ kind: params.fundEventKind,
877
+ ...(pieces.length > 1 ? { occurrence: "repeatable" } : {}),
878
+ toActor: "escrow",
879
+ trigger: params.fundTrigger(0, pieces.length),
880
+ }));
881
+ for (const [index, piece] of pieces.entries()) {
882
+ verbs[fundVerbs[index]] = {
883
+ from: [fundStates[index]?.from],
884
+ moneyEvent: fundEventKey,
885
+ moves: [
886
+ {
887
+ key: "transfer",
888
+ operation: "create",
889
+ amount: piece.field,
890
+ from: params.payer,
891
+ to: "escrow",
892
+ },
893
+ ],
894
+ summary: `Fund piece ${index + 1} of the held amount into escrow`,
895
+ to: fundStates[index]?.to,
896
+ };
897
+ }
898
+ if (params.payerFeeBps !== undefined) {
899
+ const index = fundVerbs.length - 1;
900
+ const eventKey = `${noun}_service_fee`;
901
+ events.push(mintEvent({
902
+ amount: `${formatBps(params.payerFeeBps)} of the ${amountName}, on top`,
903
+ fromActor: params.payer,
904
+ key: eventKey,
905
+ kind: "charge",
906
+ toActor: "platform",
907
+ trigger: "Collect the service fee at funding",
908
+ }));
909
+ verbs.collect_service_fee = {
910
+ from: [fundStates[index]?.from],
911
+ moneyEvent: eventKey,
912
+ moves: [
913
+ {
914
+ key: "transfer",
915
+ operation: "create",
916
+ amount: "serviceFeeAmount",
917
+ from: params.payer,
918
+ to: "platform",
919
+ },
920
+ ],
921
+ summary: "Collect the payer-side service fee",
922
+ to: fundStates[index]?.to,
923
+ };
924
+ }
925
+ // Release and cancel pieces group by recipient: one frame event per
926
+ // distinct endpoint (an event's toActor is fixed), shared by every piece
927
+ // verb paying that recipient.
928
+ const releaseGroups = new Map();
929
+ for (const piece of pieces) {
930
+ releaseGroups.set(piece.releaseTo, (releaseGroups.get(piece.releaseTo) ?? 0) + 1);
931
+ }
932
+ for (const [releaseTo, pieceCount] of releaseGroups) {
933
+ const totalBps = pieces
934
+ .filter((piece) => piece.releaseTo === releaseTo)
935
+ .reduce((sum, piece) => sum + piece.bps, 0);
936
+ events.push(mintEvent({
937
+ amount: `${formatBps(totalBps)} of the ${amountName}`,
938
+ fromActor: "escrow",
939
+ key: `${noun}_release_${releaseTo}`,
940
+ kind: "payout",
941
+ ...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
942
+ toActor: releaseTo,
943
+ trigger: `Release to the ${releaseTo.replaceAll("_", " ")}`,
944
+ }));
945
+ }
946
+ for (const [index, piece] of pieces.entries()) {
947
+ verbs[releaseVerbs[index]] = {
948
+ ...(index === 0 && params.deadlineField
949
+ ? { deadline: { field: params.deadlineField } }
950
+ : {}),
951
+ from: [releaseStates[index]?.from],
952
+ moneyEvent: frameKey(`${noun}_release_${piece.releaseTo}`),
953
+ moves: [
954
+ {
955
+ key: "transfer",
956
+ operation: "create",
957
+ amount: piece.field,
958
+ from: "escrow",
959
+ to: piece.releaseTo,
960
+ },
961
+ ],
962
+ summary: index === 0
963
+ ? `Confirm through ${params.port.name} and start the ${params.releaseWord} payout`
964
+ : `${titleize(params.releaseWord)} piece ${index + 1} of the held amount`,
965
+ to: releaseStates[index]?.to,
966
+ };
967
+ }
968
+ if (deadlineVerb && params.deadlineField) {
969
+ const first = pieces[0];
970
+ verbs[deadlineVerb] = {
971
+ due: { field: params.deadlineField, rule: deadlineRuleKey },
972
+ from: [releaseStates[0]?.from],
973
+ moneyEvent: frameKey(`${noun}_release_${first.releaseTo}`),
974
+ moves: [
975
+ {
976
+ key: "transfer",
977
+ operation: "create",
978
+ amount: first.field,
979
+ from: "escrow",
980
+ to: first.releaseTo,
981
+ },
982
+ ],
983
+ summary: `Release to the ${first.releaseTo.replaceAll("_", " ")} when ${params.deadlineField} arrives undecided`,
984
+ to: releaseStates[0]?.to,
985
+ };
986
+ }
987
+ if (params.onCancel) {
988
+ const cancelGroups = new Map();
989
+ for (const piece of pieces) {
990
+ const cancelTo = piece.cancelTo;
991
+ cancelGroups.set(cancelTo, (cancelGroups.get(cancelTo) ?? 0) + 1);
992
+ }
993
+ for (const [cancelTo, pieceCount] of cancelGroups) {
994
+ const totalBps = pieces
995
+ .filter((piece) => piece.cancelTo === cancelTo)
996
+ .reduce((sum, piece) => sum + piece.bps, 0);
997
+ events.push(mintEvent({
998
+ amount: `${formatBps(totalBps)} of the ${amountName}`,
999
+ fromActor: "escrow",
1000
+ key: `${noun}_cancel_${cancelTo}`,
1001
+ kind: cancelTo === params.payer ? "refund" : "penalty",
1002
+ ...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
1003
+ toActor: cancelTo,
1004
+ trigger: `Return to the ${cancelTo.replaceAll("_", " ")} on cancellation`,
1005
+ }));
1006
+ }
1007
+ for (const [index, piece] of pieces.entries()) {
1008
+ verbs[cancelVerbs[index]] = {
1009
+ ...(index === 0 && params.deadlineField
1010
+ ? { deadline: { field: params.deadlineField } }
1011
+ : {}),
1012
+ from: [cancelStates[index]?.from],
1013
+ moneyEvent: frameKey(`${noun}_cancel_${piece.cancelTo}`),
1014
+ moves: [
1015
+ {
1016
+ key: "transfer",
1017
+ operation: "create",
1018
+ amount: piece.field,
1019
+ from: "escrow",
1020
+ to: piece.cancelTo,
1021
+ },
1022
+ ],
1023
+ summary: index === 0
1024
+ ? "Cancel the settlement and start the unwind"
1025
+ : `Return piece ${index + 1} on cancellation`,
1026
+ to: cancelStates[index]?.to,
1027
+ };
1028
+ }
1029
+ }
1030
+ if (unfundVerbs.length > 0) {
1031
+ const eventKey = `${noun}_abandon`;
1032
+ events.push(mintEvent({
1033
+ amount: `The funded pieces of the ${amountName}, returned exactly`,
1034
+ fromActor: "escrow",
1035
+ key: eventKey,
1036
+ kind: "refund",
1037
+ ...(unfundVerbs.length > 1
1038
+ ? { occurrence: "repeatable" }
1039
+ : {}),
1040
+ toActor: params.payer,
1041
+ trigger: `Return the held pieces to the ${params.payer.replaceAll("_", " ")} on abandonment`,
1042
+ }));
1043
+ for (const [index, verbName] of unfundVerbs.entries()) {
1044
+ const step = index + 1;
1045
+ verbs[verbName] = {
1046
+ from: [
1047
+ `funding_${step}`,
1048
+ ...(step < fundingStateCount ? [`abandoning_${step}`] : []),
1049
+ ],
1050
+ moneyEvent: frameKey(eventKey),
1051
+ moves: [
1052
+ {
1053
+ key: "transfer",
1054
+ operation: "create",
1055
+ amount: pieces[index].field,
1056
+ from: "escrow",
1057
+ to: params.payer,
1058
+ },
1059
+ ],
1060
+ summary: `Return piece ${step} to the ${params.payer.replaceAll("_", " ")} on abandonment`,
1061
+ to: step === 1 ? "abandoned" : `abandoning_${step - 1}`,
1062
+ };
1063
+ }
1064
+ }
1065
+ verbs.abandon = {
1066
+ from: ["created"],
1067
+ summary: "Abandon the settlement before any money is held",
1068
+ to: "abandoned",
1069
+ };
1070
+ verbs.create = {
1071
+ summary: `Create a ${titleize(params.name).toLowerCase()} settlement`,
1072
+ to: "created",
1073
+ };
1074
+ const rules = [
1075
+ {
1076
+ allowedActors: [...params.port.allowed],
1077
+ detail: `${params.port.allowed.map(titleize).join(" or ")} confirms through the tenant backend before ${params.deadlineField ? `${params.deadlineField}, to decide ahead of it` : "any payout"}`,
1078
+ dueDriven: false,
1079
+ enforcement: "tenant_app",
1080
+ gatesEvent: frameKey(`${noun}_release_${pieces[0].releaseTo}`),
1081
+ key: frameKey(`${noun}_${params.port.name}_gate`),
1082
+ kind: "release_condition",
1083
+ label: `${titleize(params.releaseWord)} decided through ${params.port.name}`,
1084
+ tenantTunable: false,
1085
+ },
1086
+ ];
1087
+ if (params.deadlineField) {
1088
+ rules.push({
1089
+ allowedActors: [],
1090
+ detail: `A hold nobody decided releases to the ${releaseToWords} on its stored ${params.deadlineField}, exactly once`,
1091
+ dueDriven: true,
1092
+ enforcement: "platform",
1093
+ gatesEvent: null,
1094
+ key: deadlineRuleKey,
1095
+ kind: "deadline",
1096
+ label: `Undecided holds release on ${params.deadlineField}`,
1097
+ tenantTunable: false,
1098
+ });
1099
+ }
1100
+ return {
1101
+ design: [
1102
+ `${noun}: own escrow; ${pieces.length}-piece partition of ${amountName} (${pieces
1103
+ .map((piece) => formatBps(piece.bps))
1104
+ .join(" + ")}); every exit drains every piece; abandonable before funded (created closes directly, funding states unwind piece by piece to the ${params.payer})`,
1105
+ ...(params.deadlineField
1106
+ ? [
1107
+ `${noun}: undecided holds release to the ${releaseTo} on ${params.deadlineField}; the port and the cancel decide only before that anchor`,
1108
+ ]
1109
+ : []),
1110
+ ...(params.carveTo
1111
+ ? [
1112
+ `${noun}: the ${params.payee}'s whole release share is carved to the ${params.carveTo}, who financed it; the platform fee and the cancellation split are untouched`,
1113
+ ]
1114
+ : []),
1115
+ ...(params.payerFeeBps !== undefined
1116
+ ? [
1117
+ `${noun}: ${formatBps(params.payerFeeBps)} ${params.payer} service fee on top, straight to platform at funding`,
1118
+ ]
1119
+ : []),
1120
+ ],
1121
+ feeLines: [],
1122
+ moneyEvents: events,
1123
+ noun: {
1124
+ actors: {
1125
+ [params.payer]: "payer",
1126
+ // The payee stays the beneficiary under a carve, it is their
1127
+ // receivable, while the funder joins as the endpoint the release
1128
+ // pays. Only one beneficiary, so the frame's parties stay unambiguous.
1129
+ [params.payee]: "beneficiary",
1130
+ ...(params.carveTo ? { [params.carveTo]: "party" } : {}),
1131
+ platform: "party",
1132
+ },
1133
+ desc: `Held amount from ${params.payer.replaceAll("_", " ")} to ${params.payee.replaceAll("_", " ")}${params.carveTo ? `, released to the ${releaseToWords} against the advance it secures` : ""}`,
1134
+ escrow: true,
1135
+ fields,
1136
+ id: params.name,
1137
+ ...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
1138
+ summary: `Escrow-held amount from ${params.payer.replaceAll("_", " ")}`,
1139
+ title: titleize(params.name),
1140
+ verbs,
1141
+ },
1142
+ rules,
1143
+ settlement: {
1144
+ name: params.name,
1145
+ pieces,
1146
+ ...(params.payerFeeBps !== undefined
1147
+ ? { serviceFee: { bps: params.payerFeeBps, field: "serviceFeeAmount" } }
1148
+ : {}),
1149
+ },
1150
+ };
1151
+ }
1152
+ // ---------------------------------------------------------------------------
1153
+ // instant_transfer: straight-through partitioned payment, no custody
1154
+ function lowerInstantTransfer(settlement) {
1155
+ const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
1156
+ const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
1157
+ const amountName = settlement.amount.name;
1158
+ // Same single-piece law as the held family: a fee-free transfer moves the
1159
+ // amount field itself, so nothing untied to the gross can be admitted.
1160
+ const rawPieces = partitionPieces({
1161
+ amount: settlement.amount,
1162
+ payee: settlement.payee,
1163
+ payeeFeeBps: payeeFee?.bps ?? 0,
1164
+ });
1165
+ const pieces = rawPieces.length === 1
1166
+ ? rawPieces.map((piece) => ({ ...piece, field: amountName }))
1167
+ : rawPieces;
1168
+ const noun = settlement.name;
1169
+ const fields = {
1170
+ [amountName]: moneyFieldSpec(pieces.length === 1
1171
+ ? `The amount in ${settlement.amount.currency} minor units, paid through whole`
1172
+ : `The gross amount in ${settlement.amount.currency} minor units; the piece fields below partition it exactly`),
1173
+ };
1174
+ for (const [index, piece] of pieces.entries()) {
1175
+ if (piece.field === amountName)
1176
+ continue;
1177
+ fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, settlement.amount.currency));
1178
+ }
1179
+ if (payerFee) {
1180
+ fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(payerFee.bps)} of ${amountName}, the ${settlement.payer.replaceAll("_", " ")}-side service fee charged on top; non-refundable`);
1181
+ }
1182
+ const payVerbs = pieces.map((_, index) => `pay_piece_${index + 1}`);
1183
+ if (payerFee)
1184
+ payVerbs.push("collect_service_fee");
1185
+ const payStates = chain(payVerbs, "created", "paid", "paying");
1186
+ const events = [];
1187
+ const verbs = {
1188
+ create: {
1189
+ summary: `Create a ${titleize(noun).toLowerCase()} payment`,
1190
+ to: "created",
1191
+ },
1192
+ };
1193
+ for (const [index, piece] of pieces.entries()) {
1194
+ const eventKey = `${noun}_pay_${index + 1}`;
1195
+ events.push(mintEvent({
1196
+ amount: `${formatBps(piece.bps)} of the ${amountName}`,
1197
+ fromActor: settlement.payer,
1198
+ key: eventKey,
1199
+ kind: "charge",
1200
+ toActor: piece.releaseTo,
1201
+ trigger: `Pay piece ${index + 1} straight to the ${piece.releaseTo.replaceAll("_", " ")}`,
1202
+ }));
1203
+ verbs[payVerbs[index]] = {
1204
+ from: [payStates[index]?.from],
1205
+ moneyEvent: eventKey,
1206
+ moves: [
1207
+ {
1208
+ key: "transfer",
1209
+ operation: "create",
1210
+ amount: piece.field,
1211
+ from: settlement.payer,
1212
+ to: piece.releaseTo,
1213
+ },
1214
+ ],
1215
+ summary: `Pay piece ${index + 1} of the amount through`,
1216
+ to: payStates[index]?.to,
1217
+ };
1218
+ }
1219
+ if (payerFee) {
1220
+ const index = payVerbs.length - 1;
1221
+ const eventKey = `${noun}_service_fee`;
1222
+ events.push(mintEvent({
1223
+ amount: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
1224
+ fromActor: settlement.payer,
1225
+ key: eventKey,
1226
+ kind: "charge",
1227
+ toActor: "platform",
1228
+ trigger: "Collect the service fee with the payment",
1229
+ }));
1230
+ verbs.collect_service_fee = {
1231
+ from: [payStates[index]?.from],
1232
+ moneyEvent: eventKey,
1233
+ moves: [
1234
+ {
1235
+ key: "transfer",
1236
+ operation: "create",
1237
+ amount: "serviceFeeAmount",
1238
+ from: settlement.payer,
1239
+ to: "platform",
1240
+ },
1241
+ ],
1242
+ summary: "Collect the payer-side service fee",
1243
+ to: payStates[index]?.to,
1244
+ };
1245
+ }
1246
+ const touchesPlatform = payerFee !== undefined ||
1247
+ pieces.some((piece) => piece.releaseTo === "platform");
1248
+ return {
1249
+ design: [
1250
+ `${noun}: instant pass-through; ${pieces.length}-piece partition of ${amountName} (${pieces
1251
+ .map((piece) => formatBps(piece.bps))
1252
+ .join(" + ")}); no custody`,
1253
+ ],
1254
+ feeLines: [
1255
+ ...(payerFee
1256
+ ? [
1257
+ {
1258
+ label: `${titleize(settlement.payer)} service fee`,
1259
+ on: `each ${noun.replaceAll("_", " ")}`,
1260
+ structure: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
1261
+ },
1262
+ ]
1263
+ : []),
1264
+ ...(payeeFee
1265
+ ? [
1266
+ {
1267
+ label: `${titleize(settlement.payee)} fee`,
1268
+ on: `each ${noun.replaceAll("_", " ")}`,
1269
+ structure: `${formatBps(payeeFee.bps)} of the ${amountName}, deducted from the payout`,
1270
+ },
1271
+ ]
1272
+ : []),
1273
+ ],
1274
+ moneyEvents: events,
1275
+ noun: {
1276
+ actors: {
1277
+ [settlement.payer]: "payer",
1278
+ [settlement.payee]: "beneficiary",
1279
+ ...(touchesPlatform ? { platform: "party" } : {}),
1280
+ },
1281
+ desc: `Instant transfer: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} straight through to the ${settlement.payee.replaceAll("_", " ")}, no custody`,
1282
+ fields,
1283
+ id: noun,
1284
+ ...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
1285
+ summary: `Instant payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
1286
+ title: titleize(noun),
1287
+ verbs,
1288
+ },
1289
+ rules: [],
1290
+ settlement: {
1291
+ name: noun,
1292
+ pieces,
1293
+ ...(payerFee
1294
+ ? { serviceFee: { bps: payerFee.bps, field: "serviceFeeAmount" } }
1295
+ : {}),
1296
+ },
1297
+ };
1298
+ }
1299
+ // ---------------------------------------------------------------------------
1300
+ // deposit: a reservation placed, then claimed or returned
1301
+ function lowerDeposit(settlement, claim, giveBack, issues) {
1302
+ const noun = settlement.name;
1303
+ const amountName = settlement.amount.name;
1304
+ if (!verbNameIssues(noun, ["place_deposit", claim.name, giveBack.name], settlement.origin, issues)) {
1305
+ return undefined;
1306
+ }
1307
+ const eventKey = `${noun}_hold_1`;
1308
+ const events = [
1309
+ mintEvent({
1310
+ amount: `The full ${amountName}`,
1311
+ fromActor: settlement.payer,
1312
+ key: eventKey,
1313
+ kind: "hold",
1314
+ toActor: settlement.holder,
1315
+ trigger: `Reserve the ${amountName} in the ${settlement.holder.replaceAll("_", " ")}'s favor`,
1316
+ }),
1317
+ ];
1318
+ const verbs = {
1319
+ create: {
1320
+ summary: `Create a ${titleize(noun).toLowerCase()}`,
1321
+ to: "created",
1322
+ },
1323
+ place_deposit: {
1324
+ from: ["created"],
1325
+ moves: [
1326
+ {
1327
+ key: "reservation",
1328
+ operation: "reserve",
1329
+ amount: amountName,
1330
+ from: settlement.payer,
1331
+ to: settlement.holder,
1332
+ },
1333
+ ],
1334
+ moneyEvent: eventKey,
1335
+ summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
1336
+ to: "held",
1337
+ },
1338
+ [claim.name]: {
1339
+ from: ["held"],
1340
+ moves: [
1341
+ {
1342
+ key: "post",
1343
+ operation: "post",
1344
+ reservation: "place_deposit_reservation",
1345
+ },
1346
+ ],
1347
+ summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
1348
+ to: "claimed",
1349
+ },
1350
+ [giveBack.name]: {
1351
+ from: ["held"],
1352
+ summary: `Return the deposit to the ${settlement.payer.replaceAll("_", " ")} through ${giveBack.name}`,
1353
+ to: "returned",
1354
+ moves: [
1355
+ {
1356
+ key: "void",
1357
+ operation: "void",
1358
+ reason: "Deposit returned in full",
1359
+ reservation: "place_deposit_reservation",
1360
+ },
1361
+ ],
1362
+ },
1363
+ };
1364
+ const portRule = (port, verbLabel) => ({
1365
+ allowedActors: [...port.allowed],
1366
+ detail: `${port.allowed.map(titleize).join(" or ")} decides through the tenant backend`,
1367
+ dueDriven: false,
1368
+ enforcement: "tenant_app",
1369
+ gatesEvent: null,
1370
+ key: frameKey(`${noun}_${port.name}_gate`),
1371
+ kind: "release_condition",
1372
+ label: `${verbLabel} decided through ${port.name}`,
1373
+ tenantTunable: false,
1374
+ });
1375
+ return {
1376
+ design: [
1377
+ `${noun}: ${amountName} held as a reservation on the ${settlement.payer.replaceAll("_", " ")}'s account; claimed whole through ${claim.name} or returned whole through ${giveBack.name}`,
1378
+ ],
1379
+ feeLines: [],
1380
+ moneyEvents: events,
1381
+ noun: {
1382
+ actors: {
1383
+ [settlement.payer]: "payer",
1384
+ [settlement.holder]: "beneficiary",
1385
+ },
1386
+ desc: `Deposit: the ${amountName} is reserved against the ${settlement.payer.replaceAll("_", " ")}'s account in the ${settlement.holder.replaceAll("_", " ")}'s favor, then claimed or returned in full`,
1387
+ fields: {
1388
+ [amountName]: moneyFieldSpec(`The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`),
1389
+ },
1390
+ id: noun,
1391
+ summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
1392
+ title: titleize(noun),
1393
+ verbs,
1394
+ },
1395
+ rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
1396
+ settlement: { name: noun, pieces: [] },
1397
+ };
1398
+ }
1399
+ // ---------------------------------------------------------------------------
1400
+ // scheduled and advance: finite due-driven anchors
1401
+ /** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
1402
+ function evenPieceBps(count) {
1403
+ const base = Math.floor(Number(TOTAL_BPS) / count);
1404
+ const widths = Array.from({ length: count }, () => base);
1405
+ widths[0] = Number(TOTAL_BPS) - base * (count - 1);
1406
+ return widths;
1407
+ }
1408
+ function anchorOffset(schedule, index) {
1409
+ return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
1410
+ }
1411
+ function lowerScheduled(settlement) {
1412
+ const noun = settlement.name;
1413
+ const amountName = settlement.amount.name;
1414
+ const { schedule } = settlement;
1415
+ const ruleKey = `${noun}_schedule`;
1416
+ const widths = evenPieceBps(schedule.count);
1417
+ const fields = {
1418
+ [amountName]: moneyFieldSpec(`The total scheduled amount in ${settlement.amount.currency} minor units; the installment fields below partition it exactly`),
1419
+ [schedule.firstDueField]: dateFieldSpec(`Due date of the first installment; installment k falls ${schedule.every.raw} after its predecessor`),
1420
+ };
1421
+ const installmentFields = widths.map((_, index) => {
1422
+ const field = `installment${index + 1}Amount`;
1423
+ fields[field] = moneyFieldSpec(`Installment ${index + 1} of ${schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}: about ${formatBps(widths[index])} of ${amountName}, collected on its own stored-date anchor`);
1424
+ return field;
1425
+ });
1426
+ const payVerbs = widths.map((_, index) => `pay_installment_${index + 1}`);
1427
+ const payStates = chain(payVerbs, "active", "settled", "collecting");
1428
+ // The whole schedule is ONE money event (occurrence: repeatable): the
1429
+ // budget counts money BEHAVIORS, not anchors, so a longer schedule never
1430
+ // crowds out a composite program's other settlements. The document still
1431
+ // unrolls to one idempotent anchor verb per installment, all implementing
1432
+ // the same event key.
1433
+ const eventKey = `${noun}_installments`;
1434
+ const events = [
1435
+ mintEvent({
1436
+ amount: `The ${amountName}, partitioned into ${schedule.count} installments`,
1437
+ fromActor: settlement.payer,
1438
+ key: eventKey,
1439
+ kind: "installment",
1440
+ occurrence: "repeatable",
1441
+ toActor: settlement.payee,
1442
+ trigger: `Collect each of the ${schedule.count} installments on its stored due date`,
1443
+ }),
1444
+ ];
1445
+ const verbs = {
1446
+ create: {
1447
+ summary: `Create a ${titleize(noun).toLowerCase()} plan`,
1448
+ to: "active",
1449
+ },
1450
+ };
1451
+ for (const [index, field] of installmentFields.entries()) {
1452
+ verbs[payVerbs[index]] = {
1453
+ due: {
1454
+ field: schedule.firstDueField,
1455
+ rule: ruleKey,
1456
+ ...anchorOffset(schedule, index),
1457
+ },
1458
+ from: [payStates[index]?.from],
1459
+ moneyEvent: eventKey,
1460
+ moves: [
1461
+ {
1462
+ key: "transfer",
1463
+ operation: "create",
1464
+ amount: field,
1465
+ from: settlement.payer,
1466
+ to: settlement.payee,
1467
+ },
1468
+ ],
1469
+ summary: `Collect installment ${index + 1} of ${schedule.count}`,
1470
+ to: payStates[index]?.to,
1471
+ };
1472
+ }
1473
+ return {
1474
+ design: [
1475
+ `${noun}: ${schedule.count} installments every ${schedule.every.raw} from ${schedule.firstDueField}; finite by construction, one idempotent anchor per installment`,
1476
+ ],
1477
+ feeLines: [],
1478
+ moneyEvents: events,
1479
+ noun: {
1480
+ actors: {
1481
+ [settlement.payer]: "payer",
1482
+ [settlement.payee]: "beneficiary",
1483
+ },
1484
+ desc: `Scheduled payment: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} to the ${settlement.payee.replaceAll("_", " ")} in ${schedule.count} installments, one every ${schedule.every.raw}`,
1485
+ fields,
1486
+ id: noun,
1487
+ ...partitionsSpread(partitionClause(amountName, installmentFields)),
1488
+ summary: `${schedule.count}-installment schedule from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
1489
+ title: titleize(noun),
1490
+ verbs,
1491
+ },
1492
+ rules: [
1493
+ {
1494
+ allowedActors: [],
1495
+ detail: `Each of the ${schedule.count} installments is collected once from its stored due date`,
1496
+ dueDriven: true,
1497
+ enforcement: "platform",
1498
+ gatesEvent: null,
1499
+ key: ruleKey,
1500
+ kind: "deadline",
1501
+ label: "Installments collected on their stored due dates",
1502
+ tenantTunable: false,
1503
+ },
1504
+ ],
1505
+ settlement: { name: noun, pieces: [] },
1506
+ };
1507
+ }
1508
+ function lowerAdvance(settlement) {
1509
+ return settlement.source.kind === "carve"
1510
+ ? lowerCarvedAdvance(settlement, settlement.source.settlement)
1511
+ : lowerScheduledAdvance(settlement, settlement.source.schedule);
1512
+ }
1513
+ /**
1514
+ * `advance { against: <hold>.release }`. The repayment leg is not this noun's
1515
+ * to make: the hold releases the financed party's whole share straight to the
1516
+ * funder, so what stays here is the disbursement, the terms the funder is
1517
+ * owed on, and the close that records the carve landing. An advance carved
1518
+ * this way can never pay out more than the hold already holds.
1519
+ */
1520
+ function lowerCarvedAdvance(settlement, hold) {
1521
+ const noun = settlement.name;
1522
+ const amountName = settlement.amount.name;
1523
+ const hasFee = settlement.feeBps > 0;
1524
+ const advancedWords = settlement.advanced.replaceAll("_", " ");
1525
+ const funderWords = settlement.funder.replaceAll("_", " ");
1526
+ const holdWords = hold.replaceAll("_", " ");
1527
+ const fields = {
1528
+ [amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${advancedWords} up front`),
1529
+ ...(hasFee
1530
+ ? {
1531
+ feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount owed on top of the advance`),
1532
+ repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: what the ${holdWords} release owes the ${funderWords}`),
1533
+ }
1534
+ : {}),
1535
+ };
1536
+ return {
1537
+ design: [
1538
+ `${noun}: ${amountName} advanced to the ${settlement.advanced} up front and repaid by carving the ${hold} release${hasFee ? `; repayableAmount = ${amountName} + ${formatBps(settlement.feeBps)} fee` : ""}`,
1539
+ ],
1540
+ feeLines: hasFee
1541
+ ? [
1542
+ {
1543
+ label: `${titleize(settlement.funder)} discount`,
1544
+ on: `each ${noun.replaceAll("_", " ")}`,
1545
+ structure: `${formatBps(settlement.feeBps)} of the ${amountName}, owed on top out of the ${holdWords} release`,
1546
+ },
1547
+ ]
1548
+ : [],
1549
+ moneyEvents: [
1550
+ mintEvent({
1551
+ amount: `The full ${amountName}`,
1552
+ fromActor: settlement.funder,
1553
+ key: `${noun}_disburse`,
1554
+ kind: "payout",
1555
+ toActor: settlement.advanced,
1556
+ trigger: `Disburse the advance to the ${advancedWords}`,
1557
+ }),
1558
+ ],
1559
+ noun: {
1560
+ actors: {
1561
+ [settlement.advanced]: "beneficiary",
1562
+ [settlement.funder]: "payer",
1563
+ },
1564
+ desc: `Advance: the ${funderWords} disburses ${amountName} to the ${advancedWords} and is repaid out of the ${holdWords} release, which pays the ${funderWords} in the ${advancedWords}'s place${hasFee ? ", plus the funder's discount" : ""}`,
1565
+ fields,
1566
+ id: noun,
1567
+ ...partitionsSpread(hasFee
1568
+ ? partitionClause("repayableAmount", [amountName, "feeAmount"])
1569
+ : []),
1570
+ summary: `Advance to the ${advancedWords} repaid by carving the ${holdWords} release`,
1571
+ title: titleize(noun),
1572
+ verbs: {
1573
+ create: {
1574
+ summary: `Create a ${titleize(noun).toLowerCase()}`,
1575
+ to: "created",
1576
+ },
1577
+ disburse: {
1578
+ from: ["created"],
1579
+ moneyEvent: `${noun}_disburse`,
1580
+ moves: [
1581
+ {
1582
+ key: "transfer",
1583
+ operation: "create",
1584
+ amount: amountName,
1585
+ from: settlement.funder,
1586
+ to: settlement.advanced,
1587
+ },
1588
+ ],
1589
+ summary: `Disburse the ${amountName} to the ${advancedWords}`,
1590
+ to: "advanced",
1591
+ },
1592
+ // Moneyless by construction: the repayment already moved, on the hold.
1593
+ // This verb only records that it did, so the advance has a close
1594
+ // instead of resting forever in the state it was disbursed in.
1595
+ settle: {
1596
+ from: ["advanced"],
1597
+ summary: `Close the advance once the ${holdWords} has released to the ${funderWords}`,
1598
+ to: "repaid",
1599
+ },
1600
+ },
1601
+ },
1602
+ rules: [
1603
+ {
1604
+ allowedActors: [],
1605
+ detail: `The ${holdWords} releases the ${advancedWords}'s whole share to the ${funderWords} instead of to the ${advancedWords}; the advance is repaid out of that release and never out of new money`,
1606
+ dueDriven: false,
1607
+ enforcement: "platform",
1608
+ gatesEvent: null,
1609
+ key: `${noun}_carve`,
1610
+ kind: "release_condition",
1611
+ label: `Repaid by carving the ${holdWords} release`,
1612
+ tenantTunable: false,
1613
+ },
1614
+ ],
1615
+ settlement: { name: noun, pieces: [] },
1616
+ };
1617
+ }
1618
+ function lowerScheduledAdvance(settlement, schedule) {
1619
+ const noun = settlement.name;
1620
+ const amountName = settlement.amount.name;
1621
+ const ruleKey = `${noun}_schedule`;
1622
+ const widths = evenPieceBps(schedule.count);
1623
+ const hasFee = settlement.feeBps > 0;
1624
+ const repayableField = hasFee ? "repayableAmount" : amountName;
1625
+ const fields = {
1626
+ [amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${settlement.advanced.replaceAll("_", " ")} up front`),
1627
+ ...(hasFee
1628
+ ? {
1629
+ feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount repaid on top of the advance`),
1630
+ repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: the total the repayment fields below partition exactly`),
1631
+ }
1632
+ : {}),
1633
+ [schedule.firstDueField]: dateFieldSpec(`Due date of the first repayment; repayment k falls ${schedule.every.raw} after its predecessor`),
1634
+ };
1635
+ const repaymentFields = widths.map((_, index) => {
1636
+ const field = `repayment${index + 1}Amount`;
1637
+ fields[field] = moneyFieldSpec(`Repayment ${index + 1} of ${schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}: about ${formatBps(widths[index])} of ${repayableField}, collected on its own stored-date anchor`);
1638
+ return field;
1639
+ });
1640
+ const repayVerbs = widths.map((_, index) => `collect_repayment_${index + 1}`);
1641
+ const repayStates = chain(repayVerbs, "advanced", "repaid", "repaying");
1642
+ const events = [
1643
+ mintEvent({
1644
+ amount: `The full ${amountName}`,
1645
+ fromActor: settlement.funder,
1646
+ key: `${noun}_disburse`,
1647
+ kind: "payout",
1648
+ toActor: settlement.advanced,
1649
+ trigger: `Disburse the advance to the ${settlement.advanced.replaceAll("_", " ")}`,
1650
+ }),
1651
+ ];
1652
+ const verbs = {
1653
+ create: {
1654
+ summary: `Create a ${titleize(noun).toLowerCase()}`,
1655
+ to: "created",
1656
+ },
1657
+ disburse: {
1658
+ from: ["created"],
1659
+ moneyEvent: `${noun}_disburse`,
1660
+ moves: [
1661
+ {
1662
+ key: "transfer",
1663
+ operation: "create",
1664
+ amount: amountName,
1665
+ from: settlement.funder,
1666
+ to: settlement.advanced,
1667
+ },
1668
+ ],
1669
+ summary: `Disburse the ${amountName} to the ${settlement.advanced.replaceAll("_", " ")}`,
1670
+ to: "advanced",
1671
+ },
1672
+ };
1673
+ // One repeatable event for the whole repayment schedule (see lowerScheduled:
1674
+ // the budget counts money behaviors, not anchors).
1675
+ const repayEventKey = `${noun}_repayments`;
1676
+ events.push(mintEvent({
1677
+ amount: `The ${repayableField}, partitioned into ${schedule.count} repayments`,
1678
+ fromActor: settlement.advanced,
1679
+ key: repayEventKey,
1680
+ kind: "installment",
1681
+ occurrence: "repeatable",
1682
+ toActor: settlement.funder,
1683
+ trigger: `Collect each of the ${schedule.count} repayments on its stored due date`,
1684
+ }));
1685
+ for (const [index, field] of repaymentFields.entries()) {
1686
+ const eventKey = repayEventKey;
1687
+ verbs[repayVerbs[index]] = {
1688
+ due: {
1689
+ field: schedule.firstDueField,
1690
+ rule: ruleKey,
1691
+ ...anchorOffset(schedule, index),
1692
+ },
1693
+ from: [repayStates[index]?.from],
1694
+ moneyEvent: eventKey,
1695
+ moves: [
1696
+ {
1697
+ key: "transfer",
1698
+ operation: "create",
1699
+ amount: field,
1700
+ from: settlement.advanced,
1701
+ to: settlement.funder,
1702
+ },
1703
+ ],
1704
+ summary: `Collect repayment ${index + 1} of ${schedule.count}`,
1705
+ to: repayStates[index]?.to,
1706
+ };
1707
+ }
1708
+ return {
1709
+ design: [
1710
+ `${noun}: ${amountName} advanced up front; ${schedule.count} repayments every ${schedule.every.raw} conserve against ${repayableField}${hasFee ? ` (advance + ${formatBps(settlement.feeBps)} fee)` : ""}`,
1711
+ ],
1712
+ feeLines: hasFee
1713
+ ? [
1714
+ {
1715
+ label: `${titleize(settlement.funder)} discount`,
1716
+ on: `each ${noun.replaceAll("_", " ")}`,
1717
+ structure: `${formatBps(settlement.feeBps)} of the ${amountName}, repaid on top of the advance`,
1718
+ },
1719
+ ]
1720
+ : [],
1721
+ moneyEvents: events,
1722
+ noun: {
1723
+ actors: {
1724
+ [settlement.funder]: "payer",
1725
+ [settlement.advanced]: "beneficiary",
1726
+ },
1727
+ desc: `Advance: the ${settlement.funder.replaceAll("_", " ")} disburses ${amountName} to the ${settlement.advanced.replaceAll("_", " ")}, repaid over ${schedule.count} scheduled repayments${hasFee ? " plus the funder's discount" : ""}`,
1728
+ fields,
1729
+ id: noun,
1730
+ ...partitionsSpread([
1731
+ ...partitionClause(repayableField, repaymentFields),
1732
+ ...(hasFee
1733
+ ? partitionClause("repayableAmount", [amountName, "feeAmount"])
1734
+ : []),
1735
+ ]),
1736
+ summary: `Advance to ${settlement.advanced.replaceAll("_", " ")} repaid over ${schedule.count} anchors`,
1737
+ title: titleize(noun),
1738
+ verbs,
1739
+ },
1740
+ rules: [
1741
+ {
1742
+ allowedActors: [],
1743
+ detail: `Each of the ${schedule.count} repayments is collected once from its stored due date`,
1744
+ dueDriven: true,
1745
+ enforcement: "platform",
1746
+ gatesEvent: null,
1747
+ key: ruleKey,
1748
+ kind: "deadline",
1749
+ label: "Repayments collected on their stored due dates",
1750
+ tenantTunable: false,
1751
+ },
1752
+ ],
1753
+ settlement: { name: noun, pieces: [] },
1754
+ };
1755
+ }
1756
+ // ---------------------------------------------------------------------------
1757
+ // metered: each usage charge IS the ledger transfer
1758
+ function lowerMetered(settlement) {
1759
+ const noun = settlement.name;
1760
+ const ruleKey = `${noun}_period`;
1761
+ const currency = settlement.rates[0]?.field.currency ?? "SAR";
1762
+ const fields = {
1763
+ [settlement.closeByField]: dateFieldSpec("End of this metering period; the close makes further charges unreachable"),
1764
+ };
1765
+ for (const rate of settlement.rates) {
1766
+ fields[rate.field.name] = moneyFieldSpec(`Per-unit price of ${rate.meter.replaceAll("_", " ")} in ${currency} minor units, committed at period open`);
1767
+ }
1768
+ const events = [];
1769
+ const verbs = {
1770
+ close_period: {
1771
+ due: { field: settlement.closeByField, rule: ruleKey },
1772
+ from: ["open"],
1773
+ summary: "Close the metering period; no further usage can be charged",
1774
+ to: "closed",
1775
+ },
1776
+ create: {
1777
+ summary: `Open a ${titleize(noun).toLowerCase()} period with its committed rate card`,
1778
+ to: "open",
1779
+ },
1780
+ };
1781
+ for (const rate of settlement.rates) {
1782
+ const eventKey = frameKey(`${noun}_${rate.meter}`);
1783
+ events.push(mintEvent({
1784
+ amount: `The committed ${rate.field.name} per unit`,
1785
+ fromActor: settlement.payer,
1786
+ key: eventKey,
1787
+ kind: "charge",
1788
+ occurrence: "repeatable",
1789
+ timing: "external_schedule",
1790
+ toActor: settlement.payee,
1791
+ trigger: `Charge one ${rate.meter.replaceAll("_", " ")} at the committed rate`,
1792
+ }));
1793
+ verbs[`charge_${rate.meter}`] = {
1794
+ from: ["open"],
1795
+ moneyEvent: eventKey,
1796
+ moves: [
1797
+ {
1798
+ key: "transfer",
1799
+ operation: "create",
1800
+ amount: rate.field.name,
1801
+ from: settlement.payer,
1802
+ to: settlement.payee,
1803
+ },
1804
+ ],
1805
+ summary: `Charge one metered ${rate.meter.replaceAll("_", " ")}; the emission is the transfer itself`,
1806
+ to: "open",
1807
+ };
1808
+ }
1809
+ return {
1810
+ design: [
1811
+ `${noun}: committed rate card (${settlement.rates
1812
+ .map((rate) => rate.meter)
1813
+ .join(", ")}); each usage charge IS the ledger transfer; period closes on ${settlement.closeByField}`,
1814
+ ],
1815
+ feeLines: [],
1816
+ moneyEvents: events,
1817
+ noun: {
1818
+ actors: {
1819
+ [settlement.payer]: "payer",
1820
+ [settlement.payee]: "beneficiary",
1821
+ },
1822
+ desc: `Metered usage: the ${settlement.payer.replaceAll("_", " ")} is charged per unit at the committed rate card until the period closes on its stored end date`,
1823
+ fields,
1824
+ id: noun,
1825
+ summary: `Metered charges from ${settlement.payer.replaceAll("_", " ")} on a committed rate card`,
1826
+ title: titleize(noun),
1827
+ verbs,
1828
+ },
1829
+ rules: [
1830
+ {
1831
+ allowedActors: [],
1832
+ detail: "The period closes once from its stored end date",
1833
+ dueDriven: true,
1834
+ enforcement: "platform",
1835
+ gatesEvent: null,
1836
+ key: ruleKey,
1837
+ kind: "deadline",
1838
+ label: "Period closed on its stored end date",
1839
+ tenantTunable: false,
1840
+ },
1841
+ ],
1842
+ settlement: { name: noun, pieces: [] },
1843
+ };
1844
+ }
1845
+ // ---------------------------------------------------------------------------
1846
+ // pooled_split: pool a period total piece-wise, distribute it exactly
1847
+ function lowerPooledSplit(settlement) {
1848
+ const noun = settlement.name;
1849
+ const amountName = settlement.amount.name;
1850
+ const ruleKey = `${noun}_payout`;
1851
+ const remainderIndex = settlement.shares.findIndex((share) => share.to === settlement.remainderTo);
1852
+ const pieces = settlement.shares.map((share) => ({
1853
+ bps: share.bps,
1854
+ field: `${camelize(share.to)}ShareAmount`,
1855
+ origin: share.origin,
1856
+ releaseTo: share.to,
1857
+ }));
1858
+ const fields = {
1859
+ [amountName]: moneyFieldSpec(`The pooled period total in ${settlement.amount.currency} minor units; the share fields below partition it exactly`),
1860
+ [settlement.distributeDueField]: dateFieldSpec("The period's payout date; the pool distributes from it"),
1861
+ };
1862
+ for (const [index, piece] of pieces.entries()) {
1863
+ const remainder = index === Math.max(remainderIndex, 0)
1864
+ ? " (carries the integer-division remainder)"
1865
+ : "";
1866
+ fields[piece.field] = moneyFieldSpec(`${formatBps(piece.bps)} of ${amountName}${remainder}: the ${piece.releaseTo.replaceAll("_", " ")}'s share. Computed as floor(${amountName} * ${piece.bps} / 10000) in ${settlement.amount.currency} minor units`);
1867
+ }
1868
+ const fundVerbs = pieces.map((_, index) => `fund_share_${index + 1}`);
1869
+ const payoutVerbs = pieces.map((_, index) => `distribute_share_${index + 1}`);
1870
+ const fundStates = chain(fundVerbs, "created", "pooled", "pooling");
1871
+ const payoutStates = chain(payoutVerbs, "pooled", "distributed", "distributing");
1872
+ const events = [];
1873
+ const verbs = {
1874
+ create: {
1875
+ summary: `Open a ${titleize(noun).toLowerCase()} period`,
1876
+ to: "created",
1877
+ },
1878
+ };
1879
+ for (const [index, piece] of pieces.entries()) {
1880
+ const eventKey = `${noun}_pool_${index + 1}`;
1881
+ events.push(mintEvent({
1882
+ amount: `${formatBps(piece.bps)} of the ${amountName}`,
1883
+ fromActor: settlement.payer,
1884
+ key: eventKey,
1885
+ kind: "charge",
1886
+ toActor: "escrow",
1887
+ trigger: `Pool the ${piece.releaseTo.replaceAll("_", " ")}'s share for the period`,
1888
+ }));
1889
+ verbs[fundVerbs[index]] = {
1890
+ from: [fundStates[index]?.from],
1891
+ moneyEvent: eventKey,
1892
+ moves: [
1893
+ {
1894
+ key: "transfer",
1895
+ operation: "create",
1896
+ amount: piece.field,
1897
+ from: settlement.payer,
1898
+ to: "escrow",
1899
+ },
1900
+ ],
1901
+ summary: `Pool share ${index + 1} of the period total`,
1902
+ to: fundStates[index]?.to,
1903
+ };
1904
+ }
1905
+ for (const [index, piece] of pieces.entries()) {
1906
+ const eventKey = `${noun}_payout_${index + 1}`;
1907
+ events.push(mintEvent({
1908
+ amount: `${formatBps(piece.bps)} of the ${amountName}`,
1909
+ fromActor: "escrow",
1910
+ key: eventKey,
1911
+ kind: "payout",
1912
+ toActor: piece.releaseTo,
1913
+ trigger: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share on the payout date`,
1914
+ }));
1915
+ verbs[payoutVerbs[index]] = {
1916
+ due: { field: settlement.distributeDueField, rule: ruleKey },
1917
+ from: [payoutStates[index]?.from],
1918
+ moneyEvent: eventKey,
1919
+ moves: [
1920
+ {
1921
+ key: "transfer",
1922
+ operation: "create",
1923
+ amount: piece.field,
1924
+ from: "escrow",
1925
+ to: piece.releaseTo,
1926
+ },
1927
+ ],
1928
+ summary: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share of the pool`,
1929
+ to: payoutStates[index]?.to,
1930
+ };
1931
+ }
1932
+ return {
1933
+ design: [
1934
+ `${noun}: pool of ${amountName} partitioned ${pieces
1935
+ .map((piece) => `${formatBps(piece.bps)} ${piece.releaseTo}`)
1936
+ .join(" + ")}; distributes in full on ${settlement.distributeDueField}; remainder to ${settlement.remainderTo}`,
1937
+ ],
1938
+ feeLines: [],
1939
+ moneyEvents: events,
1940
+ noun: {
1941
+ actors: {
1942
+ [settlement.payer]: "payer",
1943
+ ...Object.fromEntries(settlement.shares.map((share) => [share.to, "beneficiary"])),
1944
+ },
1945
+ desc: `Pooled split: the ${settlement.payer.replaceAll("_", " ")} pools the period's ${amountName} share by share; the pool distributes to every recipient in full on the stored payout date`,
1946
+ escrow: true,
1947
+ fields,
1948
+ id: noun,
1949
+ ...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
1950
+ summary: `Period pool from ${settlement.payer.replaceAll("_", " ")} split ${settlement.shares.length} ways`,
1951
+ title: titleize(noun),
1952
+ verbs,
1953
+ },
1954
+ rules: [
1955
+ {
1956
+ allowedActors: [],
1957
+ detail: "Every share of the pool distributes once from the stored payout date",
1958
+ dueDriven: true,
1959
+ enforcement: "platform",
1960
+ gatesEvent: null,
1961
+ key: ruleKey,
1962
+ kind: "deadline",
1963
+ label: "Pool distributed on its stored payout date",
1964
+ tenantTunable: false,
1965
+ },
1966
+ ],
1967
+ settlement: { name: noun, pieces },
1968
+ };
1969
+ }
1970
+ /**
1971
+ * The finest common partition of the amount across both exits. Cut points
1972
+ * come from the release allocation (payee share, then the payee-side fee to
1973
+ * the platform) and the cancellation split; every resulting interval becomes
1974
+ * one piece with a fixed destination per exit.
1975
+ *
1976
+ * A carve changes only WHO the payee's share is released to. It is not a cut
1977
+ * point: the funder takes the payee's whole share, so a carved hold has the
1978
+ * same pieces as an uncarved one and the platform's fee is untouched.
1979
+ */
1980
+ function partitionPieces(input) {
1981
+ const total = Number(TOTAL_BPS);
1982
+ const releaseTo = input.carveTo ?? input.payee;
1983
+ const release = [];
1984
+ if (input.payeeFeeBps < total) {
1985
+ release.push({ end: total - input.payeeFeeBps, to: releaseTo });
1986
+ }
1987
+ if (input.payeeFeeBps > 0)
1988
+ release.push({ end: total, to: "platform" });
1989
+ const cancel = [];
1990
+ let cumulative = 0;
1991
+ for (const share of input.onCancel?.shares ?? []) {
1992
+ cumulative += share.bps;
1993
+ cancel.push({ end: cumulative, origin: share.origin, to: share.to });
1994
+ }
1995
+ const cuts = [
1996
+ ...new Set([
1997
+ ...release.map((segment) => segment.end),
1998
+ ...cancel.map((segment) => segment.end),
1999
+ total,
2000
+ ]),
2001
+ ].sort((left, right) => left - right);
2002
+ const destinationAt = (segments, start) => segments.find((segment) => start < segment.end);
2003
+ const pieces = [];
2004
+ let start = 0;
2005
+ for (const cut of cuts) {
2006
+ if (cut <= start)
2007
+ continue;
2008
+ const releaseSegment = destinationAt(release, start);
2009
+ const cancelSegment = destinationAt(cancel, start);
2010
+ pieces.push({
2011
+ bps: cut - start,
2012
+ ...(cancelSegment ? { cancelTo: cancelSegment.to } : {}),
2013
+ field: `piece${pieces.length + 1}Amount`,
2014
+ origin: cancelSegment?.origin ?? input.amount.origin,
2015
+ releaseTo: releaseSegment?.to ?? releaseTo,
2016
+ });
2017
+ start = cut;
2018
+ }
2019
+ return pieces;
2020
+ }
2021
+ function pieceDescription(piece, index, amountName, currency) {
2022
+ const cancelLeg = piece.cancelTo
2023
+ ? `; on cancellation to the ${piece.cancelTo.replaceAll("_", " ")}`
2024
+ : "";
2025
+ const remainder = index === 0 ? " (carries the integer-division remainder)" : "";
2026
+ return `${formatBps(piece.bps)} of ${amountName}${remainder}: released to the ${piece.releaseTo.replaceAll("_", " ")}${cancelLeg}. Computed as floor(${amountName} * ${piece.bps} / 10000) in ${currency} minor units`;
2027
+ }
2028
+ function formatBps(bps) {
2029
+ const percent = bps / 100;
2030
+ return `${Number.isInteger(percent) ? percent : percent.toFixed(2).replace(/0$/, "")}%`;
2031
+ }
2032
+ function summarize(program) {
2033
+ const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
2034
+ ? [[settlement.source.settlement, settlement.funder]]
2035
+ : []));
2036
+ const lines = program.settlements.map((settlement) => {
2037
+ switch (settlement.archetype) {
2038
+ case "held_payment": {
2039
+ const cancel = settlement.onCancel
2040
+ ? `; cancellation splits the held amount ${settlement.onCancel.shares
2041
+ .map((share) => `${formatBps(share.bps)} to the ${share.to.replaceAll("_", " ")}`)
2042
+ .join(" and ")}`
2043
+ : "";
2044
+ const carveTo = carveFunderByHold.get(settlement.name);
2045
+ const paid = carveTo
2046
+ ? `the ${carveTo.replaceAll("_", " ")} is paid on confirmed release, in the ${settlement.payee.replaceAll("_", " ")}'s place`
2047
+ : `the ${settlement.payee.replaceAll("_", " ")} is paid on confirmed release`;
2048
+ return `The ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into escrow and ${paid}${cancel}`;
2049
+ }
2050
+ case "instant_transfer":
2051
+ return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} straight through to the ${settlement.payee.replaceAll("_", " ")}`;
2052
+ case "premium_forward":
2053
+ return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on binding`;
2054
+ case "deposit":
2055
+ return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved for the ${settlement.holder.replaceAll("_", " ")} until claimed or returned`;
2056
+ case "scheduled":
2057
+ return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
2058
+ case "advance":
2059
+ return settlement.source.kind === "carve"
2060
+ ? `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid out of the ${settlement.source.settlement.replaceAll("_", " ")} release`
2061
+ : `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid over ${settlement.source.schedule.count} anchors`;
2062
+ case "metered":
2063
+ return `The ${settlement.payer.replaceAll("_", " ")} is charged per metered unit at a committed rate card until the period closes`;
2064
+ case "pooled_split":
2065
+ return `The ${settlement.payer.replaceAll("_", " ")} pools ${settlement.amount.name} and it distributes ${settlement.shares.length} ways on the payout date`;
2066
+ case "swap":
2067
+ return `The ${settlement.sides[0].party.replaceAll("_", " ")} and ${settlement.sides[1].party.replaceAll("_", " ")} fund one shared escrow and the entire two-sided trade releases or reverses together`;
2068
+ }
2069
+ });
2070
+ return `${lines.join(". ")}.`.slice(0, 400);
2071
+ }
2072
+ function camelize(snake) {
2073
+ const [head, ...rest] = snake.split("_");
2074
+ return ((head ?? "") +
2075
+ rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(""));
2076
+ }
2077
+ function titleize(snake) {
2078
+ const spaced = snake.replaceAll("_", " ");
2079
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
2080
+ }
2081
+ //# sourceMappingURL=lower.js.map