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