@hyperscale0/hsx 1.0.0-alpha.4 → 1.0.0-alpha.5

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.
package/src/lower.ts CHANGED
@@ -31,10 +31,16 @@
31
31
  */
32
32
 
33
33
  import type { Span } from "./ast.ts";
34
+ import { ARCHETYPE_DEFINITIONS } from "./archetypes.ts";
34
35
  import type {
35
36
  CancelPolicy,
36
37
  CheckedAdvance,
38
+ CheckedCaptureReservation,
39
+ CheckedConditionalDisbursement,
40
+ CheckedCreditFacility,
37
41
  CheckedDeposit,
42
+ CheckedDerivedAmount,
43
+ CheckedFundingRound,
38
44
  CheckedHeldPayment,
39
45
  CheckedInstantTransfer,
40
46
  CheckedMetered,
@@ -42,9 +48,14 @@ import type {
42
48
  CheckedPort,
43
49
  CheckedPremiumForward,
44
50
  CheckedProgram,
51
+ CheckedRecurringCollection,
52
+ CheckedRotatingPool,
45
53
  CheckedScheduled,
54
+ CheckedScheduledObligation,
46
55
  CheckedSettlement,
56
+ CheckedSettlementBatch,
47
57
  CheckedSwap,
58
+ CheckedWeightedDistribution,
48
59
  MoneyField,
49
60
  ScheduleTerms,
50
61
  } from "./model.ts";
@@ -55,11 +66,13 @@ type Json = Record<string, unknown>;
55
66
  /**
56
67
  * The most money events one program may mint. The Business Frame contract caps
57
68
  * 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.
69
+ * against each other, so neither can drift alone. A repeatable schedule costs
70
+ * one event declaration. Fee legs, cancellation legs, refunds, and forwards
71
+ * each count when they emit their own event declaration.
60
72
  */
61
- export const MONEY_EVENT_BUDGET = 14;
73
+ export const MONEY_EVENT_BUDGET = 20;
62
74
 
75
+ const PUBLIC_INTENT_BUDGET = 48;
63
76
  const TOTAL_BPS = 10_000n;
64
77
 
65
78
  interface LoweredPiece {
@@ -82,7 +95,7 @@ interface LoweredSettlement {
82
95
  readonly serviceFee?: { readonly bps: number; readonly field: string };
83
96
  }
84
97
 
85
- interface LoweringIssue {
98
+ export interface LoweringIssue {
86
99
  readonly message: string;
87
100
  readonly span: Span;
88
101
  }
@@ -132,12 +145,124 @@ interface LoweredNoun {
132
145
  readonly feeLines: readonly Json[];
133
146
  readonly moneyEvents: readonly Json[];
134
147
  readonly noun: Json;
148
+ readonly extraNouns?: readonly Json[];
149
+ readonly generatedPrefixNounIds?: readonly string[];
150
+ readonly repeatableCounterparty?: RepeatableCounterpartyRole;
135
151
  readonly rules: readonly Json[];
136
152
  readonly settlement: LoweredSettlement;
137
153
  }
138
154
 
139
- interface EventSpec {
155
+ export type FrameActorRole =
156
+ | "beneficiary"
157
+ | "guardian"
158
+ | "holder"
159
+ | "payer"
160
+ | "provider";
161
+
162
+ /** One role whose account endpoint repeats within one settlement. */
163
+ export interface RepeatableCounterpartyRole {
164
+ readonly key: string;
165
+ readonly label: string;
166
+ readonly maxCount: number;
167
+ readonly minCount: number;
168
+ readonly origin: Span;
169
+ readonly role: FrameActorRole;
170
+ }
171
+
172
+ export type AmountDependencyExpression =
173
+ | {
174
+ readonly kind: "bounded_by_reference";
175
+ readonly reference: string;
176
+ }
177
+ | {
178
+ readonly kind: "net_of_offsets";
179
+ readonly offsets: readonly string[];
180
+ readonly source: string;
181
+ }
182
+ | {
183
+ readonly bps: number;
184
+ readonly kind: "percent_of_reference";
185
+ readonly reference: string;
186
+ }
187
+ | {
188
+ readonly consumed: readonly string[];
189
+ readonly kind: "remainder";
190
+ readonly source: string;
191
+ };
192
+
193
+ interface EventAmountFields {
194
+ readonly amountDependencies: readonly string[];
195
+ readonly amountMode: "fixed" | "remaining_balance" | "runtime_bounded";
196
+ }
197
+
198
+ /** Lower one arithmetic dependency into the Business Frame's existing keys. */
199
+ export function lowerAmountDependency(
200
+ expression?: AmountDependencyExpression,
201
+ ): EventAmountFields {
202
+ if (!expression) return { amountDependencies: [], amountMode: "fixed" };
203
+ switch (expression.kind) {
204
+ case "bounded_by_reference":
205
+ return {
206
+ amountDependencies: [frameKey(expression.reference)],
207
+ amountMode: "runtime_bounded",
208
+ };
209
+ case "net_of_offsets": {
210
+ if (expression.offsets.length === 0) {
211
+ throw new Error("net_of_offsets requires at least one offset event");
212
+ }
213
+ const dependencies = canonicalDependencies(
214
+ expression.source,
215
+ expression.offsets,
216
+ );
217
+ return {
218
+ amountDependencies: dependencies,
219
+ amountMode: "runtime_bounded",
220
+ };
221
+ }
222
+ case "percent_of_reference":
223
+ if (
224
+ !Number.isInteger(expression.bps) ||
225
+ expression.bps <= 0 ||
226
+ expression.bps > 10_000
227
+ ) {
228
+ throw new Error(
229
+ "percent_of_reference bps must be an integer between 1 and 10000",
230
+ );
231
+ }
232
+ return {
233
+ amountDependencies: [frameKey(expression.reference)],
234
+ amountMode: "runtime_bounded",
235
+ };
236
+ case "remainder": {
237
+ if (expression.consumed.length === 0) {
238
+ throw new Error("remainder requires at least one consumed event");
239
+ }
240
+ const dependencies = canonicalDependencies(
241
+ expression.source,
242
+ expression.consumed,
243
+ );
244
+ return {
245
+ amountDependencies: dependencies,
246
+ amountMode: "remaining_balance",
247
+ };
248
+ }
249
+ }
250
+ }
251
+
252
+ function canonicalDependencies(
253
+ source: string,
254
+ dependents: readonly string[],
255
+ ): readonly string[] {
256
+ const all = [source, ...dependents].map(frameKey);
257
+ if (new Set(all).size !== all.length) {
258
+ throw new Error("amount dependency event keys must be distinct");
259
+ }
260
+ return all;
261
+ }
262
+
263
+ export interface EventSpec {
140
264
  readonly amount: string;
265
+ readonly amountDependency?: AmountDependencyExpression;
141
266
  readonly fromActor: string;
142
267
  readonly key: string;
143
268
  readonly kind: string;
@@ -212,12 +337,26 @@ function frameKey(key: string): string {
212
337
  return `${key.slice(0, 33)}_${(hash >>> 0).toString(36).slice(0, 6)}`;
213
338
  }
214
339
 
215
- function mintEvent(spec: EventSpec): Json {
340
+ function dependentAmountDescription(spec: EventSpec): string {
341
+ const expression = spec.amountDependency;
342
+ if (!expression) return spec.amount;
343
+ switch (expression.kind) {
344
+ case "bounded_by_reference":
345
+ return `Bounded by ${expression.reference}: ${spec.amount}`;
346
+ case "net_of_offsets":
347
+ return `Net of ${expression.source} after ${expression.offsets.join(", ")}: ${spec.amount}`;
348
+ case "percent_of_reference":
349
+ return `${formatBps(expression.bps)} of ${expression.reference}: ${spec.amount}`;
350
+ case "remainder":
351
+ return `Remainder of ${expression.source} after ${expression.consumed.join(", ")}: ${spec.amount}`;
352
+ }
353
+ }
354
+
355
+ export function mintEvent(spec: EventSpec): Json {
216
356
  return {
217
357
  allocationTotalBps: 0,
218
- amount: spec.amount,
219
- amountDependencies: [],
220
- amountMode: "fixed",
358
+ amount: dependentAmountDescription(spec),
359
+ ...lowerAmountDependency(spec.amountDependency),
221
360
  amountSchedule: [],
222
361
  distribution: "single",
223
362
  fromActor: spec.fromActor,
@@ -285,6 +424,48 @@ function dateFieldSpec(desc: string): Json {
285
424
  return { desc, type: "date" };
286
425
  }
287
426
 
427
+ function optionalDateFieldSpec(desc: string): Json {
428
+ return { desc, type: "date?" };
429
+ }
430
+
431
+ function derivedNounPrefix(noun: Json): string {
432
+ if (typeof noun.prefix === "string") return noun.prefix;
433
+ const id = noun.id as string;
434
+ const words = id.split("_");
435
+ const derived =
436
+ words.length > 1 ? words.map((word) => word[0]).join("") : id.slice(0, 4);
437
+ return derived.slice(0, 8).padEnd(2, "x");
438
+ }
439
+
440
+ function allocatedGeneratedPrefixes(
441
+ nouns: readonly Json[],
442
+ generatedIds: ReadonlySet<string>,
443
+ ): Json[] {
444
+ const used = new Set(
445
+ nouns
446
+ .filter((noun) => !generatedIds.has(noun.id as string))
447
+ .map(derivedNounPrefix),
448
+ );
449
+ let ordinal = 0;
450
+ const nextPrefix = (): string => {
451
+ while (true) {
452
+ const high = String.fromCharCode(97 + Math.floor(ordinal / 26));
453
+ const low = String.fromCharCode(97 + (ordinal % 26));
454
+ ordinal += 1;
455
+ const candidate = `zz${high}${low}`;
456
+ if (!used.has(candidate)) {
457
+ used.add(candidate);
458
+ return candidate;
459
+ }
460
+ }
461
+ };
462
+ return nouns.map((noun) =>
463
+ generatedIds.has(noun.id as string)
464
+ ? { ...noun, prefix: nextPrefix() }
465
+ : noun,
466
+ );
467
+ }
468
+
288
469
  // ---------------------------------------------------------------------------
289
470
  // The whole-program lowering
290
471
 
@@ -296,6 +477,8 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
296
477
  const rules: Json[] = [];
297
478
  const design: string[] = [];
298
479
  const feeLines: Json[] = [];
480
+ const repeatableCounterparties: RepeatableCounterpartyRole[] = [];
481
+ const generatedPrefixNounIds = new Set<string>();
299
482
  const mintedKeys = new Map<string, string>();
300
483
  const portsByName = new Map(program.ports.map((port) => [port.name, port]));
301
484
 
@@ -325,6 +508,33 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
325
508
  : [],
326
509
  ),
327
510
  );
511
+ const recoursesByAdvance = new Map(
512
+ program.settlements.flatMap((settlement) => {
513
+ if (
514
+ settlement.archetype !== "advance" ||
515
+ settlement.source.kind !== "carve"
516
+ ) {
517
+ return [];
518
+ }
519
+ const recourses = program.settlements.filter(
520
+ (candidate): candidate is CheckedScheduled =>
521
+ candidate.archetype === "scheduled" &&
522
+ candidate.mode === "transfer" &&
523
+ candidate.payer === settlement.advanced &&
524
+ candidate.payee === settlement.funder &&
525
+ candidate.amount.name === settlement.amount.name &&
526
+ candidate.amount.currency === settlement.amount.currency,
527
+ );
528
+ return [[settlement.name, recourses] as const];
529
+ }),
530
+ );
531
+ const collectionByObligation = new Map(
532
+ program.settlements.flatMap((settlement) =>
533
+ settlement.archetype === "recurring_collection"
534
+ ? [[settlement.obligation.settlement, settlement] as const]
535
+ : [],
536
+ ),
537
+ );
328
538
 
329
539
  for (const settlement of program.settlements) {
330
540
  let lowered: LoweredNoun | undefined;
@@ -344,14 +554,85 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
344
554
  );
345
555
  break;
346
556
  }
557
+ case "captured_payment": {
558
+ const correction = portFor(
559
+ settlement,
560
+ settlement.correction.port,
561
+ settlement.correction.origin,
562
+ );
563
+ const externalReversal = portFor(
564
+ settlement,
565
+ settlement.externalReversal.port,
566
+ settlement.externalReversal.origin,
567
+ );
568
+ if (!correction || !externalReversal) continue;
569
+ lowered = lowerCaptureReservation(
570
+ settlement,
571
+ correction,
572
+ externalReversal,
573
+ issues,
574
+ );
575
+ break;
576
+ }
577
+ case "settlement_batch": {
578
+ const acknowledgement = portFor(
579
+ settlement,
580
+ settlement.payoutAcknowledgement.port,
581
+ settlement.payoutAcknowledgement.origin,
582
+ );
583
+ if (!acknowledgement) continue;
584
+ lowered = lowerSettlementBatch(settlement, acknowledgement, issues);
585
+ break;
586
+ }
587
+ case "funding_round":
588
+ lowered = lowerFundingRound(settlement);
589
+ break;
590
+ case "weighted_distribution": {
591
+ const snapshot = portFor(
592
+ settlement,
593
+ settlement.snapshot.port,
594
+ settlement.snapshot.origin,
595
+ );
596
+ if (!snapshot) continue;
597
+ lowered = lowerWeightedDistribution(settlement, snapshot);
598
+ break;
599
+ }
600
+ case "credit_facility":
601
+ lowered = lowerCreditFacility(settlement);
602
+ break;
603
+ case "recurring_collection":
604
+ // The referenced scheduled obligation owns the payment nouns, amount
605
+ // allocation, and delinquency. Its lowerer adds the explicit mandate
606
+ // evidence gate, so this declaration mints no second noun or event.
607
+ continue;
608
+ case "conditional_disbursement": {
609
+ const decision = portFor(
610
+ settlement,
611
+ settlement.decision.port,
612
+ settlement.decision.origin,
613
+ );
614
+ if (!decision) continue;
615
+ lowered = lowerConditionalDisbursement(settlement, decision);
616
+ break;
617
+ }
618
+ case "rotating_pool":
619
+ lowered = lowerRotatingPool(settlement);
620
+ break;
347
621
  case "premium_forward": {
348
622
  const port = portFor(
349
623
  settlement,
350
624
  settlement.bind.port,
351
625
  settlement.bind.origin,
352
626
  );
353
- if (!port) continue;
354
- lowered = lowerPremiumForward(settlement, port, issues);
627
+ const endorsement = settlement.endorsement
628
+ ? portFor(
629
+ settlement,
630
+ settlement.endorsement.port,
631
+ settlement.endorsement.origin,
632
+ )
633
+ : undefined;
634
+ if (!port || (settlement.endorsement && !endorsement)) continue;
635
+ lowered = lowerPremiumForward(settlement, port, endorsement, issues);
355
636
  break;
356
637
  }
357
638
  case "deposit": {
@@ -373,10 +654,27 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
373
654
  lowered = lowerInstantTransfer(settlement);
374
655
  break;
375
656
  case "scheduled":
376
- lowered = lowerScheduled(settlement);
657
+ lowered =
658
+ settlement.mode === "obligation"
659
+ ? lowerScheduledObligation(
660
+ settlement,
661
+ collectionByObligation.get(settlement.name),
662
+ collectionByObligation.has(settlement.name)
663
+ ? portFor(
664
+ collectionByObligation.get(settlement.name)!,
665
+ collectionByObligation.get(settlement.name)!.mandate.port,
666
+ collectionByObligation.get(settlement.name)!.mandate
667
+ .origin,
668
+ )
669
+ : undefined,
670
+ )
671
+ : lowerScheduled(settlement);
377
672
  break;
378
673
  case "advance":
379
- lowered = lowerAdvance(settlement);
674
+ lowered = lowerAdvance(
675
+ settlement,
676
+ recoursesByAdvance.get(settlement.name) ?? [],
677
+ );
380
678
  break;
381
679
  case "metered":
382
680
  lowered = lowerMetered(settlement);
@@ -403,6 +701,23 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
403
701
  }
404
702
  }
405
703
  if (!lowered) continue;
704
+ const derivedAmounts = (program.derivedAmounts ?? []).filter(
705
+ (amount) => amount.settlement === settlement.name,
706
+ );
707
+ if (derivedAmounts.length > 0) {
708
+ lowered = addDerivedAmounts(lowered, settlement, derivedAmounts, issues);
709
+ if (!lowered) continue;
710
+ }
711
+ const localCap =
712
+ ARCHETYPE_DEFINITIONS[settlement.archetype].eventCap +
713
+ derivedAmounts.length;
714
+ if (lowered.moneyEvents.length > localCap) {
715
+ issues.push({
716
+ message: `settlement ${settlement.name} emits ${lowered.moneyEvents.length} money events, but ${settlement.archetype} carries a local cap of ${localCap}`,
717
+ span: settlement.origin,
718
+ });
719
+ continue;
720
+ }
406
721
  // Event and rule keys concatenate settlement names with generated stems,
407
722
  // so two settlements can mint the same key (a + b_service_fee vs a_b +
408
723
  // service_fee). The frame schema refuses duplicates wholesale, which
@@ -419,11 +734,38 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
419
734
  mintedKeys.set(key, settlement.name);
420
735
  }
421
736
  settlements.push(lowered.settlement);
422
- nouns.push(lowered.noun);
737
+ const loweredNouns = [lowered.noun, ...(lowered.extraNouns ?? [])];
738
+ for (const noun of loweredNouns) {
739
+ const verbs = noun.verbs as Record<string, Json>;
740
+ for (const [verbName, verb] of Object.entries(verbs)) {
741
+ if (
742
+ Object.hasOwn(verb, "due") ||
743
+ Object.hasOwn(verb, "requiresSettlement")
744
+ ) {
745
+ continue;
746
+ }
747
+ const publicIntent = callerDrivenPublicIntent(
748
+ noun.id as string,
749
+ verbName,
750
+ );
751
+ if (publicIntent.length <= PUBLIC_INTENT_BUDGET) continue;
752
+ issues.push({
753
+ message: `settlement ${settlement.name} generates public intent "${publicIntent}" with ${publicIntent.length} characters; rename the settlement so each public intent fits the ${PUBLIC_INTENT_BUDGET}-character camelName limit`,
754
+ span: settlement.origin,
755
+ });
756
+ }
757
+ }
758
+ nouns.push(...loweredNouns);
759
+ for (const nounId of lowered.generatedPrefixNounIds ?? []) {
760
+ generatedPrefixNounIds.add(nounId);
761
+ }
423
762
  moneyEvents.push(...lowered.moneyEvents);
424
763
  rules.push(...lowered.rules);
425
764
  design.push(...lowered.design);
426
765
  feeLines.push(...lowered.feeLines);
766
+ if (lowered.repeatableCounterparty) {
767
+ repeatableCounterparties.push(lowered.repeatableCounterparty);
768
+ }
427
769
  }
428
770
 
429
771
  if (moneyEvents.length > MONEY_EVENT_BUDGET) {
@@ -432,8 +774,20 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
432
774
  span: program.settlements[0]?.origin ?? { end: 0, start: 0 },
433
775
  });
434
776
  }
777
+ issues.push(
778
+ ...validateAmountDependencyGraph(
779
+ moneyEvents,
780
+ program.settlements[0]?.origin ?? { end: 0, start: 0 },
781
+ ),
782
+ );
783
+ const actorLowering = lowerFrameActors(program, repeatableCounterparties);
784
+ issues.push(...actorLowering.issues);
435
785
  if (issues.length > 0) return { issues, ok: false };
436
786
 
787
+ const publishedNouns = publishCallerDrivenVerbs(
788
+ allocatedGeneratedPrefixes(nouns, generatedPrefixNounIds),
789
+ );
790
+
437
791
  const subjects = program.assets.map((asset) => ({
438
792
  kind: asset.name,
439
793
  title: titleize(asset.name),
@@ -442,32 +796,14 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
442
796
 
443
797
  const document: Json = {
444
798
  hsx: HSX_IR_VERSION,
445
- nouns,
799
+ nouns: publishedNouns,
446
800
  product: program.name,
447
801
  ...(subjects.length > 0 ? { subjects } : {}),
448
802
  title: program.title,
449
803
  };
450
804
 
451
- const roles = partyRoles(program.settlements);
452
805
  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
- ],
806
+ actors: actorLowering.actors,
471
807
  confidence: "high",
472
808
  conservationGroups: [],
473
809
  design,
@@ -497,6 +833,293 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
497
833
  };
498
834
  }
499
835
 
836
+ function publishCallerDrivenVerbs(nouns: readonly Json[]): Json[] {
837
+ return nouns.map((noun) => {
838
+ const verbs = noun.verbs as Record<string, Json>;
839
+ return {
840
+ ...noun,
841
+ verbs: Object.fromEntries(
842
+ Object.entries(verbs).map(([verbName, verb]) => [
843
+ verbName,
844
+ Object.hasOwn(verb, "due") ||
845
+ Object.hasOwn(verb, "requiresSettlement")
846
+ ? verb
847
+ : {
848
+ ...verb,
849
+ publicIntent: callerDrivenPublicIntent(
850
+ noun.id as string,
851
+ verbName,
852
+ ),
853
+ },
854
+ ]),
855
+ ),
856
+ };
857
+ });
858
+ }
859
+
860
+ function callerDrivenPublicIntent(nounId: string, verbName: string): string {
861
+ const nounName = camelize(nounId);
862
+ const domainName = nounName.charAt(0).toUpperCase() + nounName.slice(1);
863
+ return `${camelize(verbName)}${domainName}`;
864
+ }
865
+
866
+ /** Add generic on-top amounts after archetype lowering, so no brick owns fee syntax. */
867
+ function addDerivedAmounts(
868
+ lowered: LoweredNoun,
869
+ settlement: CheckedSettlement,
870
+ amounts: readonly CheckedDerivedAmount[],
871
+ issues: LoweringIssue[],
872
+ ): LoweredNoun | undefined {
873
+ const noun = lowered.noun;
874
+ const fields = { ...((noun.fields as Json | undefined) ?? {}) };
875
+ const verbs = { ...((noun.verbs as Json | undefined) ?? {}) };
876
+ const create = { ...((verbs.create as Json | undefined) ?? {}) };
877
+ const moves = [...((create.moves as Json[] | undefined) ?? [])];
878
+ const actors = { ...((noun.actors as Json | undefined) ?? {}) };
879
+ const derived: Json[] = [];
880
+ const events = [...lowered.moneyEvents];
881
+ const lines = [...lowered.feeLines];
882
+ for (const amount of amounts) {
883
+ const sourceField = fields[amount.baseField] as Json | undefined;
884
+ if (sourceField === undefined || sourceField.type !== "money") {
885
+ issues.push({
886
+ message: `settlement ${settlement.name} derives ${amount.field} from ${sourceField === undefined ? "unknown " : "non-money "}field ${amount.baseField}; from must name a stored money field on the settlement owner`,
887
+ span: amount.origin,
888
+ });
889
+ return undefined;
890
+ }
891
+ if (fields[amount.field] !== undefined) {
892
+ issues.push({
893
+ message: `settlement ${settlement.name} derives into existing field ${amount.field}; choose a new derived amount field`,
894
+ span: amount.origin,
895
+ });
896
+ return undefined;
897
+ }
898
+ const eventKey = frameKey(`${settlement.name}_derived_amount`);
899
+ fields[amount.field] = moneyFieldSpec(
900
+ `Machine-computed ${formatBps(amount.bps)} of ${amount.baseField}; callers never supply it`,
901
+ );
902
+ if (actors[amount.bearer] === undefined) {
903
+ actors[amount.bearer] = "payer";
904
+ }
905
+ actors.platform = "beneficiary";
906
+ derived.push({
907
+ field: amount.field,
908
+ rounding: "floor",
909
+ rule: { bps: amount.bps, kind: "percentage_of" },
910
+ sourceField: amount.baseField,
911
+ });
912
+ moves.push({
913
+ amount: amount.field,
914
+ from: amount.bearer,
915
+ key: "derived_amount",
916
+ moneyEvent: eventKey,
917
+ operation: "create",
918
+ to: "platform",
919
+ });
920
+ events.push(
921
+ mintEvent({
922
+ amount: `The machine-computed ${amount.field}`,
923
+ fromActor: amount.bearer,
924
+ key: eventKey,
925
+ kind: "charge",
926
+ toActor: "platform",
927
+ trigger: `Collect ${amount.field} with settlement creation`,
928
+ }),
929
+ );
930
+ lines.push({
931
+ label: titleize(amount.field),
932
+ on: `each ${settlement.name.replaceAll("_", " ")}`,
933
+ structure: `${formatBps(amount.bps)} of stored ${amount.baseField}, computed by the runtime`,
934
+ });
935
+ }
936
+ create.moves = moves;
937
+ verbs.create = create;
938
+ return {
939
+ ...lowered,
940
+ design: [
941
+ ...lowered.design,
942
+ `${settlement.name}: derived amounts are machine-computed from stored source fields before create movements; fixed and tiered rules are refused`,
943
+ ],
944
+ feeLines: lines,
945
+ moneyEvents: events,
946
+ noun: {
947
+ ...noun,
948
+ actors,
949
+ derivedAmounts: derived,
950
+ fields,
951
+ verbs,
952
+ },
953
+ };
954
+ }
955
+
956
+ /** Lower fixed parties plus future settlement-declared repeating roles. */
957
+ export interface FrameActorLoweringResult {
958
+ readonly actors: readonly Record<string, unknown>[];
959
+ readonly issues: readonly LoweringIssue[];
960
+ }
961
+
962
+ export function lowerFrameActors(
963
+ program: Pick<CheckedProgram, "parties" | "settlements">,
964
+ repeatableCounterparties: readonly RepeatableCounterpartyRole[] = [],
965
+ ): FrameActorLoweringResult {
966
+ const roles = partyRoles(program.settlements);
967
+ const parties = new Set(program.parties.map((party) => party.name));
968
+ const overrides = new Map<string, RepeatableCounterpartyRole>();
969
+ const issues: LoweringIssue[] = [];
970
+ for (const counterparty of repeatableCounterparties) {
971
+ if (!parties.has(counterparty.key)) {
972
+ issues.push({
973
+ message: `repeatable counterparty ${counterparty.key} is not a declared party`,
974
+ span: counterparty.origin,
975
+ });
976
+ continue;
977
+ }
978
+ const fixedRole = roles.get(counterparty.key);
979
+ if (!fixedRole) {
980
+ issues.push({
981
+ message: `repeatable counterparty ${counterparty.key} is not used by any settlement`,
982
+ span: counterparty.origin,
983
+ });
984
+ continue;
985
+ }
986
+ if (fixedRole !== counterparty.role) {
987
+ issues.push({
988
+ message: `repeatable counterparty ${counterparty.key} declares role ${counterparty.role}, but its settlement uses role ${fixedRole}`,
989
+ span: counterparty.origin,
990
+ });
991
+ continue;
992
+ }
993
+ if (
994
+ counterparty.label.trim().length === 0 ||
995
+ counterparty.label.length > 160
996
+ ) {
997
+ issues.push({
998
+ message: `repeatable counterparty ${counterparty.key} label must contain 1 through 160 characters`,
999
+ span: counterparty.origin,
1000
+ });
1001
+ continue;
1002
+ }
1003
+ if (
1004
+ !Number.isInteger(counterparty.minCount) ||
1005
+ !Number.isInteger(counterparty.maxCount) ||
1006
+ counterparty.minCount < 1 ||
1007
+ counterparty.maxCount > 10_000
1008
+ ) {
1009
+ issues.push({
1010
+ message: `repeatable counterparty ${counterparty.key} counts must be integers from 1 through 10000`,
1011
+ span: counterparty.origin,
1012
+ });
1013
+ continue;
1014
+ }
1015
+ if (counterparty.minCount > counterparty.maxCount) {
1016
+ issues.push({
1017
+ message: `repeatable counterparty ${counterparty.key} has minCount ${counterparty.minCount} above maxCount ${counterparty.maxCount}`,
1018
+ span: counterparty.origin,
1019
+ });
1020
+ continue;
1021
+ }
1022
+ if (overrides.has(counterparty.key)) {
1023
+ issues.push({
1024
+ message: `repeatable counterparty ${counterparty.key} is declared twice`,
1025
+ span: counterparty.origin,
1026
+ });
1027
+ continue;
1028
+ }
1029
+ overrides.set(counterparty.key, counterparty);
1030
+ }
1031
+ const actors = [
1032
+ ...program.parties
1033
+ .filter((party) => roles.has(party.name))
1034
+ .map((party) => {
1035
+ const override = overrides.get(party.name);
1036
+ return override
1037
+ ? {
1038
+ key: override.key,
1039
+ label: override.label,
1040
+ maxCount: override.maxCount,
1041
+ minCount: override.minCount,
1042
+ role: override.role,
1043
+ }
1044
+ : {
1045
+ key: party.name,
1046
+ label: titleize(party.name),
1047
+ maxCount: 1,
1048
+ minCount: 1,
1049
+ role: roles.get(party.name),
1050
+ };
1051
+ }),
1052
+ {
1053
+ key: "platform",
1054
+ label: "Platform",
1055
+ maxCount: 1,
1056
+ minCount: 1,
1057
+ role: "platform",
1058
+ },
1059
+ ];
1060
+ return { actors, issues };
1061
+ }
1062
+
1063
+ /** Validate the completed event graph before HSX returns a frame. */
1064
+ export function validateAmountDependencyGraph(
1065
+ events: readonly Record<string, unknown>[],
1066
+ origin: Span,
1067
+ ): readonly LoweringIssue[] {
1068
+ const issues: LoweringIssue[] = [];
1069
+ const dependenciesByKey = new Map<string, readonly string[]>();
1070
+ for (const event of events) {
1071
+ if (typeof event.key !== "string") continue;
1072
+ const dependencies = Array.isArray(event.amountDependencies)
1073
+ ? event.amountDependencies.filter(
1074
+ (dependency): dependency is string => typeof dependency === "string",
1075
+ )
1076
+ : [];
1077
+ dependenciesByKey.set(event.key, dependencies);
1078
+ }
1079
+ for (const [key, dependencies] of dependenciesByKey) {
1080
+ for (const dependency of dependencies) {
1081
+ if (dependency === key) {
1082
+ issues.push({
1083
+ message: `money event ${key} cannot depend on itself`,
1084
+ span: origin,
1085
+ });
1086
+ continue;
1087
+ }
1088
+ if (!dependenciesByKey.has(dependency)) {
1089
+ issues.push({
1090
+ message: `money event ${key} depends on missing money event ${dependency}`,
1091
+ span: origin,
1092
+ });
1093
+ }
1094
+ }
1095
+ }
1096
+ const visiting = new Set<string>();
1097
+ const visited = new Set<string>();
1098
+ const cyclic = new Set<string>();
1099
+ const visit = (key: string): void => {
1100
+ if (visited.has(key) || cyclic.has(key)) return;
1101
+ if (visiting.has(key)) {
1102
+ cyclic.add(key);
1103
+ return;
1104
+ }
1105
+ visiting.add(key);
1106
+ for (const dependency of dependenciesByKey.get(key) ?? []) {
1107
+ if (dependenciesByKey.has(dependency)) visit(dependency);
1108
+ if (cyclic.has(dependency)) cyclic.add(key);
1109
+ }
1110
+ visiting.delete(key);
1111
+ visited.add(key);
1112
+ };
1113
+ for (const key of dependenciesByKey.keys()) visit(key);
1114
+ if (cyclic.size > 0) {
1115
+ issues.push({
1116
+ message: `money event amount dependencies contain a cycle through ${[...cyclic].sort().join(", ")}`,
1117
+ span: origin,
1118
+ });
1119
+ }
1120
+ return issues;
1121
+ }
1122
+
500
1123
  /** Frame actor role per party, with a fixed precedence when roles overlap. */
501
1124
  function partyRoles(
502
1125
  settlements: readonly CheckedSettlement[],
@@ -508,12 +1131,20 @@ function partyRoles(
508
1131
  for (const settlement of settlements) {
509
1132
  switch (settlement.archetype) {
510
1133
  case "held_payment":
1134
+ case "captured_payment":
511
1135
  case "instant_transfer":
512
- case "scheduled":
513
1136
  case "metered":
514
1137
  payers.add(settlement.payer);
515
1138
  beneficiaries.add(settlement.payee);
516
1139
  break;
1140
+ case "scheduled":
1141
+ payers.add(settlement.payer);
1142
+ beneficiaries.add(settlement.payee);
1143
+ if (settlement.mode === "obligation") {
1144
+ payers.add(settlement.debtor);
1145
+ if (settlement.advanceTo) beneficiaries.add(settlement.advanceTo);
1146
+ }
1147
+ break;
517
1148
  case "premium_forward":
518
1149
  payers.add(settlement.payer);
519
1150
  providers.add(settlement.carrier);
@@ -530,6 +1161,36 @@ function partyRoles(
530
1161
  payers.add(settlement.payer);
531
1162
  for (const share of settlement.shares) beneficiaries.add(share.to);
532
1163
  break;
1164
+ case "settlement_batch":
1165
+ payers.add(settlement.settlementAccount);
1166
+ beneficiaries.add(settlement.payoutDestination);
1167
+ break;
1168
+ case "funding_round":
1169
+ payers.add(settlement.contributor);
1170
+ beneficiaries.add(settlement.beneficiary);
1171
+ break;
1172
+ case "weighted_distribution":
1173
+ payers.add(settlement.source);
1174
+ beneficiaries.add(settlement.recipient);
1175
+ break;
1176
+ case "credit_facility":
1177
+ payers.add(settlement.lender);
1178
+ beneficiaries.add(settlement.borrower);
1179
+ beneficiaries.add(settlement.drawDestination);
1180
+ break;
1181
+ case "recurring_collection":
1182
+ break;
1183
+ case "conditional_disbursement":
1184
+ payers.add(settlement.source);
1185
+ beneficiaries.add(settlement.destination);
1186
+ break;
1187
+ case "rotating_pool":
1188
+ for (const member of settlement.members) {
1189
+ payers.add(member);
1190
+ beneficiaries.add(member);
1191
+ }
1192
+ if (settlement.guarantor) payers.add(settlement.guarantor);
1193
+ break;
533
1194
  case "swap":
534
1195
  payers.add(settlement.sides[0].party);
535
1196
  beneficiaries.add(settlement.sides[1].party);
@@ -549,19 +1210,31 @@ function partyRoles(
549
1210
 
550
1211
  const ARCHETYPE_MECHANICS: Record<CheckedSettlement["archetype"], string> = {
551
1212
  advance: "credit",
1213
+ captured_payment: "escrow",
1214
+ conditional_disbursement: "marketplace",
1215
+ credit_facility: "credit",
552
1216
  deposit: "escrow",
1217
+ funding_round: "credit",
553
1218
  held_payment: "escrow",
554
1219
  instant_transfer: "marketplace",
555
1220
  metered: "recurring_billing",
556
1221
  pooled_split: "marketplace",
557
1222
  premium_forward: "insurance",
1223
+ recurring_collection: "recurring_billing",
1224
+ rotating_pool: "recurring_billing",
558
1225
  scheduled: "recurring_billing",
1226
+ settlement_batch: "marketplace",
559
1227
  swap: "escrow",
1228
+ weighted_distribution: "marketplace",
560
1229
  };
561
1230
 
562
1231
  function mechanicsOf(settlements: readonly CheckedSettlement[]): string[] {
563
1232
  const mechanics = new Set(
564
- settlements.map((settlement) => ARCHETYPE_MECHANICS[settlement.archetype]),
1233
+ settlements.map((settlement) =>
1234
+ settlement.archetype === "scheduled" && settlement.mode === "obligation"
1235
+ ? "credit"
1236
+ : ARCHETYPE_MECHANICS[settlement.archetype],
1237
+ ),
565
1238
  );
566
1239
  return mechanics.size > 0 ? [...mechanics] : ["escrow"];
567
1240
  }
@@ -1016,6 +1689,7 @@ function lowerHeldPayment(
1016
1689
  function lowerPremiumForward(
1017
1690
  settlement: CheckedPremiumForward,
1018
1691
  port: CheckedPort,
1692
+ endorsement: CheckedPort | undefined,
1019
1693
  issues: LoweringIssue[],
1020
1694
  ): LoweredNoun | undefined {
1021
1695
  const held = lowerHeldFamily(
@@ -1042,10 +1716,64 @@ function lowerPremiumForward(
1042
1716
  issues,
1043
1717
  );
1044
1718
  if (!held) return undefined;
1719
+ const baseNoun = held.noun;
1720
+ const fields = { ...((baseNoun.fields as Json | undefined) ?? {}) };
1721
+ const verbs = { ...((baseNoun.verbs as Json | undefined) ?? {}) };
1722
+ const rules = [...held.rules];
1723
+ if (
1724
+ settlement.policyReferenceField &&
1725
+ settlement.renewalDueField &&
1726
+ settlement.endorsement &&
1727
+ endorsement
1728
+ ) {
1729
+ fields[settlement.policyReferenceField] = {
1730
+ desc: "Immutable external policy reference recorded with this forward",
1731
+ type: "text",
1732
+ };
1733
+ fields[settlement.renewalDueField] = dateFieldSpec(
1734
+ "Stored renewal due condition for the forwarded policy",
1735
+ );
1736
+ verbs[settlement.endorsement.port] = {
1737
+ captureInput: { endorsementEvidenceReference: "evidenceReference" },
1738
+ from: ["released"],
1739
+ port: {
1740
+ allowed: endorsement.allowed,
1741
+ fields: { evidenceReference: "text" },
1742
+ },
1743
+ summary: "Record one non-money endorsement from external evidence",
1744
+ to: "endorsed",
1745
+ };
1746
+ const lapseRule = frameKey(`${settlement.name}_renewal_due`);
1747
+ verbs.lapse = {
1748
+ due: { field: settlement.renewalDueField, rule: lapseRule },
1749
+ from: ["released", "endorsed"],
1750
+ requiresDrainedAccount: { path: "refs.escrowAccountId" },
1751
+ summary:
1752
+ "Mark the forwarded policy lapsed at its stored renewal due condition",
1753
+ to: "lapsed",
1754
+ };
1755
+ rules.push({
1756
+ allowedActors: [],
1757
+ detail:
1758
+ "The stored renewal due condition changes policy state without moving money",
1759
+ dueDriven: true,
1760
+ enforcement: "platform",
1761
+ gatesEvent: null,
1762
+ key: lapseRule,
1763
+ kind: "deadline",
1764
+ label: "Policy lapses at its stored renewal due condition",
1765
+ tenantTunable: false,
1766
+ });
1767
+ }
1045
1768
  return {
1046
1769
  ...held,
1047
1770
  design: [
1048
1771
  `${settlement.name}: premium forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on ${port.name}; ${formatBps(settlement.commissionBps)} commission retained by the platform`,
1772
+ ...(settlement.policyReferenceField
1773
+ ? [
1774
+ `${settlement.name}: extends premium_forward with stored policy reference, non-money endorsement evidence, and a due-only lapse; renewal creates a new forward`,
1775
+ ]
1776
+ : []),
1049
1777
  ],
1050
1778
  feeLines:
1051
1779
  settlement.commissionBps > 0
@@ -1059,9 +1787,12 @@ function lowerPremiumForward(
1059
1787
  : [],
1060
1788
  noun: {
1061
1789
  ...held.noun,
1790
+ fields,
1791
+ verbs,
1062
1792
  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
1793
  summary: `Premium held for the ${settlement.carrier.replaceAll("_", " ")} until the policy binds`,
1064
1794
  },
1795
+ rules,
1065
1796
  };
1066
1797
  }
1067
1798
 
@@ -1410,6 +2141,7 @@ function lowerHeldFamily(
1410
2141
  }
1411
2142
  verbs.abandon = {
1412
2143
  from: ["created"],
2144
+ requiresDrainedAccount: { path: "refs.escrowAccountId" },
1413
2145
  summary: "Abandon the settlement before any money is held",
1414
2146
  to: "abandoned",
1415
2147
  };
@@ -1681,29 +2413,601 @@ function lowerInstantTransfer(settlement: CheckedInstantTransfer): LoweredNoun {
1681
2413
  // ---------------------------------------------------------------------------
1682
2414
  // deposit: a reservation placed, then claimed or returned
1683
2415
 
1684
- function lowerDeposit(
1685
- settlement: CheckedDeposit,
1686
- claim: CheckedPort,
1687
- giveBack: CheckedPort,
2416
+ function lowerCaptureReservation(
2417
+ settlement: CheckedCaptureReservation,
2418
+ correctionPort: CheckedPort,
2419
+ reversalPort: CheckedPort,
1688
2420
  issues: LoweringIssue[],
1689
2421
  ): LoweredNoun | undefined {
1690
2422
  const noun = settlement.name;
1691
2423
  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
- ) {
2424
+ const reserveRef = "authorize_reservation";
2425
+ const capturedRef = "capturedAmount";
2426
+ const reversalCutoffField = "reversalUntil";
2427
+ const captureVerbs = ["capture", "capture_more"];
2428
+ const verbNames = [
2429
+ "authorize",
2430
+ ...captureVerbs,
2431
+ "settle",
2432
+ "void",
2433
+ "expire",
2434
+ "settle_on_expiry",
2435
+ settlement.correction.port,
2436
+ settlement.externalReversal.port,
2437
+ ];
2438
+ if (!verbNameIssues(noun, verbNames, settlement.origin, issues)) {
1700
2439
  return undefined;
1701
2440
  }
1702
2441
 
1703
- const eventKey = `${noun}_hold_1`;
1704
- const events = [
1705
- mintEvent({
1706
- amount: `The full ${amountName}`,
2442
+ const reserveEventKey = frameKey(`${noun}_reserve`);
2443
+ const captureEventKey = frameKey(`${noun}_capture`);
2444
+ const correctionEventKey = frameKey(`${noun}_correction`);
2445
+ const reversalEventKey = frameKey(`${noun}_external_reversal`);
2446
+ const expiryRuleKey = frameKey(`${noun}_reservation_expiry`);
2447
+ const captureMove = (partialOnly: boolean): Json => ({
2448
+ amount: "captureAmount",
2449
+ capture: { [capturedRef]: "postedAmount" },
2450
+ key: "post",
2451
+ operation: "post",
2452
+ ...(partialOnly ? { partialOnly: true } : {}),
2453
+ reservation: reserveRef,
2454
+ });
2455
+ const reverseMove = (): Json => ({
2456
+ amount: `refs.${capturedRef}`,
2457
+ clawbackOf: reserveRef,
2458
+ from: settlement.payee,
2459
+ key: "transfer",
2460
+ operation: "create",
2461
+ to: settlement.payer,
2462
+ });
2463
+ const verbs: Json = {
2464
+ create: {
2465
+ summary: `Create a ${titleize(noun).toLowerCase()}`,
2466
+ to: "created",
2467
+ },
2468
+ authorize: {
2469
+ from: ["created"],
2470
+ moneyEvent: reserveEventKey,
2471
+ moves: [
2472
+ {
2473
+ amount: amountName,
2474
+ from: settlement.payer,
2475
+ key: "reservation",
2476
+ operation: "reserve",
2477
+ to: settlement.payee,
2478
+ },
2479
+ ],
2480
+ summary: `Reserve the ${amountName} until ${settlement.reserveUntilField}`,
2481
+ to: "authorized",
2482
+ },
2483
+ capture: {
2484
+ deadline: { field: settlement.reserveUntilField },
2485
+ from: ["authorized"],
2486
+ moneyEvent: captureEventKey,
2487
+ moves: [captureMove(true)],
2488
+ summary: "Post one strict partial capture slice",
2489
+ to: "partially_captured",
2490
+ },
2491
+ capture_more: {
2492
+ deadline: { field: settlement.reserveUntilField },
2493
+ from: ["partially_captured"],
2494
+ moneyEvent: captureEventKey,
2495
+ moves: [captureMove(true)],
2496
+ summary: "Post another strict partial capture slice",
2497
+ to: "partially_captured",
2498
+ },
2499
+ settle: {
2500
+ deadline: { field: settlement.reserveUntilField },
2501
+ from: ["authorized", "partially_captured"],
2502
+ moneyEvent: captureEventKey,
2503
+ moves: [
2504
+ {
2505
+ capture: { [capturedRef]: "postedAmount" },
2506
+ key: "post",
2507
+ operation: "post",
2508
+ reservation: reserveRef,
2509
+ },
2510
+ ],
2511
+ summary: "Post the full reserved remainder and settle",
2512
+ setsAt: {
2513
+ field: reversalCutoffField,
2514
+ offset: settlement.externalReversal.window.raw,
2515
+ },
2516
+ to: "settled",
2517
+ },
2518
+ void: {
2519
+ from: ["authorized"],
2520
+ moves: [
2521
+ {
2522
+ key: "void",
2523
+ operation: "void",
2524
+ reason: "Reservation voided before any capture",
2525
+ reservation: reserveRef,
2526
+ },
2527
+ ],
2528
+ summary: "Release an entirely uncaptured reservation",
2529
+ to: "voided",
2530
+ },
2531
+ expire: {
2532
+ due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
2533
+ from: ["authorized"],
2534
+ moves: [
2535
+ {
2536
+ key: "void",
2537
+ operation: "void",
2538
+ reason: "Uncaptured reservation expired",
2539
+ reservation: reserveRef,
2540
+ },
2541
+ ],
2542
+ summary: "Release an uncaptured reservation at expiry",
2543
+ to: "expired",
2544
+ },
2545
+ settle_on_expiry: {
2546
+ due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
2547
+ from: ["partially_captured"],
2548
+ moves: [
2549
+ {
2550
+ key: "void",
2551
+ operation: "void",
2552
+ reason: "Uncaptured remainder released at expiry",
2553
+ reservation: reserveRef,
2554
+ },
2555
+ ],
2556
+ summary: "Release the uncaptured remainder and settle captured slices",
2557
+ setsAt: {
2558
+ field: reversalCutoffField,
2559
+ offset: settlement.externalReversal.window.raw,
2560
+ },
2561
+ to: "settled",
2562
+ },
2563
+ [settlement.correction.port]: {
2564
+ from: ["settled"],
2565
+ moneyEvent: correctionEventKey,
2566
+ moves: [reverseMove()],
2567
+ port: { allowed: correctionPort.allowed },
2568
+ summary: "Return the full captured amount on payee correction",
2569
+ to: "corrected",
2570
+ },
2571
+ [settlement.externalReversal.port]: {
2572
+ captureInput: { externalReference: "externalReference" },
2573
+ deadline: { field: reversalCutoffField },
2574
+ from: ["settled"],
2575
+ moneyEvent: reversalEventKey,
2576
+ moves: [reverseMove()],
2577
+ port: {
2578
+ allowed: reversalPort.allowed,
2579
+ fields: { externalReference: "text" },
2580
+ },
2581
+ summary: "Return the full captured amount on an external reversal",
2582
+ to: "reversed",
2583
+ },
2584
+ };
2585
+
2586
+ const events = [
2587
+ mintEvent({
2588
+ amount: `The full ${amountName}`,
2589
+ fromActor: settlement.payer,
2590
+ key: reserveEventKey,
2591
+ kind: "hold",
2592
+ toActor: settlement.payee,
2593
+ trigger: `Reserve ${amountName} until ${settlement.reserveUntilField}`,
2594
+ }),
2595
+ mintEvent({
2596
+ amount: `Each posted slice, never more than the remaining ${amountName}`,
2597
+ amountDependency: {
2598
+ kind: "bounded_by_reference",
2599
+ reference: reserveEventKey,
2600
+ },
2601
+ fromActor: settlement.payer,
2602
+ key: captureEventKey,
2603
+ kind: "payout",
2604
+ occurrence: "repeatable",
2605
+ toActor: settlement.payee,
2606
+ trigger: "Post a capture slice or the final remainder",
2607
+ }),
2608
+ mintEvent({
2609
+ amount: "100% of the cumulative captured amount",
2610
+ amountDependency: {
2611
+ bps: 10_000,
2612
+ kind: "percent_of_reference",
2613
+ reference: captureEventKey,
2614
+ },
2615
+ fromActor: settlement.payee,
2616
+ key: correctionEventKey,
2617
+ kind: "refund",
2618
+ toActor: settlement.payer,
2619
+ trigger: "Apply one full payee correction",
2620
+ }),
2621
+ mintEvent({
2622
+ amount: "100% of the cumulative captured amount",
2623
+ amountDependency: {
2624
+ bps: 10_000,
2625
+ kind: "percent_of_reference",
2626
+ reference: captureEventKey,
2627
+ },
2628
+ fromActor: settlement.payee,
2629
+ key: reversalEventKey,
2630
+ kind: "refund",
2631
+ toActor: settlement.payer,
2632
+ trigger: "Apply one full externally decided reversal",
2633
+ }),
2634
+ ];
2635
+
2636
+ return {
2637
+ design: [
2638
+ `${noun}: reserve ${amountName} until ${settlement.reserveUntilField}; capture in strict partial slices; post the remainder to settle; expiry releases only the uncaptured remainder`,
2639
+ `${noun}: correction and external reversal each return the full captured amount once; insufficient payee funds reject the move instead of creating a negative position`,
2640
+ ],
2641
+ feeLines: [],
2642
+ moneyEvents: events,
2643
+ noun: {
2644
+ actors: {
2645
+ [settlement.payer]: "payer",
2646
+ [settlement.payee]: "beneficiary",
2647
+ },
2648
+ desc: `Payer reservation captured by the payee in slices within a fixed window`,
2649
+ fields: {
2650
+ [amountName]: moneyFieldSpec(
2651
+ `Maximum captured amount in ${settlement.amount.currency} minor units`,
2652
+ ),
2653
+ [settlement.reserveUntilField]: dateFieldSpec(
2654
+ "Reservation expiry that releases any uncaptured remainder",
2655
+ ),
2656
+ [reversalCutoffField]: {
2657
+ desc: "Machine-owned external reversal cutoff anchored when settlement completes",
2658
+ type: "date?",
2659
+ },
2660
+ },
2661
+ id: noun,
2662
+ summary: `Capture reservation from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
2663
+ title: titleize(noun),
2664
+ verbs,
2665
+ },
2666
+ rules: [
2667
+ {
2668
+ allowedActors: [],
2669
+ detail: `At ${settlement.reserveUntilField}, the platform releases the uncaptured remainder and preserves any posted slices`,
2670
+ dueDriven: true,
2671
+ enforcement: "platform",
2672
+ gatesEvent: null,
2673
+ key: expiryRuleKey,
2674
+ kind: "deadline",
2675
+ label: `Uncaptured remainder releases on ${settlement.reserveUntilField}`,
2676
+ tenantTunable: false,
2677
+ },
2678
+ {
2679
+ allowedActors: [...correctionPort.allowed],
2680
+ detail: "The payee may return the full captured amount once",
2681
+ dueDriven: false,
2682
+ enforcement: "tenant_app",
2683
+ gatesEvent: correctionEventKey,
2684
+ key: frameKey(`${noun}_${settlement.correction.port}_gate`),
2685
+ kind: "release_condition",
2686
+ label: "Full correction confirmed through the tenant backend",
2687
+ tenantTunable: false,
2688
+ },
2689
+ {
2690
+ allowedActors: [...reversalPort.allowed],
2691
+ detail: `A confirmed external decision may reverse the full captured amount within ${settlement.externalReversal.window.raw}; timeout moves nothing`,
2692
+ dueDriven: false,
2693
+ enforcement: "tenant_app",
2694
+ gatesEvent: reversalEventKey,
2695
+ key: frameKey(`${noun}_${settlement.externalReversal.port}_gate`),
2696
+ kind: "release_condition",
2697
+ label: "External reversal confirmed through the tenant backend",
2698
+ tenantTunable: false,
2699
+ },
2700
+ ],
2701
+ settlement: { name: noun, pieces: [] },
2702
+ };
2703
+ }
2704
+
2705
+ // ---------------------------------------------------------------------------
2706
+ // settlement_batch: immutable close, signed lineage sum, one payout
2707
+
2708
+ function lowerSettlementBatch(
2709
+ settlement: CheckedSettlementBatch,
2710
+ acknowledgementPort: CheckedPort,
2711
+ issues: LoweringIssue[],
2712
+ ): LoweredNoun | undefined {
2713
+ const noun = settlement.name;
2714
+ const captureEntry = `${noun}_capture_entry`;
2715
+ const creditAdjustment = `${noun}_credit_adjustment`;
2716
+ const debitAdjustment = `${noun}_debit_adjustment`;
2717
+ const batchIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}Id`;
2718
+ const payoutEventKey = frameKey(`${noun}_payout`);
2719
+ const closeRuleKey = frameKey(`${noun}_close`);
2720
+ if (
2721
+ !verbNameIssues(
2722
+ noun,
2723
+ [
2724
+ "close",
2725
+ "calculate",
2726
+ "approve",
2727
+ "instruct",
2728
+ "reconcile",
2729
+ settlement.payoutAcknowledgement.port,
2730
+ ],
2731
+ settlement.origin,
2732
+ issues,
2733
+ )
2734
+ ) {
2735
+ return undefined;
2736
+ }
2737
+
2738
+ const parentRequirement: Json = {
2739
+ [batchIdField]: {
2740
+ match: { "fields.currency": "fields.currency" },
2741
+ statuses: ["open"],
2742
+ },
2743
+ };
2744
+ const captureNoun: Json = {
2745
+ desc: "One gross capture entry linked to an open payout batch",
2746
+ fields: {
2747
+ amount: moneyFieldSpec("Gross captured amount in minor units"),
2748
+ currency: {
2749
+ desc: "ISO 4217 currency shared with the payout batch",
2750
+ type: "currency",
2751
+ },
2752
+ [batchIdField]: {
2753
+ desc: "Open batch this capture entry accrues into",
2754
+ type: `ref:${noun}`,
2755
+ },
2756
+ [settlement.sourceCaptureReferenceField]: {
2757
+ desc: "Immutable source capture reference",
2758
+ type: "text",
2759
+ },
2760
+ },
2761
+ id: captureEntry,
2762
+ summary: "Gross capture lineage entry",
2763
+ title: `${titleize(noun)} Capture Entry`,
2764
+ verbs: {
2765
+ create: {
2766
+ requires: parentRequirement,
2767
+ summary: "Create a capture lineage entry on an open batch",
2768
+ to: "created",
2769
+ },
2770
+ accrue: {
2771
+ from: ["created"],
2772
+ requires: parentRequirement,
2773
+ summary: "Accrue the capture entry into the open batch",
2774
+ to: "accrued",
2775
+ },
2776
+ },
2777
+ };
2778
+
2779
+ const adjustmentNoun = (id: string, direction: "credit" | "debit"): Json => ({
2780
+ desc: `One ${direction} adjustment linked to an open payout batch; closed batches stay unchanged`,
2781
+ fields: {
2782
+ amount: moneyFieldSpec(
2783
+ `${titleize(direction)} adjustment amount in minor units`,
2784
+ ),
2785
+ currency: {
2786
+ desc: "ISO 4217 currency shared with the payout batch",
2787
+ type: "currency",
2788
+ },
2789
+ adjustmentReference: {
2790
+ desc: "Immutable explicit adjustment reference",
2791
+ type: "text",
2792
+ },
2793
+ [batchIdField]: {
2794
+ desc: "Open batch this adjustment applies to",
2795
+ type: `ref:${noun}`,
2796
+ },
2797
+ [settlement.externalReversalReferenceField]: {
2798
+ desc: "Optional externally decided reversal reference",
2799
+ type: "text?",
2800
+ },
2801
+ [settlement.feeReferenceField]: {
2802
+ desc: "Optional fee entry reference",
2803
+ type: "text?",
2804
+ },
2805
+ [settlement.sourceCaptureReferenceField]: {
2806
+ desc: "Original capture reference that this adjustment corrects",
2807
+ type: "text",
2808
+ },
2809
+ },
2810
+ id,
2811
+ summary: `${titleize(direction)} adjustment with capture lineage`,
2812
+ title: `${titleize(noun)} ${titleize(direction)} Adjustment`,
2813
+ verbs: {
2814
+ create: {
2815
+ requires: parentRequirement,
2816
+ summary: `Create a ${direction} adjustment on an open batch`,
2817
+ to: "created",
2818
+ },
2819
+ adjust: {
2820
+ from: ["created"],
2821
+ requires: parentRequirement,
2822
+ summary: `Apply the ${direction} adjustment to the open batch`,
2823
+ to: "applied",
2824
+ },
2825
+ correct: {
2826
+ from: ["created"],
2827
+ requires: parentRequirement,
2828
+ summary:
2829
+ "Record a later correction on this open batch instead of changing the closed source batch",
2830
+ to: "applied",
2831
+ },
2832
+ },
2833
+ });
2834
+
2835
+ const verbs: Json = {
2836
+ create: {
2837
+ summary: `Open a ${titleize(noun).toLowerCase()}`,
2838
+ to: "open",
2839
+ },
2840
+ close: {
2841
+ due: { field: settlement.closeTriggerField, rule: closeRuleKey },
2842
+ from: ["open"],
2843
+ summary: "Freeze the batch and stop all new entries",
2844
+ to: "closed",
2845
+ },
2846
+ calculate: {
2847
+ from: ["closed"],
2848
+ signedSum: {
2849
+ amountRef: "netPayable",
2850
+ onNegative: "refuse",
2851
+ onZero: "refuse",
2852
+ sources: [
2853
+ {
2854
+ amountField: "amount",
2855
+ nounId: captureEntry,
2856
+ refField: batchIdField,
2857
+ sign: "add",
2858
+ statuses: ["accrued"],
2859
+ subtotalRef: "grossCaptureAmount",
2860
+ },
2861
+ {
2862
+ amountField: "amount",
2863
+ nounId: creditAdjustment,
2864
+ refField: batchIdField,
2865
+ sign: "add",
2866
+ statuses: ["applied"],
2867
+ subtotalRef: "creditAdjustmentAmount",
2868
+ },
2869
+ {
2870
+ amountField: "amount",
2871
+ nounId: debitAdjustment,
2872
+ refField: batchIdField,
2873
+ sign: "subtract",
2874
+ statuses: ["applied"],
2875
+ subtotalRef: "debitAdjustmentAmount",
2876
+ },
2877
+ ],
2878
+ },
2879
+ summary: "Prove and freeze the one signed net payable amount",
2880
+ to: "calculated",
2881
+ },
2882
+ approve: {
2883
+ from: ["calculated"],
2884
+ summary: "Approve the frozen payable without recomputing it",
2885
+ to: "approved",
2886
+ },
2887
+ instruct: {
2888
+ from: ["approved"],
2889
+ moneyEvent: payoutEventKey,
2890
+ payout: {
2891
+ amount: "refs.netPayable",
2892
+ beneficiaryField: settlement.payoutBeneficiaryReferenceField,
2893
+ beneficiaryPartyField: `${camelize(settlement.payoutDestination)}AccountId`,
2894
+ capture: "payoutId",
2895
+ currencyField: "currency",
2896
+ sourceAccountField: `${camelize(settlement.settlementAccount)}AccountId`,
2897
+ speed: "standard",
2898
+ },
2899
+ summary: "Create one idempotent payout from the frozen net payable",
2900
+ to: "instructed",
2901
+ },
2902
+ [settlement.payoutAcknowledgement.port]: {
2903
+ captureInput: {
2904
+ acknowledgementReference: "acknowledgementReference",
2905
+ },
2906
+ from: ["instructed"],
2907
+ port: {
2908
+ allowed: acknowledgementPort.allowed,
2909
+ fields: { acknowledgementReference: "text" },
2910
+ },
2911
+ summary: "Record the tenant's payout acknowledgement in the receipt",
2912
+ to: "acknowledged",
2913
+ },
2914
+ reconcile: {
2915
+ from: ["instructed", "acknowledged"],
2916
+ requiresSettlement: {
2917
+ capture: "settlementEvidenceId",
2918
+ payoutRef: "payoutId",
2919
+ },
2920
+ summary: "Record durable evidence that the payout settled",
2921
+ to: "reconciled",
2922
+ },
2923
+ };
2924
+
2925
+ return {
2926
+ design: [
2927
+ `${noun}: capture entries plus signed adjustments freeze at ${settlement.closeTriggerField}; calculate persists gross, credit, debit, and net refs; negative or zero net refuses`,
2928
+ `${noun}: instruct creates one payout intent for the frozen refs.netPayable; only matched settlement evidence can reconcile it`,
2929
+ ],
2930
+ extraNouns: [
2931
+ captureNoun,
2932
+ adjustmentNoun(creditAdjustment, "credit"),
2933
+ adjustmentNoun(debitAdjustment, "debit"),
2934
+ ],
2935
+ feeLines: [],
2936
+ moneyEvents: [
2937
+ mintEvent({
2938
+ amount:
2939
+ "The frozen signed sum of gross capture entries plus credit adjustments minus debit adjustments",
2940
+ fromActor: settlement.settlementAccount,
2941
+ key: payoutEventKey,
2942
+ kind: "payout",
2943
+ toActor: settlement.payoutDestination,
2944
+ trigger: "Instruct the approved batch payout exactly once",
2945
+ }),
2946
+ ],
2947
+ noun: {
2948
+ actors: {
2949
+ [settlement.payoutDestination]: "beneficiary",
2950
+ [settlement.settlementAccount]: "payer",
2951
+ },
2952
+ desc: "Immutable batch of capture lineage and signed adjustments that creates one payout",
2953
+ fields: {
2954
+ [settlement.closeTriggerField]: dateFieldSpec(
2955
+ "Date the open batch freezes against later entries",
2956
+ ),
2957
+ currency: {
2958
+ desc: "ISO 4217 currency shared by the batch and payout instruction",
2959
+ type: "currency",
2960
+ },
2961
+ [settlement.payoutBeneficiaryReferenceField]: {
2962
+ desc: "Beneficiary ID for the payout instruction",
2963
+ type: "beneficiary",
2964
+ },
2965
+ },
2966
+ id: noun,
2967
+ summary: `Payout batch from ${settlement.settlementAccount.replaceAll("_", " ")} to ${settlement.payoutDestination.replaceAll("_", " ")}`,
2968
+ title: titleize(noun),
2969
+ verbs,
2970
+ },
2971
+ rules: [
2972
+ {
2973
+ allowedActors: [],
2974
+ detail: `At ${settlement.closeTriggerField}, the platform closes the batch and every child reference gate refuses later entries`,
2975
+ dueDriven: true,
2976
+ enforcement: "platform",
2977
+ gatesEvent: null,
2978
+ key: closeRuleKey,
2979
+ kind: "deadline",
2980
+ label: `Batch freezes on ${settlement.closeTriggerField}`,
2981
+ tenantTunable: false,
2982
+ },
2983
+ ],
2984
+ settlement: { name: noun, pieces: [] },
2985
+ };
2986
+ }
2987
+
2988
+ function lowerDeposit(
2989
+ settlement: CheckedDeposit,
2990
+ claim: CheckedPort,
2991
+ giveBack: CheckedPort,
2992
+ issues: LoweringIssue[],
2993
+ ): LoweredNoun | undefined {
2994
+ const noun = settlement.name;
2995
+ const amountName = settlement.amount.name;
2996
+ if (
2997
+ !verbNameIssues(
2998
+ noun,
2999
+ ["place_deposit", claim.name, giveBack.name],
3000
+ settlement.origin,
3001
+ issues,
3002
+ )
3003
+ ) {
3004
+ return undefined;
3005
+ }
3006
+
3007
+ const eventKey = `${noun}_hold_1`;
3008
+ const events = [
3009
+ mintEvent({
3010
+ amount: `The full ${amountName}`,
1707
3011
  fromActor: settlement.payer,
1708
3012
  key: eventKey,
1709
3013
  kind: "hold",
@@ -1714,105 +3018,1536 @@ function lowerDeposit(
1714
3018
 
1715
3019
  const verbs: Json = {
1716
3020
  create: {
1717
- summary: `Create a ${titleize(noun).toLowerCase()}`,
1718
- to: "created",
3021
+ summary: `Create a ${titleize(noun).toLowerCase()}`,
3022
+ to: "created",
3023
+ },
3024
+ place_deposit: {
3025
+ from: ["created"],
3026
+ moves: [
3027
+ {
3028
+ key: "reservation",
3029
+ operation: "reserve",
3030
+ amount: amountName,
3031
+ from: settlement.payer,
3032
+ to: settlement.holder,
3033
+ },
3034
+ ],
3035
+ moneyEvent: eventKey,
3036
+ summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
3037
+ to: "held",
3038
+ },
3039
+ [claim.name]: {
3040
+ from: ["held"],
3041
+ moves: [
3042
+ {
3043
+ key: "post",
3044
+ operation: "post",
3045
+ reservation: "place_deposit_reservation",
3046
+ },
3047
+ ],
3048
+ summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
3049
+ to: "claimed",
3050
+ },
3051
+ [giveBack.name]: {
3052
+ from: ["held"],
3053
+ summary: `Return the deposit to the ${settlement.payer.replaceAll("_", " ")} through ${giveBack.name}`,
3054
+ to: "returned",
3055
+ moves: [
3056
+ {
3057
+ key: "void",
3058
+ operation: "void",
3059
+ reason: "Deposit returned in full",
3060
+ reservation: "place_deposit_reservation",
3061
+ },
3062
+ ],
3063
+ },
3064
+ };
3065
+
3066
+ const portRule = (port: CheckedPort, verbLabel: string): Json => ({
3067
+ allowedActors: [...port.allowed],
3068
+ detail: `${port.allowed.map(titleize).join(" or ")} decides through the tenant backend`,
3069
+ dueDriven: false,
3070
+ enforcement: "tenant_app",
3071
+ gatesEvent: null,
3072
+ key: frameKey(`${noun}_${port.name}_gate`),
3073
+ kind: "release_condition",
3074
+ label: `${verbLabel} decided through ${port.name}`,
3075
+ tenantTunable: false,
3076
+ });
3077
+
3078
+ return {
3079
+ design: [
3080
+ `${noun}: ${amountName} held as a reservation on the ${settlement.payer.replaceAll("_", " ")}'s account; claimed whole through ${claim.name} or returned whole through ${giveBack.name}`,
3081
+ ],
3082
+ feeLines: [],
3083
+ moneyEvents: events,
3084
+ noun: {
3085
+ actors: {
3086
+ [settlement.payer]: "payer",
3087
+ [settlement.holder]: "beneficiary",
3088
+ },
3089
+ 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`,
3090
+ fields: {
3091
+ [amountName]: moneyFieldSpec(
3092
+ `The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`,
3093
+ ),
3094
+ },
3095
+ id: noun,
3096
+ summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
3097
+ title: titleize(noun),
3098
+ verbs,
3099
+ },
3100
+ rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
3101
+ settlement: { name: noun, pieces: [] },
3102
+ };
3103
+ }
3104
+
3105
+ // ---------------------------------------------------------------------------
3106
+ // scheduled and advance: finite due-driven anchors
3107
+
3108
+ /** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
3109
+ function evenPieceBps(count: number): number[] {
3110
+ const base = Math.floor(Number(TOTAL_BPS) / count);
3111
+ const widths = Array.from({ length: count }, () => base);
3112
+ widths[0] = Number(TOTAL_BPS) - base * (count - 1);
3113
+ return widths;
3114
+ }
3115
+
3116
+ // ---------------------------------------------------------------------------
3117
+ // funding_round: aggregate commitments with threshold close and whole unwind
3118
+
3119
+ function lowerFundingRound(settlement: CheckedFundingRound): LoweredNoun {
3120
+ const noun = settlement.name;
3121
+ const child = `${noun}_commitment`;
3122
+ const parentRef = `${camelize(noun)}Id`;
3123
+ const commitEvent = frameKey(`${noun}_commit`);
3124
+ const cancelEvent = frameKey(`${noun}_cancel`);
3125
+ const collectEvent = frameKey(`${noun}_collect`);
3126
+ const refundEvent = frameKey(`${noun}_refund`);
3127
+ const closeRule = frameKey(`${noun}_close`);
3128
+ const aggregate = (kind: "sum_at_least" | "sum_below"): Json[] => [
3129
+ {
3130
+ check: {
3131
+ amountField: "amount",
3132
+ kind,
3133
+ targetField: settlement.target.name,
3134
+ },
3135
+ nounId: child,
3136
+ over: "children",
3137
+ refField: parentRef,
3138
+ statuses: ["committed"],
3139
+ },
3140
+ ];
3141
+ const parentRequirement = (statuses: readonly string[]): Json => ({
3142
+ [parentRef]: {
3143
+ bind: {
3144
+ currency: "fields.currency",
3145
+ [`${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
3146
+ },
3147
+ statuses,
3148
+ },
3149
+ });
3150
+ const transitionRequirement = (statuses: readonly string[]): Json => ({
3151
+ [parentRef]: {
3152
+ match: {
3153
+ "fields.currency": "fields.currency",
3154
+ [`fields.${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
3155
+ },
3156
+ statuses,
3157
+ },
3158
+ });
3159
+
3160
+ return {
3161
+ design: [
3162
+ `${noun}: reuses the catalog funding round and commitment mechanism; the parent lock caps committed rows by target and contributor count`,
3163
+ `${noun}: the stored close anchor chooses threshold activation or failure; each commitment then moves whole from its own custody`,
3164
+ ],
3165
+ extraNouns: [
3166
+ {
3167
+ actors: {
3168
+ [settlement.beneficiary]: "beneficiary",
3169
+ [settlement.contributor]: "payer",
3170
+ },
3171
+ desc: `One whole commitment linked to ${noun}`,
3172
+ escrow: true,
3173
+ fields: {
3174
+ amount: moneyFieldSpec("One whole commitment amount"),
3175
+ currency: {
3176
+ desc: "Currency derived from the funding round",
3177
+ type: "currency",
3178
+ },
3179
+ [parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
3180
+ },
3181
+ id: child,
3182
+ summary: `Whole commitment to ${noun}`,
3183
+ title: `${titleize(noun)} Commitment`,
3184
+ verbs: {
3185
+ create: {
3186
+ moneyEvent: commitEvent,
3187
+ moves: [
3188
+ {
3189
+ amount: "amount",
3190
+ from: settlement.contributor,
3191
+ key: "commit",
3192
+ operation: "create",
3193
+ to: "escrow",
3194
+ },
3195
+ ],
3196
+ requires: parentRequirement(["open"]),
3197
+ requiresExposure: [
3198
+ {
3199
+ amountField: "amount",
3200
+ anchorField: parentRef,
3201
+ capField: settlement.target.name,
3202
+ capOnAnchor: true,
3203
+ childNounId: child,
3204
+ statuses: ["committed"],
3205
+ },
3206
+ ],
3207
+ summary:
3208
+ "Store one whole commitment without exceeding the round target",
3209
+ to: "committed",
3210
+ },
3211
+ cancel: {
3212
+ from: ["committed"],
3213
+ moneyEvent: cancelEvent,
3214
+ moves: [
3215
+ {
3216
+ amount: "amount",
3217
+ from: "escrow",
3218
+ key: "cancel",
3219
+ operation: "create",
3220
+ to: settlement.contributor,
3221
+ },
3222
+ ],
3223
+ requires: transitionRequirement(["open"]),
3224
+ summary: "Cancel one commitment while the round is open",
3225
+ to: "cancelled",
3226
+ },
3227
+ collect: {
3228
+ from: ["committed"],
3229
+ moneyEvent: collectEvent,
3230
+ moves: [
3231
+ {
3232
+ amount: "amount",
3233
+ from: "escrow",
3234
+ key: "collect",
3235
+ operation: "create",
3236
+ to: settlement.beneficiary,
3237
+ },
3238
+ ],
3239
+ requires: transitionRequirement(["active"]),
3240
+ summary: "Collect one successful commitment whole",
3241
+ to: "collected",
3242
+ },
3243
+ refund: {
3244
+ from: ["committed"],
3245
+ moneyEvent: refundEvent,
3246
+ moves: [
3247
+ {
3248
+ amount: "amount",
3249
+ from: "escrow",
3250
+ key: "refund",
3251
+ operation: "create",
3252
+ to: settlement.contributor,
3253
+ },
3254
+ ],
3255
+ requires: transitionRequirement(["failed"]),
3256
+ summary: "Refund one failed-round commitment whole",
3257
+ to: "refunded",
3258
+ },
3259
+ },
3260
+ },
3261
+ ],
3262
+ generatedPrefixNounIds: [child],
3263
+ feeLines: [],
3264
+ moneyEvents: [
3265
+ mintEvent({
3266
+ amount: "One stored commitment",
3267
+ fromActor: settlement.contributor,
3268
+ key: commitEvent,
3269
+ kind: "charge",
3270
+ occurrence: "repeatable",
3271
+ toActor: "escrow",
3272
+ trigger: "Create one target-capped commitment",
3273
+ }),
3274
+ mintEvent({
3275
+ amount: "One stored commitment whole",
3276
+ fromActor: "escrow",
3277
+ key: cancelEvent,
3278
+ kind: "refund",
3279
+ occurrence: "repeatable",
3280
+ toActor: settlement.contributor,
3281
+ trigger: "Cancel before close",
3282
+ }),
3283
+ mintEvent({
3284
+ amount: "One stored commitment whole",
3285
+ fromActor: "escrow",
3286
+ key: collectEvent,
3287
+ kind: "payout",
3288
+ occurrence: "repeatable",
3289
+ toActor: settlement.beneficiary,
3290
+ trigger: "Collect after threshold close",
3291
+ }),
3292
+ mintEvent({
3293
+ amount: "One stored commitment whole",
3294
+ fromActor: "escrow",
3295
+ key: refundEvent,
3296
+ kind: "refund",
3297
+ occurrence: "repeatable",
3298
+ toActor: settlement.contributor,
3299
+ trigger: "Refund after failed close",
3300
+ }),
3301
+ ],
3302
+ noun: {
3303
+ actors: { [settlement.beneficiary]: "beneficiary" },
3304
+ aggregateInvariants: [
3305
+ {
3306
+ childField: "amount",
3307
+ childNounId: child,
3308
+ childRefField: parentRef,
3309
+ childStatuses: ["committed"],
3310
+ parentField: settlement.target.name,
3311
+ },
3312
+ {
3313
+ count: true,
3314
+ childNounId: child,
3315
+ childRefField: parentRef,
3316
+ childStatuses: ["committed"],
3317
+ parentField: "maxContributors",
3318
+ },
3319
+ ],
3320
+ desc: "All-or-nothing aggregate funding threshold",
3321
+ fields: {
3322
+ currency: {
3323
+ desc: `Currency fixed to ${settlement.target.currency}`,
3324
+ type: "currency",
3325
+ },
3326
+ [settlement.target.name]: moneyFieldSpec(
3327
+ `Funding target in ${settlement.target.currency} minor units`,
3328
+ ),
3329
+ [settlement.closeByField]: dateFieldSpec("Stored close anchor"),
3330
+ maxContributors: {
3331
+ desc: `Exactly ${settlement.maxContributors} admitted contributors`,
3332
+ type: `const:${settlement.maxContributors}`,
3333
+ },
3334
+ },
3335
+ id: noun,
3336
+ summary: `Threshold funding round for ${settlement.beneficiary.replaceAll("_", " ")}`,
3337
+ title: titleize(noun),
3338
+ verbs: {
3339
+ create: { summary: "Open the funding round", to: "open" },
3340
+ activate: {
3341
+ due: { field: settlement.closeByField, rule: closeRule },
3342
+ from: ["open"],
3343
+ requiresAggregate: aggregate("sum_at_least"),
3344
+ summary: "Activate when commitments meet the target",
3345
+ to: "active",
3346
+ },
3347
+ fail: {
3348
+ due: { field: settlement.closeByField, rule: closeRule },
3349
+ from: ["open"],
3350
+ requiresAggregate: aggregate("sum_below"),
3351
+ summary: "Fail when commitments remain below target",
3352
+ to: "failed",
3353
+ },
3354
+ close: {
3355
+ from: ["active"],
3356
+ requiresAggregate: [
3357
+ {
3358
+ check: { kind: "all_in" },
3359
+ nounId: child,
3360
+ over: "children",
3361
+ refField: parentRef,
3362
+ statuses: ["cancelled", "collected"],
3363
+ },
3364
+ ],
3365
+ summary:
3366
+ "Settle after every admitted row is collected or was cancelled before activation",
3367
+ to: "settled",
3368
+ },
3369
+ },
3370
+ },
3371
+ rules: [
3372
+ {
3373
+ allowedActors: [],
3374
+ detail:
3375
+ "The stored close anchor compares committed rows with the target",
3376
+ dueDriven: true,
3377
+ enforcement: "platform",
3378
+ gatesEvent: null,
3379
+ key: closeRule,
3380
+ kind: "deadline",
3381
+ label: "Round closes against its stored threshold",
3382
+ tenantTunable: false,
3383
+ },
3384
+ ],
3385
+ settlement: { name: noun, pieces: [] },
3386
+ };
3387
+ }
3388
+
3389
+ // ---------------------------------------------------------------------------
3390
+ // weighted_distribution: frozen weights with deterministic largest remainder
3391
+
3392
+ function lowerWeightedDistribution(
3393
+ settlement: CheckedWeightedDistribution,
3394
+ snapshot: CheckedPort,
3395
+ ): LoweredNoun {
3396
+ const noun = settlement.name;
3397
+ const child = `${noun}_entitlement`;
3398
+ const parentRef = `${camelize(noun)}Id`;
3399
+ const payoutEvent = frameKey(`${noun}_payout`);
3400
+ const parentRequirement = (statuses: readonly string[]): Json => ({
3401
+ [parentRef]: {
3402
+ bind: {
3403
+ currency: "fields.currency",
3404
+ [`${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
3405
+ },
3406
+ statuses,
3407
+ },
3408
+ });
3409
+ return {
3410
+ design: [
3411
+ `${noun}: reuses the catalog largest-remainder distribution; the evidence port freezes the claimant set before any payout`,
3412
+ ],
3413
+ extraNouns: [
3414
+ {
3415
+ actors: {
3416
+ [settlement.recipient]: "beneficiary",
3417
+ [settlement.source]: "payer",
3418
+ },
3419
+ desc: `One frozen weighted entitlement in ${noun}`,
3420
+ fields: {
3421
+ currency: {
3422
+ desc: "Currency derived from the distribution",
3423
+ type: "currency",
3424
+ },
3425
+ [parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
3426
+ [settlement.weight.name]: moneyFieldSpec(
3427
+ "Stored non-negative entitlement weight",
3428
+ ),
3429
+ },
3430
+ id: child,
3431
+ summary: `Frozen entitlement in ${noun}`,
3432
+ title: `${titleize(noun)} Entitlement`,
3433
+ verbs: {
3434
+ create: {
3435
+ requires: parentRequirement(["open"]),
3436
+ summary: "Record one entitlement before snapshot",
3437
+ to: "recorded",
3438
+ },
3439
+ payout: {
3440
+ distribute: {
3441
+ amountRef: "payoutShare",
3442
+ onZero: "skip_steps",
3443
+ pool: {
3444
+ from: "parent",
3445
+ path: `fields.${settlement.amount.name}`,
3446
+ },
3447
+ refField: parentRef,
3448
+ statuses: ["recorded", "paid"],
3449
+ weightField: settlement.weight.name,
3450
+ },
3451
+ from: ["recorded"],
3452
+ moneyEvent: payoutEvent,
3453
+ moves: [
3454
+ {
3455
+ amount: "refs.payoutShare",
3456
+ from: settlement.source,
3457
+ key: "payout",
3458
+ operation: "create",
3459
+ to: settlement.recipient,
3460
+ },
3461
+ ],
3462
+ requires: {
3463
+ [parentRef]: {
3464
+ match: {
3465
+ "fields.currency": "fields.currency",
3466
+ [`fields.${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
3467
+ },
3468
+ statuses: ["snapshotted"],
3469
+ },
3470
+ },
3471
+ summary: "Pay the deterministic largest-remainder share once",
3472
+ to: "paid",
3473
+ },
3474
+ },
3475
+ },
3476
+ ],
3477
+ generatedPrefixNounIds: [child],
3478
+ feeLines: [],
3479
+ moneyEvents: [
3480
+ mintEvent({
3481
+ amount: "A deterministic largest-remainder share of the stored pool",
3482
+ fromActor: settlement.source,
3483
+ key: payoutEvent,
3484
+ kind: "payout",
3485
+ occurrence: "repeatable",
3486
+ toActor: settlement.recipient,
3487
+ trigger: "Pay one frozen entitlement",
3488
+ }),
3489
+ ],
3490
+ noun: {
3491
+ actors: { [settlement.source]: "payer" },
3492
+ aggregateInvariants: [
3493
+ {
3494
+ count: true,
3495
+ childNounId: child,
3496
+ childRefField: parentRef,
3497
+ childStatuses: ["recorded", "paid"],
3498
+ parentField: "maxRecipients",
3499
+ },
3500
+ ],
3501
+ desc: "Evidence-frozen weighted distribution",
3502
+ fields: {
3503
+ currency: {
3504
+ desc: `Currency fixed to ${settlement.amount.currency}`,
3505
+ type: "currency",
3506
+ },
3507
+ [settlement.amount.name]: moneyFieldSpec(
3508
+ `Distribution pool in ${settlement.amount.currency} minor units`,
3509
+ ),
3510
+ [settlement.recordAtField]: dateFieldSpec("Stored record date"),
3511
+ maxRecipients: {
3512
+ desc: `Exactly ${settlement.maxRecipients} frozen entitlement rows`,
3513
+ type: `const:${settlement.maxRecipients}`,
3514
+ },
3515
+ },
3516
+ id: noun,
3517
+ summary: "Frozen largest-remainder distribution",
3518
+ title: titleize(noun),
3519
+ verbs: {
3520
+ create: { summary: "Open entitlement recording", to: "open" },
3521
+ [settlement.snapshot.port]: {
3522
+ captureInput: { snapshotEvidenceReference: "evidenceReference" },
3523
+ from: ["open"],
3524
+ port: {
3525
+ allowed: snapshot.allowed,
3526
+ fields: { evidenceReference: "text" },
3527
+ },
3528
+ summary: "Freeze the entitlement set from stored evidence",
3529
+ to: "snapshotted",
3530
+ },
3531
+ },
3532
+ },
3533
+ rules: [],
3534
+ settlement: { name: noun, pieces: [] },
3535
+ };
3536
+ }
3537
+
3538
+ // ---------------------------------------------------------------------------
3539
+ // credit_facility: draw capacity only, repayment remains on scheduled obligation
3540
+
3541
+ function lowerCreditFacility(settlement: CheckedCreditFacility): LoweredNoun {
3542
+ const noun = settlement.name;
3543
+ const child = `${noun}_draw`;
3544
+ const facilityRef = `${camelize(noun)}Id`;
3545
+ const obligationRef = `${camelize(settlement.obligation.settlement)}Id`;
3546
+ const drawEvent = frameKey(`${noun}_draw`);
3547
+ const expiryRule = frameKey(`${noun}_expiry`);
3548
+ const countedStatuses =
3549
+ settlement.availabilityPolicy === "revolving"
3550
+ ? ["drawn"]
3551
+ : ["drawn", "resolved"];
3552
+ return {
3553
+ design: [
3554
+ `${noun}: owns reusable draw capacity only; ${settlement.obligation.settlement} remains the sole repayment and delinquency owner`,
3555
+ ],
3556
+ extraNouns: [
3557
+ {
3558
+ actors: {
3559
+ [settlement.drawDestination]: "beneficiary",
3560
+ [settlement.lender]: "payer",
3561
+ },
3562
+ desc: `One capacity-capped draw linked to ${settlement.obligation.settlement}`,
3563
+ fields: {
3564
+ amount: moneyFieldSpec("One draw amount"),
3565
+ currency: {
3566
+ desc: "Currency derived from the facility",
3567
+ type: "currency",
3568
+ },
3569
+ [facilityRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
3570
+ [obligationRef]: {
3571
+ desc: "The sole repayment obligation",
3572
+ type: `ref:${settlement.obligation.settlement}`,
3573
+ },
3574
+ },
3575
+ id: child,
3576
+ summary: `Draw from ${noun}`,
3577
+ title: `${titleize(noun)} Draw`,
3578
+ verbs: {
3579
+ create: {
3580
+ moneyEvent: drawEvent,
3581
+ moves: [
3582
+ {
3583
+ amount: "amount",
3584
+ from: settlement.lender,
3585
+ key: "draw",
3586
+ operation: "create",
3587
+ to: settlement.drawDestination,
3588
+ },
3589
+ ],
3590
+ requires: {
3591
+ [facilityRef]: {
3592
+ bind: {
3593
+ currency: "fields.currency",
3594
+ [`${camelize(settlement.drawDestination)}AccountId`]: `fields.${camelize(settlement.drawDestination)}AccountId`,
3595
+ [`${camelize(settlement.lender)}AccountId`]: `fields.${camelize(settlement.lender)}AccountId`,
3596
+ },
3597
+ statuses: ["active"],
3598
+ },
3599
+ [obligationRef]: { statuses: ["active"], unique: true },
3600
+ },
3601
+ requiresExposure: [
3602
+ {
3603
+ amountField: "amount",
3604
+ anchorField: facilityRef,
3605
+ capField: settlement.limit.name,
3606
+ capOnAnchor: true,
3607
+ childNounId: child,
3608
+ statuses: countedStatuses,
3609
+ },
3610
+ ],
3611
+ summary: "Create one draw under the locked facility capacity",
3612
+ to: "drawn",
3613
+ },
3614
+ resolve: {
3615
+ from: ["drawn"],
3616
+ requires: {
3617
+ [obligationRef]: ["repaid", "written_off"],
3618
+ },
3619
+ summary:
3620
+ "Release revolving capacity only after the linked obligation resolves",
3621
+ to: "resolved",
3622
+ },
3623
+ },
3624
+ },
3625
+ ],
3626
+ generatedPrefixNounIds: [child],
3627
+ feeLines: [],
3628
+ moneyEvents: [
3629
+ mintEvent({
3630
+ amount: "One draw under the stored facility limit",
3631
+ fromActor: settlement.lender,
3632
+ key: drawEvent,
3633
+ kind: "payout",
3634
+ occurrence: "repeatable",
3635
+ toActor: settlement.drawDestination,
3636
+ trigger: "Admit one linked draw",
3637
+ }),
3638
+ ],
3639
+ noun: {
3640
+ actors: {
3641
+ [settlement.borrower]: "party",
3642
+ [settlement.drawDestination]: "beneficiary",
3643
+ [settlement.lender]: "payer",
3644
+ },
3645
+ desc: "Reusable capacity with repayment delegated to one scheduled obligation",
3646
+ fields: {
3647
+ currency: {
3648
+ desc: `Currency fixed to ${settlement.limit.currency}`,
3649
+ type: "currency",
3650
+ },
3651
+ [settlement.limit.name]: moneyFieldSpec(
3652
+ `Facility limit in ${settlement.limit.currency} minor units`,
3653
+ ),
3654
+ [settlement.expiresAtField]: dateFieldSpec("Stored draw expiry"),
3655
+ },
3656
+ id: noun,
3657
+ summary: `Draw capacity for ${settlement.borrower.replaceAll("_", " ")}`,
3658
+ title: titleize(noun),
3659
+ verbs: {
3660
+ create: { summary: "Open the facility", to: "active" },
3661
+ freeze: {
3662
+ due: { field: settlement.expiresAtField, rule: expiryRule },
3663
+ from: ["active"],
3664
+ summary: "Freeze new draws at expiry",
3665
+ to: "frozen",
3666
+ },
3667
+ close: {
3668
+ from: ["active", "frozen"],
3669
+ requiresAggregate: [
3670
+ {
3671
+ check: { kind: "all_in" },
3672
+ nounId: child,
3673
+ over: "children",
3674
+ refField: facilityRef,
3675
+ statuses: ["resolved"],
3676
+ },
3677
+ ],
3678
+ summary: "Close only when every admitted draw resolved",
3679
+ to: "closed",
3680
+ },
3681
+ },
3682
+ },
3683
+ rules: [
3684
+ {
3685
+ allowedActors: [],
3686
+ detail:
3687
+ "The stored expiry freezes new draws without changing repayment state",
3688
+ dueDriven: true,
3689
+ enforcement: "platform",
3690
+ gatesEvent: null,
3691
+ key: expiryRule,
3692
+ kind: "deadline",
3693
+ label: "Facility freezes at expiry",
3694
+ tenantTunable: false,
3695
+ },
3696
+ ],
3697
+ settlement: { name: noun, pieces: [] },
3698
+ };
3699
+ }
3700
+
3701
+ // ---------------------------------------------------------------------------
3702
+ // conditional_disbursement: one evidence-gated amount under a stored cap
3703
+
3704
+ function lowerConditionalDisbursement(
3705
+ settlement: CheckedConditionalDisbursement,
3706
+ decision: CheckedPort,
3707
+ ): LoweredNoun {
3708
+ const noun = settlement.name;
3709
+ const child = `${noun}_approved_amount`;
3710
+ const parentRef = `${camelize(noun)}Id`;
3711
+ const payoutEvent = frameKey(`${noun}_payout`);
3712
+ const sourceAccountField = `${camelize(settlement.source)}AccountId`;
3713
+ const destinationAccountField = `${camelize(settlement.destination)}AccountId`;
3714
+ const parentTransitionRequirement = {
3715
+ [parentRef]: {
3716
+ match: {
3717
+ "fields.currency": "fields.currency",
3718
+ [`fields.${destinationAccountField}`]: `fields.${destinationAccountField}`,
3719
+ [`fields.${sourceAccountField}`]: `fields.${sourceAccountField}`,
3720
+ },
3721
+ statuses: ["submitted"],
3722
+ },
3723
+ };
3724
+ return {
3725
+ design: [
3726
+ `${noun}: a stored external decision may approve one amount under the cap; recovery requires a separate transfer`,
3727
+ ],
3728
+ extraNouns: [
3729
+ {
3730
+ actors: {
3731
+ [settlement.destination]: "beneficiary",
3732
+ [settlement.source]: "payer",
3733
+ },
3734
+ desc: `One evidence-gated amount under ${noun}`,
3735
+ fields: {
3736
+ amount: moneyFieldSpec("Approved amount under the parent cap"),
3737
+ currency: {
3738
+ desc: "Currency derived from the parent cap",
3739
+ type: "currency",
3740
+ },
3741
+ [parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
3742
+ },
3743
+ id: child,
3744
+ summary: `Approved amount under ${noun}`,
3745
+ title: `${titleize(noun)} Approved Amount`,
3746
+ verbs: {
3747
+ create: {
3748
+ requires: {
3749
+ [parentRef]: {
3750
+ bind: {
3751
+ currency: "fields.currency",
3752
+ [destinationAccountField]: `fields.${destinationAccountField}`,
3753
+ [sourceAccountField]: `fields.${sourceAccountField}`,
3754
+ },
3755
+ statuses: ["submitted"],
3756
+ unique: true,
3757
+ },
3758
+ },
3759
+ summary: "Create one candidate amount under the parent",
3760
+ to: "created",
3761
+ },
3762
+ approve: {
3763
+ captureInput: { decisionEvidenceReference: "evidenceReference" },
3764
+ from: ["created"],
3765
+ port: {
3766
+ allowed: decision.allowed,
3767
+ fields: { evidenceReference: "text" },
3768
+ },
3769
+ requires: parentTransitionRequirement,
3770
+ requiresExposure: [
3771
+ {
3772
+ amountField: "amount",
3773
+ anchorField: parentRef,
3774
+ capField: settlement.cap.name,
3775
+ capOnAnchor: true,
3776
+ childNounId: child,
3777
+ statuses: ["approved", "paid"],
3778
+ },
3779
+ ],
3780
+ summary: "Store one externally approved amount under the cap",
3781
+ to: "approved",
3782
+ },
3783
+ pay: {
3784
+ from: ["approved"],
3785
+ moneyEvent: payoutEvent,
3786
+ moves: [
3787
+ {
3788
+ amount: "amount",
3789
+ from: settlement.source,
3790
+ key: "payout",
3791
+ operation: "create",
3792
+ to: settlement.destination,
3793
+ },
3794
+ ],
3795
+ requires: parentTransitionRequirement,
3796
+ summary: "Pay the stored approved amount once",
3797
+ to: "paid",
3798
+ },
3799
+ },
3800
+ },
3801
+ ],
3802
+ generatedPrefixNounIds: [child],
3803
+ feeLines: [],
3804
+ moneyEvents: [
3805
+ mintEvent({
3806
+ amount: "The stored approved amount under the cap",
3807
+ fromActor: settlement.source,
3808
+ key: payoutEvent,
3809
+ kind: "payout",
3810
+ occurrence: "repeatable",
3811
+ toActor: settlement.destination,
3812
+ trigger: "Pay one approved amount",
3813
+ }),
3814
+ ],
3815
+ noun: {
3816
+ actors: {
3817
+ [settlement.destination]: "beneficiary",
3818
+ [settlement.source]: "payer",
3819
+ },
3820
+ desc: "Capped disbursement controlled by stored external evidence",
3821
+ fields: {
3822
+ currency: {
3823
+ desc: `Currency fixed to ${settlement.cap.currency}`,
3824
+ type: "currency",
3825
+ },
3826
+ [settlement.cap.name]: moneyFieldSpec(
3827
+ `Disbursement cap in ${settlement.cap.currency} minor units`,
3828
+ ),
3829
+ },
3830
+ id: noun,
3831
+ summary: `Capped disbursement to ${settlement.destination.replaceAll("_", " ")}`,
3832
+ title: titleize(noun),
3833
+ verbs: {
3834
+ create: { summary: "Submit the capped disbursement", to: "submitted" },
3835
+ deny: {
3836
+ captureInput: { decisionEvidenceReference: "evidenceReference" },
3837
+ from: ["submitted"],
3838
+ port: {
3839
+ allowed: decision.allowed,
3840
+ fields: { evidenceReference: "text" },
3841
+ },
3842
+ requiresAggregate: [
3843
+ {
3844
+ check: { kind: "all_in" },
3845
+ nounId: child,
3846
+ over: "children",
3847
+ refField: parentRef,
3848
+ statuses: ["created"],
3849
+ },
3850
+ ],
3851
+ summary: "Record a denial without moving money",
3852
+ to: "denied",
3853
+ },
3854
+ },
3855
+ },
3856
+ rules: [],
3857
+ settlement: { name: noun, pieces: [] },
3858
+ };
3859
+ }
3860
+
3861
+ // ---------------------------------------------------------------------------
3862
+ // rotating_pool: fixed roster, one contribution per member and cycle
3863
+
3864
+ function lowerRotatingPool(settlement: CheckedRotatingPool): LoweredNoun {
3865
+ const noun = settlement.name;
3866
+ const parentRef = `${camelize(noun)}Id`;
3867
+ const contributionEvent = frameKey(`${noun}_contribution`);
3868
+ const guaranteeEvent = frameKey(`${noun}_guarantee_contribution`);
3869
+ const payoutEvent = frameKey(`${noun}_payout`);
3870
+ const fixedActors = [
3871
+ ...new Set([
3872
+ ...settlement.members,
3873
+ ...settlement.payoutOrder,
3874
+ ...(settlement.guarantor ? [settlement.guarantor] : []),
3875
+ ]),
3876
+ ];
3877
+ const fixedActorBindings = Object.fromEntries(
3878
+ fixedActors.map((actor) => {
3879
+ const accountField = `${camelize(actor)}AccountId`;
3880
+ return [accountField, `fields.${accountField}`];
3881
+ }),
3882
+ );
3883
+ const childIds = settlement.members.map(
3884
+ (member) => `${noun}_${member}_contribution`,
3885
+ );
3886
+ const childNouns = settlement.members.map((member, memberIndex): Json => {
3887
+ const id = childIds[memberIndex] as string;
3888
+ const verbs: Json = {
3889
+ create: {
3890
+ requires: {
3891
+ [parentRef]: {
3892
+ bind: {
3893
+ currency: "fields.currency",
3894
+ ...fixedActorBindings,
3895
+ [settlement.schedule.firstDueField]:
3896
+ `fields.${settlement.schedule.firstDueField}`,
3897
+ [settlement.contribution.name]:
3898
+ `fields.${settlement.contribution.name}`,
3899
+ },
3900
+ statuses: ["forming"],
3901
+ unique: true,
3902
+ },
3903
+ },
3904
+ summary: `Create the fixed contribution row for ${member.replaceAll("_", " ")}`,
3905
+ to: "cycle_1_due",
3906
+ },
3907
+ };
3908
+ for (let index = 0; index < settlement.schedule.count; index += 1) {
3909
+ const cycle = index + 1;
3910
+ const dueState = `cycle_${cycle}_due`;
3911
+ const defaultState = `cycle_${cycle}_defaulted`;
3912
+ const fundedState = `cycle_${cycle}_funded`;
3913
+ const guaranteedState = `cycle_${cycle}_guaranteed`;
3914
+ const nextState =
3915
+ cycle === settlement.schedule.count
3916
+ ? "final_paid"
3917
+ : `cycle_${cycle + 1}_due`;
3918
+ const dueRule = frameKey(`${noun}_cycle_${cycle}_due`);
3919
+ const cycleAmount = settlement.contribution.name;
3920
+ const guaranteeAmount = settlement.contribution.name;
3921
+ const due = {
3922
+ field: settlement.schedule.firstDueField,
3923
+ rule: dueRule,
3924
+ ...anchorOffset(settlement.schedule, index),
3925
+ };
3926
+ verbs[`contribute_cycle_${cycle}`] = {
3927
+ due,
3928
+ from: [dueState],
3929
+ moneyEvent: contributionEvent,
3930
+ moves: [
3931
+ {
3932
+ amount: cycleAmount,
3933
+ from: member,
3934
+ key: "contribution",
3935
+ operation: "create",
3936
+ to: "escrow",
3937
+ },
3938
+ ],
3939
+ requires: { [parentRef]: [`active_cycle_${cycle}`] },
3940
+ summary: `Fund ${member.replaceAll("_", " ")}'s cycle ${cycle} contribution`,
3941
+ to: fundedState,
3942
+ };
3943
+ verbs[`mark_default_cycle_${cycle}`] = {
3944
+ due,
3945
+ from: [dueState],
3946
+ requires: { [parentRef]: [`active_cycle_${cycle}`] },
3947
+ summary: `Mark the stored cycle ${cycle} due condition`,
3948
+ to: defaultState,
3949
+ };
3950
+ if (settlement.guarantor) {
3951
+ verbs[`guarantee_cycle_${cycle}`] = {
3952
+ from: [defaultState],
3953
+ moneyEvent: guaranteeEvent,
3954
+ moves: [
3955
+ {
3956
+ amount: guaranteeAmount,
3957
+ from: settlement.guarantor,
3958
+ key: "guarantee",
3959
+ operation: "create",
3960
+ to: "escrow",
3961
+ },
3962
+ ],
3963
+ requires: { [parentRef]: [`active_cycle_${cycle}`] },
3964
+ summary: `Fund the defaulted cycle ${cycle} amount before payout`,
3965
+ to: guaranteedState,
3966
+ };
3967
+ }
3968
+ verbs[`pay_cycle_${cycle}`] = {
3969
+ from: [fundedState],
3970
+ moneyEvent: payoutEvent,
3971
+ moves: [
3972
+ {
3973
+ amount: cycleAmount,
3974
+ from: "escrow",
3975
+ key: "payout",
3976
+ operation: "create",
3977
+ to: settlement.payoutOrder[index],
3978
+ },
3979
+ ],
3980
+ requires: { [parentRef]: [`cycle_${cycle}_ready`] },
3981
+ summary: `Pay this member's stored contribution into cycle ${cycle}'s shared pot recipient`,
3982
+ to: nextState,
3983
+ };
3984
+ if (settlement.guarantor) {
3985
+ verbs[`pay_guaranteed_cycle_${cycle}`] = {
3986
+ from: [guaranteedState],
3987
+ moneyEvent: payoutEvent,
3988
+ moves: [
3989
+ {
3990
+ amount: guaranteeAmount,
3991
+ from: "escrow",
3992
+ key: "payout",
3993
+ operation: "create",
3994
+ to: settlement.payoutOrder[index],
3995
+ },
3996
+ ],
3997
+ requires: { [parentRef]: [`cycle_${cycle}_ready`] },
3998
+ summary: `Pay the funded default into cycle ${cycle}'s stored recipient`,
3999
+ to: nextState,
4000
+ };
4001
+ }
4002
+ }
4003
+ verbs.close = {
4004
+ from: ["final_paid"],
4005
+ requiresDrainedAccount: { path: "refs.escrowAccountId" },
4006
+ summary: "Complete after the final payout drains this member custody",
4007
+ to: "completed",
4008
+ };
4009
+ return {
4010
+ actors: Object.fromEntries([
4011
+ [member, "payer"],
4012
+ ...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
4013
+ ...settlement.payoutOrder.map((recipient) => [
4014
+ recipient,
4015
+ "beneficiary",
4016
+ ]),
4017
+ ]),
4018
+ desc: `Fixed contribution row for ${member.replaceAll("_", " ")}`,
4019
+ escrow: true,
4020
+ fields: {
4021
+ [settlement.contribution.name]: moneyFieldSpec(
4022
+ "Exact contribution amount shared by every cycle",
4023
+ ),
4024
+ currency: { desc: "Currency derived from the pool", type: "currency" },
4025
+ [settlement.schedule.firstDueField]: dateFieldSpec(
4026
+ "First due anchor derived from the pool",
4027
+ ),
4028
+ [parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
4029
+ },
4030
+ id,
4031
+ summary: `${member.replaceAll("_", " ")} contribution row`,
4032
+ title: `${titleize(noun)} ${titleize(member)} Contribution`,
4033
+ verbs,
4034
+ };
4035
+ });
4036
+ const parentVerbs: Json = {
4037
+ create: {
4038
+ summary: "Create the fixed roster before activation",
4039
+ to: "forming",
4040
+ },
4041
+ cancel: {
4042
+ from: ["forming"],
4043
+ summary: "Cancel before activation without moving money",
4044
+ to: "cancelled",
4045
+ },
4046
+ activate: {
4047
+ from: ["forming"],
4048
+ requiresAggregate: childIds.map((childId) => ({
4049
+ check: { kind: "count_equals_field", field: "one" },
4050
+ nounId: childId,
4051
+ over: "children",
4052
+ refField: parentRef,
4053
+ statuses: ["cycle_1_due"],
4054
+ })),
4055
+ summary: "Activate only after every fixed member row exists once",
4056
+ to: "active_cycle_1",
4057
+ },
4058
+ };
4059
+ for (let index = 0; index < settlement.schedule.count; index += 1) {
4060
+ const cycle = index + 1;
4061
+ parentVerbs[`ready_cycle_${cycle}`] = {
4062
+ from: [`active_cycle_${cycle}`],
4063
+ requiresAggregate: childIds.map((childId) => ({
4064
+ check: { kind: "all_in" },
4065
+ nounId: childId,
4066
+ over: "children",
4067
+ refField: parentRef,
4068
+ statuses: [
4069
+ `cycle_${cycle}_funded`,
4070
+ ...(settlement.guarantor ? [`cycle_${cycle}_guaranteed`] : []),
4071
+ ],
4072
+ })),
4073
+ summary: `Lock cycle ${cycle} only after every member row is funded or guaranteed`,
4074
+ to: `cycle_${cycle}_ready`,
4075
+ };
4076
+ parentVerbs[`advance_cycle_${cycle}`] = {
4077
+ from: [`cycle_${cycle}_ready`],
4078
+ requiresAggregate: childIds.map((childId) => ({
4079
+ check: { kind: "all_in" },
4080
+ nounId: childId,
4081
+ over: "children",
4082
+ refField: parentRef,
4083
+ statuses: [
4084
+ cycle === settlement.schedule.count
4085
+ ? "completed"
4086
+ : `cycle_${cycle + 1}_due`,
4087
+ ],
4088
+ })),
4089
+ summary:
4090
+ cycle === settlement.schedule.count
4091
+ ? "Complete after the final shared pot pays"
4092
+ : `Advance after every cycle ${cycle} contribution pays`,
4093
+ to:
4094
+ cycle === settlement.schedule.count
4095
+ ? "completed"
4096
+ : `active_cycle_${cycle + 1}`,
4097
+ };
4098
+ }
4099
+ return {
4100
+ design: [
4101
+ `${noun}: fixed roster and stored payout order; one member-specific row per member avoids tuple identity and keeps each cycle idempotent`,
4102
+ ],
4103
+ extraNouns: childNouns,
4104
+ generatedPrefixNounIds: childIds,
4105
+ feeLines: [],
4106
+ moneyEvents: [
4107
+ mintEvent({
4108
+ amount: "One exact member contribution",
4109
+ fromActor: settlement.members[0] as string,
4110
+ key: contributionEvent,
4111
+ kind: "charge",
4112
+ occurrence: "repeatable",
4113
+ toActor: "escrow",
4114
+ trigger: "Fund one member and cycle",
4115
+ }),
4116
+ ...(settlement.guarantor
4117
+ ? [
4118
+ mintEvent({
4119
+ amount: "One exact defaulted contribution",
4120
+ fromActor: settlement.guarantor,
4121
+ key: guaranteeEvent,
4122
+ kind: "charge",
4123
+ occurrence: "repeatable",
4124
+ toActor: "escrow",
4125
+ trigger: "Fund one defaulted member and cycle",
4126
+ }),
4127
+ ]
4128
+ : []),
4129
+ mintEvent({
4130
+ amount: "One exact member contribution from the cycle pot",
4131
+ fromActor: "escrow",
4132
+ key: payoutEvent,
4133
+ kind: "payout",
4134
+ occurrence: "repeatable",
4135
+ toActor: settlement.payoutOrder[0] as string,
4136
+ trigger: "Pay the stored cycle recipient",
4137
+ }),
4138
+ ],
4139
+ noun: {
4140
+ actors: Object.fromEntries([
4141
+ ...settlement.members.map((member) => [member, "party"]),
4142
+ ...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
4143
+ ]),
4144
+ desc: "Fixed rotating contribution and payout order",
4145
+ fields: {
4146
+ currency: {
4147
+ desc: `Currency fixed to ${settlement.contribution.currency}`,
4148
+ type: "currency",
4149
+ },
4150
+ [settlement.contribution.name]: moneyFieldSpec(
4151
+ `Exact contribution in ${settlement.contribution.currency} minor units`,
4152
+ ),
4153
+ [settlement.schedule.firstDueField]: dateFieldSpec(
4154
+ "Stored first contribution due date",
4155
+ ),
4156
+ one: { desc: "Exact fixed member-row count", type: "const:1" },
4157
+ },
4158
+ id: noun,
4159
+ summary: `${settlement.members.length}-member rotating pool`,
4160
+ title: titleize(noun),
4161
+ verbs: parentVerbs,
4162
+ },
4163
+ rules: Array.from({ length: settlement.schedule.count }, (_, index) => ({
4164
+ allowedActors: [],
4165
+ detail: `Cycle ${index + 1} default follows its stored due condition`,
4166
+ dueDriven: true,
4167
+ enforcement: "platform",
4168
+ gatesEvent: null,
4169
+ key: frameKey(`${noun}_cycle_${index + 1}_due`),
4170
+ kind: "deadline",
4171
+ label: `Cycle ${index + 1} due condition`,
4172
+ tenantTunable: false,
4173
+ })),
4174
+ settlement: { name: noun, pieces: [] },
4175
+ };
4176
+ }
4177
+
4178
+ function anchorOffset(schedule: ScheduleTerms, index: number): Json {
4179
+ return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
4180
+ }
4181
+
4182
+ /**
4183
+ * Obligation mode extends the existing schedule instead of minting a second
4184
+ * repayment archetype. The parent stores every anchor amount and date. One
4185
+ * generated payment noun per anchor makes matching exact at the operation
4186
+ * boundary and lets the generic aggregate lock cap concurrent partial pays.
4187
+ */
4188
+ function lowerScheduledObligation(
4189
+ settlement: CheckedScheduledObligation,
4190
+ collection?: CheckedRecurringCollection,
4191
+ collectionMandate?: CheckedPort,
4192
+ ): LoweredNoun {
4193
+ const noun = settlement.name;
4194
+ const amountName = settlement.amount.name;
4195
+ const obligationIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}Id`;
4196
+ const widths = evenPieceBps(settlement.schedule.count);
4197
+ const installmentFields = widths.map(
4198
+ (_, index) => `installment${index + 1}Amount`,
4199
+ );
4200
+ const paymentNouns = widths.map(
4201
+ (_, index) => `${noun}_installment_${index + 1}_payment`,
4202
+ );
4203
+ const activeState = "active";
4204
+ const delinquentState = (index: number) =>
4205
+ `installment_${index + 1}_delinquent`;
4206
+ const delinquentStates = widths.map((_, index) => delinquentState(index));
4207
+ const liveStates = [activeState, ...delinquentStates];
4208
+ const repaymentEvent = frameKey(`${noun}_repayment`);
4209
+ const refundEvent = frameKey(`${noun}_refund`);
4210
+ const advanceEvent = frameKey(`${noun}_advance`);
4211
+
4212
+ const fields: Json = {
4213
+ currency: {
4214
+ desc: `Currency fixed to ${settlement.amount.currency}`,
4215
+ type: "currency",
4216
+ },
4217
+ [amountName]: moneyFieldSpec(
4218
+ `The principal in ${settlement.amount.currency} minor units; the stored installment anchors partition it exactly`,
4219
+ ),
4220
+ [settlement.schedule.firstDueField]: dateFieldSpec(
4221
+ `Due date of the first installment; later anchors use fixed offsets of ${settlement.schedule.every.raw}`,
4222
+ ),
4223
+ };
4224
+ for (const [index, field] of installmentFields.entries()) {
4225
+ fields[field] = moneyFieldSpec(
4226
+ `Stored amount for installment ${index + 1} of ${settlement.schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}`,
4227
+ );
4228
+ fields[`installment${index + 1}DelinquentAfter`] = optionalDateFieldSpec(
4229
+ `Machine-set marker proving installment ${index + 1} reached its stored due date while unpaid`,
4230
+ );
4231
+ }
4232
+
4233
+ const aggregateInvariants = paymentNouns.map((paymentNoun, index) => ({
4234
+ childField: "amount",
4235
+ childNounId: paymentNoun,
4236
+ childRefField: obligationIdField,
4237
+ childStatuses: ["paid"],
4238
+ parentField: installmentFields[index],
4239
+ }));
4240
+
4241
+ const verbs: Json = {
4242
+ create: {
4243
+ summary: `Create a ${titleize(noun).toLowerCase()} obligation`,
4244
+ to: "draft",
1719
4245
  },
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",
4246
+ approve: {
4247
+ from: ["draft"],
4248
+ summary: "Approve the immutable principal partition and stored anchors",
4249
+ to: settlement.advanceTo ? "approved" : activeState,
1734
4250
  },
1735
- [claim.name]: {
1736
- from: ["held"],
1737
- moves: [
1738
- {
1739
- key: "post",
1740
- operation: "post",
1741
- reservation: "place_deposit_reservation",
1742
- },
4251
+ ...(settlement.advanceTo
4252
+ ? {
4253
+ advance: {
4254
+ from: ["approved"],
4255
+ moneyEvent: advanceEvent,
4256
+ moves: [
4257
+ {
4258
+ amount: amountName,
4259
+ from: settlement.payee,
4260
+ key: "advance",
4261
+ operation: "create",
4262
+ to: settlement.advanceTo,
4263
+ },
4264
+ ],
4265
+ summary: `Advance the principal to the ${settlement.advanceTo.replaceAll("_", " ")}; the internal ledger receipt is the confirmation`,
4266
+ to: activeState,
4267
+ },
4268
+ }
4269
+ : {}),
4270
+ write_off: {
4271
+ from: [
4272
+ "draft",
4273
+ ...(settlement.advanceTo ? ["approved"] : []),
4274
+ ...liveStates,
1743
4275
  ],
1744
- summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
1745
- to: "claimed",
4276
+ summary: "Write off the remaining exposure without moving money",
4277
+ to: "written_off",
1746
4278
  },
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",
4279
+ };
4280
+
4281
+ const rules: Json[] = [];
4282
+ for (const [index, paymentNoun] of paymentNouns.entries()) {
4283
+ const anchor = index + 1;
4284
+ const ruleKey = frameKey(`${noun}_installment_${anchor}_due`);
4285
+ const due = {
4286
+ field: settlement.schedule.firstDueField,
4287
+ rule: ruleKey,
4288
+ ...anchorOffset(settlement.schedule, index),
4289
+ };
4290
+ const aggregate = (kind: "sum_below" | "sum_exactly"): Json[] => [
4291
+ {
4292
+ check: {
4293
+ amountField: "amount",
4294
+ kind,
4295
+ targetField: installmentFields[index],
1757
4296
  },
1758
- ],
4297
+ nounId: paymentNoun,
4298
+ over: "children",
4299
+ refField: obligationIdField,
4300
+ statuses: ["paid"],
4301
+ },
4302
+ ];
4303
+ verbs[`mark_installment_${anchor}_delinquent`] = {
4304
+ due,
4305
+ from: liveStates.filter((state) => state !== delinquentState(index)),
4306
+ requiresAggregate: aggregate("sum_below"),
4307
+ setsAt: {
4308
+ field: `installment${anchor}DelinquentAfter`,
4309
+ marker: true,
4310
+ offset: "PT1S",
4311
+ },
4312
+ summary: `Mark installment ${anchor} delinquent only when its due anchor is unmet`,
4313
+ to: delinquentState(index),
4314
+ };
4315
+ verbs[`collect_installment_${anchor}`] = {
4316
+ due,
4317
+ from: delinquentStates,
4318
+ requiresAggregate: aggregate("sum_exactly"),
4319
+ summary: `Close delinquent installment ${anchor} after linked payments reach its stored amount`,
4320
+ to: activeState,
4321
+ };
4322
+ rules.push({
4323
+ allowedActors: [],
4324
+ detail: `At stored anchor ${anchor}, the platform compares paid child rows with ${installmentFields[index]} and chooses paid or delinquent`,
4325
+ dueDriven: true,
4326
+ enforcement: "platform",
4327
+ gatesEvent: null,
4328
+ key: ruleKey,
4329
+ kind: "deadline",
4330
+ label: `Installment ${anchor} resolves from its stored due condition`,
4331
+ tenantTunable: false,
4332
+ });
4333
+ }
4334
+
4335
+ const completionRuleKey = frameKey(`${noun}_completion_due`);
4336
+ verbs.complete = {
4337
+ due: {
4338
+ field: settlement.schedule.firstDueField,
4339
+ rule: completionRuleKey,
4340
+ ...anchorOffset(settlement.schedule, widths.length - 1),
1759
4341
  },
4342
+ from: liveStates,
4343
+ requiresAggregate: paymentNouns.map((paymentNoun, index) => ({
4344
+ check: {
4345
+ amountField: "amount",
4346
+ kind: "sum_exactly",
4347
+ targetField: installmentFields[index],
4348
+ },
4349
+ nounId: paymentNoun,
4350
+ over: "children",
4351
+ refField: obligationIdField,
4352
+ statuses: ["paid"],
4353
+ })),
4354
+ summary:
4355
+ "Close the obligation only after every stored anchor is paid exactly",
4356
+ to: "repaid",
1760
4357
  };
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",
4358
+ rules.push({
4359
+ allowedActors: [],
4360
+ detail:
4361
+ "After the final stored anchor, the platform closes only when every anchor is paid exactly",
4362
+ dueDriven: true,
4363
+ enforcement: "platform",
1767
4364
  gatesEvent: null,
1768
- key: frameKey(`${noun}_${port.name}_gate`),
1769
- kind: "release_condition",
1770
- label: `${verbLabel} decided through ${port.name}`,
4365
+ key: completionRuleKey,
4366
+ kind: "deadline",
4367
+ label: "Obligation completion follows exact aggregate repayment",
1771
4368
  tenantTunable: false,
1772
4369
  });
1773
4370
 
4371
+ const paymentNoun = (index: number): Json => {
4372
+ const anchor = index + 1;
4373
+ const id = paymentNouns[index] as string;
4374
+ const permittedParentStates = liveStates;
4375
+ const payerAccountField = `${settlement.payer.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}AccountId`;
4376
+ const payeeAccountField = `${settlement.payee.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}AccountId`;
4377
+ const createRequirement = {
4378
+ [obligationIdField]: {
4379
+ bind: {
4380
+ currency: "fields.currency",
4381
+ [payerAccountField]: `fields.${payerAccountField}`,
4382
+ [payeeAccountField]: `fields.${payeeAccountField}`,
4383
+ },
4384
+ statuses: permittedParentStates,
4385
+ },
4386
+ };
4387
+ const transitionRequirement = {
4388
+ [obligationIdField]: {
4389
+ match: {
4390
+ "fields.currency": "fields.currency",
4391
+ [`fields.${payerAccountField}`]: `fields.${payerAccountField}`,
4392
+ [`fields.${payeeAccountField}`]: `fields.${payeeAccountField}`,
4393
+ },
4394
+ statuses: permittedParentStates,
4395
+ },
4396
+ };
4397
+ return {
4398
+ actors: {
4399
+ [settlement.payee]: "beneficiary",
4400
+ [settlement.payer]: "payer",
4401
+ },
4402
+ desc: `One partial or full payment bound to installment ${anchor} of ${noun}; the operation name fixes the anchor and ${obligationIdField} fixes the obligation`,
4403
+ fields: {
4404
+ amount: moneyFieldSpec(
4405
+ `Positive payment amount capped with its paid siblings at ${installmentFields[index]}`,
4406
+ ),
4407
+ currency: {
4408
+ desc: "ISO 4217 currency derived from the obligation",
4409
+ type: "currency",
4410
+ },
4411
+ [obligationIdField]: {
4412
+ desc: `The exact ${noun.replaceAll("_", " ")} this payment belongs to`,
4413
+ type: `ref:${noun}`,
4414
+ },
4415
+ },
4416
+ id,
4417
+ summary: `Anchor-bound payment for installment ${anchor}`,
4418
+ title: `${titleize(noun)} Installment ${anchor} Payment`,
4419
+ verbs: {
4420
+ create: {
4421
+ requires: createRequirement,
4422
+ summary: `Create a payment record for installment ${anchor}`,
4423
+ to: "created",
4424
+ },
4425
+ repay: {
4426
+ ...(collection && collectionMandate
4427
+ ? {
4428
+ captureInput: {
4429
+ mandateEvidenceReference: "evidenceReference",
4430
+ },
4431
+ port: {
4432
+ allowed: collectionMandate.allowed,
4433
+ fields: { evidenceReference: "text" },
4434
+ },
4435
+ }
4436
+ : {}),
4437
+ from: ["created"],
4438
+ moneyEvent: repaymentEvent,
4439
+ moves: [
4440
+ {
4441
+ amount: "amount",
4442
+ from: settlement.payer,
4443
+ key: "repayment",
4444
+ operation: "create",
4445
+ to: settlement.payee,
4446
+ },
4447
+ ],
4448
+ requires: transitionRequirement,
4449
+ requiresExposure: [
4450
+ {
4451
+ amountField: "amount",
4452
+ anchorField: obligationIdField,
4453
+ capField: installmentFields[index],
4454
+ capOnAnchor: true,
4455
+ childNounId: id,
4456
+ statuses: ["paid"],
4457
+ },
4458
+ ],
4459
+ summary: `Pay a partial or full amount against installment ${anchor}`,
4460
+ to: "paid",
4461
+ },
4462
+ refund: {
4463
+ from: ["paid"],
4464
+ moneyEvent: refundEvent,
4465
+ moves: [
4466
+ {
4467
+ amount: "amount",
4468
+ from: settlement.payee,
4469
+ key: "refund",
4470
+ operation: "create",
4471
+ to: settlement.payer,
4472
+ },
4473
+ ],
4474
+ requires: transitionRequirement,
4475
+ summary: `Refund this one stored installment ${anchor} payment whole`,
4476
+ to: "refunded",
4477
+ },
4478
+ },
4479
+ };
4480
+ };
4481
+
1774
4482
  return {
1775
4483
  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}`,
4484
+ `${noun}: existing scheduled mechanism in obligation mode; principal partitions into ${settlement.schedule.count} stored anchors; each payment operation names one anchor and one obligation`,
4485
+ `${noun}: partial and early payments serialize under per-anchor aggregate caps; each refund reverses one paid row whole; rescheduling is refused by the checker`,
4486
+ `${noun}: due-only delinquency and write-off change state without money; ${settlement.advanceTo ? "advance is an internal ledger movement with no provider claim" : "no advance is emitted"}`,
4487
+ ...(collection
4488
+ ? [
4489
+ `${collection.name}: explicit collection attempts reuse ${noun}'s anchor-bound repayment verbs; mandate evidence is captured per attempt; failures remain receipted failures and delinquency stays on ${noun}`,
4490
+ ]
4491
+ : []),
1777
4492
  ],
4493
+ extraNouns: paymentNouns.map((_, index) => paymentNoun(index)),
4494
+ generatedPrefixNounIds: paymentNouns,
1778
4495
  feeLines: [],
1779
- moneyEvents: events,
4496
+ moneyEvents: [
4497
+ ...(settlement.advanceTo
4498
+ ? [
4499
+ mintEvent({
4500
+ amount: `The full ${amountName}`,
4501
+ fromActor: settlement.payee,
4502
+ key: advanceEvent,
4503
+ kind: "payout",
4504
+ toActor: settlement.advanceTo,
4505
+ trigger: "Advance the approved principal once",
4506
+ }),
4507
+ ]
4508
+ : []),
4509
+ mintEvent({
4510
+ amount: "A positive amount capped by its stored installment anchor",
4511
+ fromActor: settlement.payer,
4512
+ key: repaymentEvent,
4513
+ kind: "installment",
4514
+ occurrence: "repeatable",
4515
+ toActor: settlement.payee,
4516
+ trigger: "Pay one anchor-bound partial or full installment amount",
4517
+ }),
4518
+ mintEvent({
4519
+ amount: "Exactly one stored paid installment payment",
4520
+ fromActor: settlement.payee,
4521
+ key: refundEvent,
4522
+ kind: "refund",
4523
+ occurrence: "repeatable",
4524
+ toActor: settlement.payer,
4525
+ trigger: "Refund one linked paid installment payment whole",
4526
+ }),
4527
+ ],
1780
4528
  noun: {
1781
4529
  actors: {
4530
+ ...(settlement.advanceTo
4531
+ ? { [settlement.advanceTo]: "beneficiary" }
4532
+ : {}),
4533
+ [settlement.payee]: "beneficiary",
1782
4534
  [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
- ),
4535
+ [settlement.debtor]: "party",
1790
4536
  },
4537
+ aggregateInvariants,
4538
+ desc: `Installment obligation for ${settlement.debtor.replaceAll("_", " ")}; ${settlement.payer.replaceAll("_", " ")} pays ${settlement.payee.replaceAll("_", " ")} against exact stored anchors`,
4539
+ fields,
1791
4540
  id: noun,
1792
- summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
4541
+ ...partitionsSpread(partitionClause(amountName, installmentFields)),
4542
+ summary: `${settlement.schedule.count}-anchor obligation for ${settlement.debtor.replaceAll("_", " ")}`,
1793
4543
  title: titleize(noun),
1794
4544
  verbs,
1795
4545
  },
1796
- rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
4546
+ rules,
1797
4547
  settlement: { name: noun, pieces: [] },
1798
4548
  };
1799
4549
  }
1800
4550
 
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
4551
  function lowerScheduled(settlement: CheckedScheduled): LoweredNoun {
1817
4552
  const noun = settlement.name;
1818
4553
  const amountName = settlement.amount.name;
@@ -1921,9 +4656,12 @@ function lowerScheduled(settlement: CheckedScheduled): LoweredNoun {
1921
4656
  };
1922
4657
  }
1923
4658
 
1924
- function lowerAdvance(settlement: CheckedAdvance): LoweredNoun {
4659
+ function lowerAdvance(
4660
+ settlement: CheckedAdvance,
4661
+ recourses: readonly CheckedScheduled[],
4662
+ ): LoweredNoun {
1925
4663
  return settlement.source.kind === "carve"
1926
- ? lowerCarvedAdvance(settlement, settlement.source.settlement)
4664
+ ? lowerCarvedAdvance(settlement, settlement.source.settlement, recourses)
1927
4665
  : lowerScheduledAdvance(settlement, settlement.source.schedule);
1928
4666
  }
1929
4667
 
@@ -1937,6 +4675,7 @@ function lowerAdvance(settlement: CheckedAdvance): LoweredNoun {
1937
4675
  function lowerCarvedAdvance(
1938
4676
  settlement: CheckedAdvance,
1939
4677
  hold: string,
4678
+ recourses: readonly CheckedScheduled[],
1940
4679
  ): LoweredNoun {
1941
4680
  const noun = settlement.name;
1942
4681
  const amountName = settlement.amount.name;
@@ -1944,11 +4683,29 @@ function lowerCarvedAdvance(
1944
4683
  const advancedWords = settlement.advanced.replaceAll("_", " ");
1945
4684
  const funderWords = settlement.funder.replaceAll("_", " ");
1946
4685
  const holdWords = hold.replaceAll("_", " ");
4686
+ const holdRefField = "carveHoldId";
4687
+ const referenceBindings = [
4688
+ { field: holdRefField, statuses: ["funded"], target: hold },
4689
+ ...recourses.map((recourse, index) => ({
4690
+ field: `carveRecourse${index + 1}Id`,
4691
+ statuses: ["active"],
4692
+ target: recourse.name,
4693
+ })),
4694
+ ];
1947
4695
 
1948
4696
  const fields: Json = {
1949
4697
  [amountName]: moneyFieldSpec(
1950
4698
  `The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${advancedWords} up front`,
1951
4699
  ),
4700
+ ...Object.fromEntries(
4701
+ referenceBindings.map((binding) => [
4702
+ binding.field,
4703
+ {
4704
+ desc: `The ${binding.target.replaceAll("_", " ")} bound to this advance`,
4705
+ type: `ref:${binding.target}`,
4706
+ },
4707
+ ]),
4708
+ ),
1952
4709
  ...(hasFee
1953
4710
  ? {
1954
4711
  feeAmount: moneyFieldSpec(
@@ -2016,6 +4773,18 @@ function lowerCarvedAdvance(
2016
4773
  to: settlement.advanced,
2017
4774
  },
2018
4775
  ],
4776
+ requires: Object.fromEntries(
4777
+ referenceBindings.map((binding) => [
4778
+ binding.field,
4779
+ {
4780
+ match: {
4781
+ [`fields.${amountName}`]: `fields.${amountName}`,
4782
+ "fields.currency": "fields.currency",
4783
+ },
4784
+ statuses: binding.statuses,
4785
+ },
4786
+ ]),
4787
+ ),
2019
4788
  summary: `Disburse the ${amountName} to the ${advancedWords}`,
2020
4789
  to: "advanced",
2021
4790
  },
@@ -2582,6 +5351,8 @@ function summarize(program: CheckedProgram): string {
2582
5351
  : `the ${settlement.payee.replaceAll("_", " ")} is paid on confirmed release`;
2583
5352
  return `The ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into escrow and ${paid}${cancel}`;
2584
5353
  }
5354
+ case "captured_payment":
5355
+ return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved until ${settlement.reserveUntilField}, captured by the ${settlement.payee.replaceAll("_", " ")} in strict partial slices, then settled or released`;
2585
5356
  case "instant_transfer":
2586
5357
  return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} straight through to the ${settlement.payee.replaceAll("_", " ")}`;
2587
5358
  case "premium_forward":
@@ -2589,7 +5360,9 @@ function summarize(program: CheckedProgram): string {
2589
5360
  case "deposit":
2590
5361
  return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved for the ${settlement.holder.replaceAll("_", " ")} until claimed or returned`;
2591
5362
  case "scheduled":
2592
- return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
5363
+ return settlement.mode === "obligation"
5364
+ ? `The ${settlement.debtor.replaceAll("_", " ")} owes ${settlement.amount.name}; ${settlement.advanceTo ? `the ${settlement.payee.replaceAll("_", " ")} advances it to the ${settlement.advanceTo.replaceAll("_", " ")}, then ` : ""}the ${settlement.payer.replaceAll("_", " ")} repays the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} anchor-bound installments`
5365
+ : `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
2593
5366
  case "advance":
2594
5367
  return settlement.source.kind === "carve"
2595
5368
  ? `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid out of the ${settlement.source.settlement.replaceAll("_", " ")} release`
@@ -2598,6 +5371,20 @@ function summarize(program: CheckedProgram): string {
2598
5371
  return `The ${settlement.payer.replaceAll("_", " ")} is charged per metered unit at a committed rate card until the period closes`;
2599
5372
  case "pooled_split":
2600
5373
  return `The ${settlement.payer.replaceAll("_", " ")} pools ${settlement.amount.name} and it distributes ${settlement.shares.length} ways on the payout date`;
5374
+ case "settlement_batch":
5375
+ return `The ${settlement.settlementAccount.replaceAll("_", " ")} freezes capture lineage and pays one signed net amount to the ${settlement.payoutDestination.replaceAll("_", " ")}`;
5376
+ case "funding_round":
5377
+ return `The ${settlement.contributor.replaceAll("_", " ")} commits under ${settlement.target.name} until the stored close anchor activates or fails the round`;
5378
+ case "weighted_distribution":
5379
+ return `The ${settlement.source.replaceAll("_", " ")} pays a frozen claimant set by deterministic largest remainder`;
5380
+ case "credit_facility":
5381
+ return `The ${settlement.lender.replaceAll("_", " ")} admits draws under ${settlement.limit.name}; ${settlement.obligation.settlement.replaceAll("_", " ")} owns repayment`;
5382
+ case "recurring_collection":
5383
+ return `${settlement.name.replaceAll("_", " ")} adds explicit mandate evidence to ${settlement.obligation.settlement.replaceAll("_", " ")} repayment attempts`;
5384
+ case "conditional_disbursement":
5385
+ return `The ${settlement.source.replaceAll("_", " ")} pays one evidence-approved amount under ${settlement.cap.name} to the ${settlement.destination.replaceAll("_", " ")}`;
5386
+ case "rotating_pool":
5387
+ return `${settlement.members.length} fixed members contribute one exact amount per cycle in a stored payout order`;
2601
5388
  case "swap":
2602
5389
  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
5390
  }