@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/check.ts CHANGED
@@ -14,6 +14,11 @@ import type {
14
14
  Program,
15
15
  Span,
16
16
  } from "./ast.ts";
17
+ import {
18
+ ARCHETYPE_DEFINITIONS,
19
+ type ArchetypeName,
20
+ SETTLEMENT_ARCHETYPES,
21
+ } from "./archetypes.ts";
17
22
  import {
18
23
  ASSET_KINDS,
19
24
  PARTY_KINDS,
@@ -23,9 +28,20 @@ import {
23
28
  type CheckDiagnostic,
24
29
  type CheckResult,
25
30
  type CheckedAsset,
31
+ type CheckedCaptureReservation,
32
+ type CheckedConditionalDisbursement,
33
+ type CheckedCreditFacility,
34
+ type CheckedDerivedAmount,
35
+ type CheckedFundingRound,
26
36
  type CheckedParty,
27
37
  type CheckedPort,
38
+ type CheckedPremiumForward,
39
+ type CheckedSettlementReference,
28
40
  type CheckedSettlement,
41
+ type CheckedSettlementBatch,
42
+ type CheckedRecurringCollection,
43
+ type CheckedRotatingPool,
44
+ type CheckedWeightedDistribution,
29
45
  type FeeTerm,
30
46
  type MeterRate,
31
47
  type MoneyField,
@@ -37,75 +53,7 @@ import {
37
53
  type SwapFee,
38
54
  } from "./model.ts";
39
55
 
40
- /** Every archetype the settlement stdlib ships; all of them lower today. */
41
- export const SETTLEMENT_ARCHETYPES = [
42
- "advance",
43
- "deposit",
44
- "held_payment",
45
- "instant_transfer",
46
- "metered",
47
- "pooled_split",
48
- "premium_forward",
49
- "scheduled",
50
- "swap",
51
- ] as const;
52
-
53
- type ArchetypeName = (typeof SETTLEMENT_ARCHETYPES)[number];
54
-
55
- /** Each archetype's parameter surface: the entries its body understands. */
56
- const ARCHETYPE_SURFACES: Record<
57
- ArchetypeName,
58
- { readonly keys: readonly string[]; readonly required: readonly string[] }
59
- > = {
60
- advance: {
61
- keys: [
62
- "against",
63
- "amount",
64
- "count",
65
- "every",
66
- "fee",
67
- "first_due",
68
- "funder",
69
- "to",
70
- ],
71
- // The repayment source is one of two shapes (a schedule, or a carve out of
72
- // a hold's release), so the advance case below states that requirement
73
- // itself rather than listing either shape's keys here.
74
- required: ["funder", "to", "amount"],
75
- },
76
- deposit: {
77
- keys: ["amount", "claim", "holder", "payer", "return"],
78
- required: ["payer", "holder", "amount", "claim", "return"],
79
- },
80
- held_payment: {
81
- keys: ["amount", "fees", "on_cancel", "payer", "payee", "release"],
82
- required: ["payer", "payee", "amount", "release"],
83
- },
84
- instant_transfer: {
85
- keys: ["amount", "fees", "payer", "payee"],
86
- required: ["payer", "payee", "amount"],
87
- },
88
- metered: {
89
- keys: ["close_by", "payer", "payee", "rates"],
90
- required: ["payer", "payee", "rates", "close_by"],
91
- },
92
- pooled_split: {
93
- keys: ["amount", "payer", "payout_due", "split"],
94
- required: ["payer", "amount", "split", "payout_due"],
95
- },
96
- premium_forward: {
97
- keys: ["amount", "bind", "carrier", "commission", "on_cancel", "payer"],
98
- required: ["payer", "carrier", "amount", "bind"],
99
- },
100
- scheduled: {
101
- keys: ["amount", "count", "every", "first_due", "payer", "payee"],
102
- required: ["payer", "payee", "amount", "count", "every", "first_due"],
103
- },
104
- swap: {
105
- keys: ["amounts", "between", "dispute", "fees", "release"],
106
- required: ["between", "amounts", "release"],
107
- },
108
- };
56
+ export { SETTLEMENT_ARCHETYPES } from "./archetypes.ts";
109
57
 
110
58
  /** The three terms that together declare a finite schedule. */
111
59
  const SCHEDULE_KEYS = ["count", "every", "first_due"] as const;
@@ -124,6 +72,7 @@ export function checkProgram(program: Program): CheckResult {
124
72
  const warning = (span: Span, message: string): void => {
125
73
  diagnostics.push({ message, severity: "warning", span });
126
74
  };
75
+ const derivedAmounts: CheckedDerivedAmount[] = [];
127
76
 
128
77
  // --- Header ---------------------------------------------------------------
129
78
  const headers = program.decls.filter((decl) => decl.kind === "program");
@@ -338,6 +287,7 @@ export function checkProgram(program: Program): CheckResult {
338
287
  /^piece\d+Amount$/,
339
288
  /^installment\d+Amount$/,
340
289
  /^repayment\d+Amount$/,
290
+ /^carve(?:Hold|Recourse\d+)Id$/,
341
291
  /ShareAmount$/,
342
292
  /AccountId$/,
343
293
  ];
@@ -464,6 +414,7 @@ export function checkProgram(program: Program): CheckResult {
464
414
  const parseDisputeEntry = (
465
415
  entry: Entry | undefined,
466
416
  owner: string,
417
+ label = "dispute",
467
418
  ):
468
419
  | {
469
420
  readonly origin: Span;
@@ -477,35 +428,35 @@ export function checkProgram(program: Program): CheckResult {
477
428
  if (value.kind !== "port_ref") {
478
429
  error(
479
430
  value.span,
480
- `${owner} needs one whole-trade dispute decision, like: dispute: port resolve_dispute within P14D`,
431
+ `${owner} needs one ${label} decision through a fixed windowed port`,
481
432
  );
482
433
  return undefined;
483
434
  }
484
435
  if (!portNames.has(value.name.name)) {
485
436
  error(
486
437
  value.name.span,
487
- `${owner} disputes through port ${value.name.name}, but no port with that name is declared`,
438
+ `${owner} routes ${label} through port ${value.name.name}, but no port with that name is declared`,
488
439
  );
489
440
  return undefined;
490
441
  }
491
442
  if (!value.within) {
492
443
  error(
493
444
  value.span,
494
- `${owner} dispute needs a fixed window, like: dispute: port ${value.name.name} within P14D`,
445
+ `${owner} ${label} needs a fixed window, like: ${entry.key.name}: port ${value.name.name} within P14D`,
495
446
  );
496
447
  return undefined;
497
448
  }
498
449
  const raw = value.within.name;
499
450
  const match = /^P([1-9]\d{0,3})([DW])$/.exec(raw);
500
- if (raw !== "P0D" && !match) {
451
+ if (!match) {
501
452
  error(
502
453
  value.within.span,
503
- `dispute on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`,
454
+ `${label} on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`,
504
455
  );
505
456
  return undefined;
506
457
  }
507
- const magnitude = match ? Number(match[1]) : 0;
508
- const days = match?.[2] === "W" ? magnitude * 7 : magnitude;
458
+ const magnitude = Number(match[1]);
459
+ const days = match[2] === "W" ? magnitude * 7 : magnitude;
509
460
  referencedPorts.add(value.name.name);
510
461
  return {
511
462
  origin: value.span,
@@ -771,10 +722,98 @@ export function checkProgram(program: Program): CheckResult {
771
722
  return undefined;
772
723
  };
773
724
 
725
+ const parseFieldName = (
726
+ entry: Entry | undefined,
727
+ owner: string,
728
+ ): string | undefined => {
729
+ if (!entry) return undefined;
730
+ noQualifiers(entry, owner);
731
+ if (entry.value.kind === "ident" && CAMEL_CASE.test(entry.value.name)) {
732
+ return reservedFieldName(entry.value.name, entry.value.span, owner)
733
+ ? undefined
734
+ : entry.value.name;
735
+ }
736
+ error(
737
+ entry.value.span,
738
+ `${entry.key.name} on ${owner} names a camelCase lineage field, like: ${entry.key.name}: captureReference`,
739
+ );
740
+ return undefined;
741
+ };
742
+
743
+ const requireIdentPolicy = (
744
+ entry: Entry | undefined,
745
+ owner: string,
746
+ expected: string,
747
+ refusal: string,
748
+ ): boolean => {
749
+ if (!entry) return false;
750
+ noQualifiers(entry, owner);
751
+ if (entry.value.kind === "ident" && entry.value.name === expected) {
752
+ return true;
753
+ }
754
+ error(
755
+ entry.value.span,
756
+ `${entry.key.name} on ${owner} is ${expected}; ${refusal}`,
757
+ );
758
+ return false;
759
+ };
760
+
761
+ const parseLiteralCount = (
762
+ entry: Entry | undefined,
763
+ owner: string,
764
+ label: string,
765
+ minimum: number,
766
+ maximum: number,
767
+ ): number | undefined => {
768
+ if (!entry) return undefined;
769
+ noQualifiers(entry, owner);
770
+ const parsed =
771
+ entry.value.kind === "number" ? Number(entry.value.raw) : Number.NaN;
772
+ if (Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum) {
773
+ return parsed;
774
+ }
775
+ error(
776
+ entry.value.span,
777
+ `${label} on ${owner} is a literal integer from ${minimum} through ${maximum}`,
778
+ );
779
+ return undefined;
780
+ };
781
+
782
+ const parsePartyList = (
783
+ entry: Entry | undefined,
784
+ owner: string,
785
+ label: string,
786
+ minimum: number,
787
+ maximum: number,
788
+ ): string[] | undefined => {
789
+ if (!entry) return undefined;
790
+ noQualifiers(entry, owner);
791
+ if (
792
+ entry.value.kind !== "list" ||
793
+ entry.value.items.length < minimum ||
794
+ entry.value.items.length > maximum
795
+ ) {
796
+ error(
797
+ entry.value.span,
798
+ `${label} on ${owner} lists ${minimum} through ${maximum} declared parties`,
799
+ );
800
+ return undefined;
801
+ }
802
+ const names = entry.value.items
803
+ .map((item) => partyRef(item, `${owner} ${label}`))
804
+ .filter((name): name is string => name !== undefined);
805
+ if (new Set(names).size !== names.length) {
806
+ error(entry.value.span, `${label} on ${owner} must not repeat a party`);
807
+ return undefined;
808
+ }
809
+ return names.length === entry.value.items.length ? names : undefined;
810
+ };
811
+
774
812
  const parseSchedule = (
775
813
  entries: Map<string, Entry>,
776
814
  owner: string,
777
815
  origin: Span,
816
+ maxCount = 12,
778
817
  ): ScheduleTerms | undefined => {
779
818
  const countEntry = entries.get("count");
780
819
  const everyEntry = entries.get("every");
@@ -783,12 +822,12 @@ export function checkProgram(program: Program): CheckResult {
783
822
  noQualifiers(countEntry, owner);
784
823
  const value = countEntry.value;
785
824
  const parsed = value.kind === "number" ? Number(value.raw) : Number.NaN;
786
- if (Number.isInteger(parsed) && parsed >= 2 && parsed <= 12) {
825
+ if (Number.isInteger(parsed) && parsed >= 2 && parsed <= maxCount) {
787
826
  count = parsed;
788
827
  } else {
789
828
  error(
790
829
  value.span,
791
- `count on ${owner} is a literal number of anchors between 2 and 12; the schedule stays finite by construction`,
830
+ `count on ${owner} is a literal number of anchors between 2 and ${maxCount}; the schedule stays finite by construction`,
792
831
  );
793
832
  }
794
833
  }
@@ -821,6 +860,61 @@ export function checkProgram(program: Program): CheckResult {
821
860
  return { count, every, firstDueField, origin };
822
861
  };
823
862
 
863
+ const parseSettlementReference = (
864
+ entry: Entry,
865
+ owner: string,
866
+ ): CheckedSettlementReference | undefined => {
867
+ noQualifiers(entry, owner);
868
+ const value = entry.value;
869
+ if (value.kind !== "settlement_ref") {
870
+ error(
871
+ value.span,
872
+ `${owner} draws against a held payment's release, like: against: retention.release`,
873
+ );
874
+ return undefined;
875
+ }
876
+ const target = value.owner.name;
877
+ const targetDecl = settlementDecls.find(
878
+ (decl) => decl.name.name === target,
879
+ );
880
+ if (!targetDecl) {
881
+ const other = declared.get(target);
882
+ error(
883
+ value.owner.span,
884
+ other
885
+ ? `${owner} references ${target}, which is a ${other.kind}; settlement exits belong to settlements`
886
+ : `${owner} references ${target}, but no settlement with that name is declared`,
887
+ );
888
+ return undefined;
889
+ }
890
+ const targetArchetype = targetDecl.archetype.name;
891
+ if (
892
+ !(SETTLEMENT_ARCHETYPES as readonly string[]).includes(targetArchetype)
893
+ ) {
894
+ return undefined;
895
+ }
896
+ const exits =
897
+ ARCHETYPE_DEFINITIONS[targetArchetype as ArchetypeName]
898
+ .referenceableExits;
899
+ if (!exits.includes(value.member.name)) {
900
+ const offered =
901
+ exits.length > 0
902
+ ? `it exposes ${exits.join(", ")}`
903
+ : "it exposes no referenceable exits";
904
+ error(
905
+ value.member.span,
906
+ `${owner} references ${target}.${value.member.name}, but ${targetArchetype} does not expose that exit; ${offered}`,
907
+ );
908
+ return undefined;
909
+ }
910
+ return {
911
+ exit: value.member.name,
912
+ origin: value.span,
913
+ settlement: target,
914
+ targetArchetype: targetArchetype as ArchetypeName,
915
+ };
916
+ };
917
+
824
918
  /**
825
919
  * An advance draws its repayment from exactly one source: its own finite
826
920
  * schedule, or a carve out of the release of a hold the advanced party is
@@ -855,23 +949,8 @@ export function checkProgram(program: Program): CheckResult {
855
949
  const schedule = parseSchedule(entries, owner, origin);
856
950
  return schedule ? { kind: "schedule", schedule } : undefined;
857
951
  }
858
- noQualifiers(againstEntry, owner);
859
- const value = againstEntry.value;
860
- if (value.kind !== "settlement_ref") {
861
- error(
862
- value.span,
863
- `${owner} draws against a held payment's release, like: against: retention.release`,
864
- );
865
- return undefined;
866
- }
867
- if (value.member.name !== "release") {
868
- error(
869
- value.member.span,
870
- `an advance carves a hold's release; there is no exit named ${value.member.name} on ${value.owner.name} to draw against`,
871
- );
872
- return undefined;
873
- }
874
- return { kind: "carve", origin: value.span, settlement: value.owner.name };
952
+ const reference = parseSettlementReference(againstEntry, owner);
953
+ return reference ? { kind: "carve", ...reference } : undefined;
875
954
  };
876
955
 
877
956
  // --- Settlements: one checker per archetype ---------------------------------
@@ -888,8 +967,12 @@ export function checkProgram(program: Program): CheckResult {
888
967
  }
889
968
 
890
969
  const owner = `settlement ${decl.name.name}`;
891
- const surface = ARCHETYPE_SURFACES[archetype as ArchetypeName];
892
- const entries = entriesOf(decl.body, surface.keys, owner);
970
+ const surface = ARCHETYPE_DEFINITIONS[archetype as ArchetypeName];
971
+ const entries = entriesOf(
972
+ decl.body,
973
+ [...surface.keys, "derived_amount"],
974
+ owner,
975
+ );
893
976
  for (const required of surface.required) {
894
977
  if (!entries.has(required)) {
895
978
  error(decl.span, `${owner} is missing ${required}`);
@@ -912,8 +995,737 @@ export function checkProgram(program: Program): CheckResult {
912
995
  }
913
996
  return true;
914
997
  };
998
+ const derived = entries.get("derived_amount");
999
+ if (derived) {
1000
+ noQualifiers(derived, owner);
1001
+ if (derived.value.kind !== "block") {
1002
+ error(
1003
+ derived.value.span,
1004
+ "derived_amount is a block with field, source, rule, and bearer",
1005
+ );
1006
+ } else {
1007
+ const terms = entriesOf(
1008
+ derived.value,
1009
+ ["field", "source", "rule", "bearer"],
1010
+ `${owner} derived_amount`,
1011
+ );
1012
+ for (const key of ["field", "source", "rule", "bearer"] as const) {
1013
+ if (!terms.has(key))
1014
+ error(derived.span, `${owner} derived_amount is missing ${key}`);
1015
+ }
1016
+ const fieldValue = terms.get("field")?.value;
1017
+ const baseValue = terms.get("source")?.value;
1018
+ const ruleValue = terms.get("rule")?.value;
1019
+ const bearerValue = terms.get("bearer")?.value;
1020
+ const field =
1021
+ fieldValue?.kind === "ident" ? fieldValue.name : undefined;
1022
+ const baseField =
1023
+ baseValue?.kind === "ident" ? baseValue.name : undefined;
1024
+ const bearer = bearerValue
1025
+ ? partyRef(bearerValue, `${owner} derived amount bearer`)
1026
+ : undefined;
1027
+ if (fieldValue && fieldValue.kind !== "ident")
1028
+ error(
1029
+ fieldValue.span,
1030
+ "derived amount field must be a camelCase identifier",
1031
+ );
1032
+ if (baseValue && baseValue.kind !== "ident")
1033
+ error(
1034
+ baseValue.span,
1035
+ "derived amount source must name one stored money field",
1036
+ );
1037
+ if (ruleValue && ruleValue.kind !== "percent") {
1038
+ error(
1039
+ ruleValue.span,
1040
+ "derived_amount proves percentage-of rules only; fixed and tiered rules are refused",
1041
+ );
1042
+ }
1043
+ if (
1044
+ ruleValue?.kind === "percent" &&
1045
+ (ruleValue.bps <= 0 || ruleValue.bps >= TOTAL_BPS)
1046
+ ) {
1047
+ error(
1048
+ ruleValue.span,
1049
+ "derived amount percentage must be above 0% and below 100%",
1050
+ );
1051
+ }
1052
+ if (field && !CAMEL_CASE.test(field))
1053
+ error(
1054
+ fieldValue!.span,
1055
+ `derived amount field ${field} must be camelCase`,
1056
+ );
1057
+ if (baseField && !CAMEL_CASE.test(baseField))
1058
+ error(
1059
+ baseValue!.span,
1060
+ `derived amount source ${baseField} must be camelCase`,
1061
+ );
1062
+ if (field && baseField && field === baseField)
1063
+ error(
1064
+ derived.span,
1065
+ "derived amount field must differ from its source field",
1066
+ );
1067
+ if (
1068
+ field &&
1069
+ baseField &&
1070
+ bearer &&
1071
+ ruleValue?.kind === "percent" &&
1072
+ ruleValue.bps > 0 &&
1073
+ ruleValue.bps < TOTAL_BPS
1074
+ ) {
1075
+ derivedAmounts.push({
1076
+ baseField,
1077
+ bearer,
1078
+ bps: ruleValue.bps,
1079
+ field,
1080
+ origin: derived.span,
1081
+ settlement: decl.name.name,
1082
+ });
1083
+ }
1084
+ }
1085
+ }
915
1086
 
916
1087
  switch (archetype as ArchetypeName) {
1088
+ case "captured_payment": {
1089
+ const payer = party("payer", "payer");
1090
+ const payee = party("payee", "payee");
1091
+ const sound = distinct(
1092
+ payer,
1093
+ payee,
1094
+ `pays ${payer} from ${payer}; payer and payee must differ`,
1095
+ );
1096
+ const amount = parseAmount(entries.get("amount"), owner);
1097
+ const reserveUntilField = parseDateField(
1098
+ entries.get("reserve_until"),
1099
+ owner,
1100
+ );
1101
+ const correction = parsePortEntry(
1102
+ entries.get("correction"),
1103
+ owner,
1104
+ "payee correction",
1105
+ );
1106
+ const externalReversal = parseDisputeEntry(
1107
+ entries.get("external_reversal"),
1108
+ owner,
1109
+ "external reversal",
1110
+ );
1111
+ const captureInSlices = requireIdentPolicy(
1112
+ entries.get("capture_mode"),
1113
+ owner,
1114
+ "partial_then_full",
1115
+ "capture calls must leave a remainder and settle posts the final remainder",
1116
+ );
1117
+ const fullCorrection = requireIdentPolicy(
1118
+ entries.get("correction_mode"),
1119
+ owner,
1120
+ "full_only",
1121
+ "repeated partial corrections are not representable yet, so the checker refuses them",
1122
+ );
1123
+ const noNegativePosition = requireIdentPolicy(
1124
+ entries.get("negative_position"),
1125
+ owner,
1126
+ "reject",
1127
+ "a correction or reversal must fail when the payee cannot fund it",
1128
+ );
1129
+ const timeoutRejects = requireIdentPolicy(
1130
+ entries.get("timeout"),
1131
+ owner,
1132
+ "reject",
1133
+ "a timeout records no money movement; only a confirmed decision may reverse funds",
1134
+ );
1135
+ if (entries.has("fees")) {
1136
+ error(
1137
+ entries.get("fees")!.span,
1138
+ `${owner} does not price fees inside captured_payment; compose a separate fee settlement so capture and correction amounts stay exact`,
1139
+ );
1140
+ }
1141
+ if (amount && reserveUntilField === amount.name) {
1142
+ error(
1143
+ entries.get("reserve_until")?.span ?? decl.span,
1144
+ `${owner} uses ${amount.name} as both the reserved amount and reserve_until date field; they need distinct names`,
1145
+ );
1146
+ break;
1147
+ }
1148
+ if (
1149
+ payer &&
1150
+ payee &&
1151
+ amount &&
1152
+ reserveUntilField &&
1153
+ correction &&
1154
+ externalReversal &&
1155
+ captureInSlices &&
1156
+ fullCorrection &&
1157
+ noNegativePosition &&
1158
+ timeoutRejects &&
1159
+ sound
1160
+ ) {
1161
+ settlements.push({
1162
+ amount,
1163
+ archetype: "captured_payment",
1164
+ correction,
1165
+ externalReversal,
1166
+ name: decl.name.name,
1167
+ origin: decl.span,
1168
+ payee,
1169
+ payer,
1170
+ reserveUntilField,
1171
+ } satisfies CheckedCaptureReservation);
1172
+ }
1173
+ break;
1174
+ }
1175
+ case "settlement_batch": {
1176
+ const settlementAccount = party(
1177
+ "settlement_account",
1178
+ "settlement account",
1179
+ );
1180
+ const payoutDestination = party(
1181
+ "payout_destination",
1182
+ "payout destination",
1183
+ );
1184
+ const sound = distinct(
1185
+ settlementAccount,
1186
+ payoutDestination,
1187
+ `routes payout from ${settlementAccount} back to itself; settlement account and payout destination must differ`,
1188
+ );
1189
+ const sourceCaptureReferenceField = parseFieldName(
1190
+ entries.get("source_capture_refs"),
1191
+ owner,
1192
+ );
1193
+ const feeReferenceField = parseFieldName(
1194
+ entries.get("fee_entries"),
1195
+ owner,
1196
+ );
1197
+ const externalReversalReferenceField = parseFieldName(
1198
+ entries.get("external_reversal_offsets"),
1199
+ owner,
1200
+ );
1201
+ const closeTriggerField = parseDateField(
1202
+ entries.get("close_trigger"),
1203
+ owner,
1204
+ );
1205
+ const payoutAcknowledgement = parsePortEntry(
1206
+ entries.get("payout_acknowledgement"),
1207
+ owner,
1208
+ "payout acknowledgement",
1209
+ );
1210
+ const payoutBeneficiaryReferenceField = parseFieldName(
1211
+ entries.get("payout_beneficiary_ref"),
1212
+ owner,
1213
+ );
1214
+ const rejectsNegative = requireIdentPolicy(
1215
+ entries.get("negative_position"),
1216
+ owner,
1217
+ "reject",
1218
+ "offsets beyond gross capture entries must stop calculation and post no payout",
1219
+ );
1220
+ const lineageFields = [
1221
+ sourceCaptureReferenceField,
1222
+ feeReferenceField,
1223
+ externalReversalReferenceField,
1224
+ closeTriggerField,
1225
+ payoutBeneficiaryReferenceField,
1226
+ ].filter((field): field is string => field !== undefined);
1227
+ if (new Set(lineageFields).size !== lineageFields.length) {
1228
+ error(
1229
+ decl.span,
1230
+ `${owner} needs distinct source_capture_refs, fee_entries, external_reversal_offsets, close_trigger, and payout_beneficiary_ref field names`,
1231
+ );
1232
+ break;
1233
+ }
1234
+ if (
1235
+ settlementAccount &&
1236
+ payoutDestination &&
1237
+ sourceCaptureReferenceField &&
1238
+ feeReferenceField &&
1239
+ externalReversalReferenceField &&
1240
+ closeTriggerField &&
1241
+ payoutAcknowledgement &&
1242
+ payoutBeneficiaryReferenceField &&
1243
+ rejectsNegative &&
1244
+ sound
1245
+ ) {
1246
+ settlements.push({
1247
+ archetype: "settlement_batch",
1248
+ closeTriggerField,
1249
+ externalReversalReferenceField,
1250
+ feeReferenceField,
1251
+ name: decl.name.name,
1252
+ origin: decl.span,
1253
+ payoutDestination,
1254
+ payoutAcknowledgement,
1255
+ payoutBeneficiaryReferenceField,
1256
+ settlementAccount,
1257
+ sourceCaptureReferenceField,
1258
+ } satisfies CheckedSettlementBatch);
1259
+ }
1260
+ break;
1261
+ }
1262
+ case "funding_round": {
1263
+ const contributor = party("contributor", "contributor");
1264
+ const beneficiary = party("beneficiary", "beneficiary");
1265
+ const target = parseAmount(entries.get("target"), owner);
1266
+ const commitment = parseAmount(entries.get("commitment"), owner);
1267
+ const maxContributors = parseLiteralCount(
1268
+ entries.get("max_contributors"),
1269
+ owner,
1270
+ "max_contributors",
1271
+ 2,
1272
+ 10_000,
1273
+ );
1274
+ const closeByField = parseDateField(entries.get("close_by"), owner);
1275
+ const policiesSound = [
1276
+ requireIdentPolicy(
1277
+ entries.get("close_policy"),
1278
+ owner,
1279
+ "threshold",
1280
+ "close must follow the locked committed sum at the stored close date",
1281
+ ),
1282
+ requireIdentPolicy(
1283
+ entries.get("overfund_policy"),
1284
+ owner,
1285
+ "reject",
1286
+ "a commitment past the remaining target headroom must refuse",
1287
+ ),
1288
+ requireIdentPolicy(
1289
+ entries.get("cancel_policy"),
1290
+ owner,
1291
+ "before_close",
1292
+ "a commitment may cancel only while its parent remains open",
1293
+ ),
1294
+ requireIdentPolicy(
1295
+ entries.get("fail_policy"),
1296
+ owner,
1297
+ "whole_commitment_refund",
1298
+ "failure refunds each stored commitment whole",
1299
+ ),
1300
+ ].every(Boolean);
1301
+ const sound = distinct(
1302
+ contributor,
1303
+ beneficiary,
1304
+ `uses ${contributor} as both contributor and beneficiary; the roles must differ`,
1305
+ );
1306
+ if (target && commitment && target.currency !== commitment.currency) {
1307
+ error(
1308
+ commitment.origin,
1309
+ `${owner} mixes ${target.currency} and ${commitment.currency}; target and commitment need one currency`,
1310
+ );
1311
+ }
1312
+ if (target && commitment && target.name === commitment.name) {
1313
+ error(
1314
+ commitment.origin,
1315
+ `${owner} uses ${target.name} for both target and commitment; the parent and child amounts need distinct fields`,
1316
+ );
1317
+ }
1318
+ if (
1319
+ contributor &&
1320
+ beneficiary &&
1321
+ target &&
1322
+ commitment &&
1323
+ target.currency === commitment.currency &&
1324
+ target.name !== commitment.name &&
1325
+ maxContributors &&
1326
+ closeByField &&
1327
+ policiesSound &&
1328
+ sound
1329
+ ) {
1330
+ settlements.push({
1331
+ archetype: "funding_round",
1332
+ beneficiary,
1333
+ cancelPolicy: "before_close",
1334
+ closeByField,
1335
+ closePolicy: "threshold",
1336
+ commitment,
1337
+ contributor,
1338
+ failPolicy: "whole_commitment_refund",
1339
+ maxContributors,
1340
+ name: decl.name.name,
1341
+ origin: decl.span,
1342
+ overfundPolicy: "reject",
1343
+ target,
1344
+ } satisfies CheckedFundingRound);
1345
+ }
1346
+ break;
1347
+ }
1348
+ case "weighted_distribution": {
1349
+ const source = party("source", "source");
1350
+ const recipient = party("recipient", "recipient template");
1351
+ const amount = parseAmount(entries.get("amount"), owner);
1352
+ const weight = parseAmount(entries.get("weight"), owner);
1353
+ const maxRecipients = parseLiteralCount(
1354
+ entries.get("max_recipients"),
1355
+ owner,
1356
+ "max_recipients",
1357
+ 2,
1358
+ 10_000,
1359
+ );
1360
+ const recordAtField = parseDateField(entries.get("record_at"), owner);
1361
+ const snapshot = parsePortEntry(
1362
+ entries.get("snapshot"),
1363
+ owner,
1364
+ "entitlement snapshot",
1365
+ );
1366
+ const policiesSound = [
1367
+ requireIdentPolicy(
1368
+ entries.get("rounding_policy"),
1369
+ owner,
1370
+ "largest_remainder",
1371
+ "floor shares use a deterministic largest-remainder allocation with noun id as the tie-break",
1372
+ ),
1373
+ requireIdentPolicy(
1374
+ entries.get("withholding_policy"),
1375
+ owner,
1376
+ "refuse",
1377
+ "withholding needs its own proved retained-amount mechanism",
1378
+ ),
1379
+ requireIdentPolicy(
1380
+ entries.get("correction_policy"),
1381
+ owner,
1382
+ "new_distribution",
1383
+ "post-payout correction is a new linked distribution, never a rewrite or caller amount",
1384
+ ),
1385
+ ].every(Boolean);
1386
+ const sound = distinct(
1387
+ source,
1388
+ recipient,
1389
+ `uses ${source} as both source and recipient; the roles must differ`,
1390
+ );
1391
+ if (amount && weight && amount.currency !== weight.currency) {
1392
+ error(
1393
+ weight.origin,
1394
+ `${owner} mixes ${amount.currency} and ${weight.currency}; pool and stored weights need one denomination`,
1395
+ );
1396
+ }
1397
+ if (amount && weight && amount.name === weight.name) {
1398
+ error(
1399
+ weight.origin,
1400
+ `${owner} uses ${amount.name} for both pool and weight; the fields must differ`,
1401
+ );
1402
+ }
1403
+ if (
1404
+ source &&
1405
+ recipient &&
1406
+ amount &&
1407
+ weight &&
1408
+ amount.currency === weight.currency &&
1409
+ amount.name !== weight.name &&
1410
+ maxRecipients &&
1411
+ recordAtField &&
1412
+ snapshot &&
1413
+ policiesSound &&
1414
+ sound
1415
+ ) {
1416
+ settlements.push({
1417
+ amount,
1418
+ archetype: "weighted_distribution",
1419
+ correctionPolicy: "new_distribution",
1420
+ maxRecipients,
1421
+ name: decl.name.name,
1422
+ origin: decl.span,
1423
+ recipient,
1424
+ recordAtField,
1425
+ roundingPolicy: "largest_remainder",
1426
+ snapshot,
1427
+ source,
1428
+ weight,
1429
+ withholdingPolicy: "refuse",
1430
+ } satisfies CheckedWeightedDistribution);
1431
+ }
1432
+ break;
1433
+ }
1434
+ case "credit_facility": {
1435
+ const lender = party("lender", "lender");
1436
+ const borrower = party("borrower", "borrower");
1437
+ const drawDestination = party("draw_destination", "draw destination");
1438
+ const limit = parseAmount(entries.get("limit"), owner);
1439
+ const expiresAtField = parseDateField(entries.get("expires_at"), owner);
1440
+ const obligationEntry = entries.get("obligation");
1441
+ const obligation = obligationEntry
1442
+ ? parseSettlementReference(obligationEntry, owner)
1443
+ : undefined;
1444
+ const availabilityEntry = entries.get("availability_policy");
1445
+ let availabilityPolicy: "revolving" | "non_revolving" | undefined;
1446
+ if (
1447
+ availabilityEntry?.value.kind === "ident" &&
1448
+ (availabilityEntry.value.name === "revolving" ||
1449
+ availabilityEntry.value.name === "non_revolving")
1450
+ ) {
1451
+ availabilityPolicy = availabilityEntry.value.name;
1452
+ } else if (availabilityEntry) {
1453
+ error(
1454
+ availabilityEntry.value.span,
1455
+ `availability_policy on ${owner} is revolving or non_revolving; the factpack does not justify an implicit choice`,
1456
+ );
1457
+ }
1458
+ const policiesSound = [
1459
+ requireIdentPolicy(
1460
+ entries.get("expiry_policy"),
1461
+ owner,
1462
+ "freeze_draws",
1463
+ "expiry stops new draws without closing linked debt",
1464
+ ),
1465
+ requireIdentPolicy(
1466
+ entries.get("close_policy"),
1467
+ owner,
1468
+ "no_open_draws",
1469
+ "the facility closes only after every linked obligation resolves",
1470
+ ),
1471
+ ].every(Boolean);
1472
+ const rolesSound = [
1473
+ distinct(
1474
+ lender,
1475
+ borrower,
1476
+ `uses ${lender} as both lender and borrower; the roles must differ`,
1477
+ ),
1478
+ distinct(
1479
+ lender,
1480
+ drawDestination,
1481
+ `draws back to its lender ${lender}; draw_destination must differ`,
1482
+ ),
1483
+ ].every(Boolean);
1484
+ if (
1485
+ lender &&
1486
+ borrower &&
1487
+ drawDestination &&
1488
+ limit &&
1489
+ expiresAtField &&
1490
+ obligation &&
1491
+ availabilityPolicy &&
1492
+ policiesSound &&
1493
+ rolesSound
1494
+ ) {
1495
+ settlements.push({
1496
+ archetype: "credit_facility",
1497
+ availabilityPolicy,
1498
+ borrower,
1499
+ closePolicy: "no_open_draws",
1500
+ drawDestination,
1501
+ expiresAtField,
1502
+ expiryPolicy: "freeze_draws",
1503
+ lender,
1504
+ limit,
1505
+ name: decl.name.name,
1506
+ obligation,
1507
+ origin: decl.span,
1508
+ } satisfies CheckedCreditFacility);
1509
+ }
1510
+ break;
1511
+ }
1512
+ case "recurring_collection": {
1513
+ const obligationEntry = entries.get("obligation");
1514
+ const obligation = obligationEntry
1515
+ ? parseSettlementReference(obligationEntry, owner)
1516
+ : undefined;
1517
+ const mandate = parsePortEntry(
1518
+ entries.get("mandate"),
1519
+ owner,
1520
+ "mandate evidence",
1521
+ );
1522
+ const policiesSound = [
1523
+ requireIdentPolicy(
1524
+ entries.get("attempt_policy"),
1525
+ owner,
1526
+ "explicit",
1527
+ "every attempt is a separate receipted call",
1528
+ ),
1529
+ requireIdentPolicy(
1530
+ entries.get("period_idempotency"),
1531
+ owner,
1532
+ "obligation_and_anchor",
1533
+ "the parent obligation and stored anchor form the period identity",
1534
+ ),
1535
+ requireIdentPolicy(
1536
+ entries.get("retry_policy"),
1537
+ owner,
1538
+ "explicit_attempt",
1539
+ "PRINCIPLES.md bans hidden retries; a retry needs a new explicit idempotency key",
1540
+ ),
1541
+ requireIdentPolicy(
1542
+ entries.get("failure_policy"),
1543
+ owner,
1544
+ "parent_delinquency",
1545
+ "the scheduled obligation owns due state and delinquency",
1546
+ ),
1547
+ ].every(Boolean);
1548
+ if (obligation && mandate && policiesSound) {
1549
+ settlements.push({
1550
+ archetype: "recurring_collection",
1551
+ attemptPolicy: "explicit",
1552
+ failurePolicy: "parent_delinquency",
1553
+ mandate,
1554
+ name: decl.name.name,
1555
+ obligation,
1556
+ origin: decl.span,
1557
+ periodIdempotency: "obligation_and_anchor",
1558
+ retryPolicy: "explicit_attempt",
1559
+ } satisfies CheckedRecurringCollection);
1560
+ }
1561
+ break;
1562
+ }
1563
+ case "conditional_disbursement": {
1564
+ const source = party("source", "source");
1565
+ const destination = party("destination", "destination");
1566
+ const cap = parseAmount(entries.get("cap"), owner);
1567
+ const amount = parseAmount(entries.get("amount"), owner);
1568
+ const decision = parsePortEntry(
1569
+ entries.get("decision"),
1570
+ owner,
1571
+ "capped disbursement decision",
1572
+ );
1573
+ const policiesSound = [
1574
+ requireIdentPolicy(
1575
+ entries.get("reopen_policy"),
1576
+ owner,
1577
+ "refuse",
1578
+ "a closed decision cannot be reopened; create a new linked disbursement",
1579
+ ),
1580
+ requireIdentPolicy(
1581
+ entries.get("recovery_policy"),
1582
+ owner,
1583
+ "separate_transfer",
1584
+ "recovery is a new money movement with its own receipt",
1585
+ ),
1586
+ ].every(Boolean);
1587
+ const sound = distinct(
1588
+ source,
1589
+ destination,
1590
+ `pays ${source} from itself; source and destination must differ`,
1591
+ );
1592
+ if (cap && amount && cap.currency !== amount.currency) {
1593
+ error(
1594
+ amount.origin,
1595
+ `${owner} mixes ${cap.currency} and ${amount.currency}; cap and approved amount need one currency`,
1596
+ );
1597
+ }
1598
+ if (cap && amount && cap.name === amount.name) {
1599
+ error(
1600
+ amount.origin,
1601
+ `${owner} uses ${cap.name} for both cap and approved amount; the fields must differ`,
1602
+ );
1603
+ }
1604
+ if (
1605
+ source &&
1606
+ destination &&
1607
+ cap &&
1608
+ amount &&
1609
+ cap.currency === amount.currency &&
1610
+ cap.name !== amount.name &&
1611
+ decision &&
1612
+ policiesSound &&
1613
+ sound
1614
+ ) {
1615
+ settlements.push({
1616
+ amount,
1617
+ archetype: "conditional_disbursement",
1618
+ cap,
1619
+ decision,
1620
+ destination,
1621
+ name: decl.name.name,
1622
+ origin: decl.span,
1623
+ recoveryPolicy: "separate_transfer",
1624
+ reopenPolicy: "refuse",
1625
+ source,
1626
+ } satisfies CheckedConditionalDisbursement);
1627
+ }
1628
+ break;
1629
+ }
1630
+ case "rotating_pool": {
1631
+ const members = parsePartyList(
1632
+ entries.get("members"),
1633
+ owner,
1634
+ "members",
1635
+ 2,
1636
+ 5,
1637
+ );
1638
+ const payoutOrder = parsePartyList(
1639
+ entries.get("payout_order"),
1640
+ owner,
1641
+ "payout_order",
1642
+ 2,
1643
+ 5,
1644
+ );
1645
+ const contribution = parseAmount(entries.get("contribution"), owner);
1646
+ const schedule = parseSchedule(entries, owner, decl.span, 12);
1647
+ const guarantor = entries.has("guarantor")
1648
+ ? party("guarantor", "guarantor")
1649
+ : undefined;
1650
+ const policiesSound = [
1651
+ requireIdentPolicy(
1652
+ entries.get("default_policy"),
1653
+ owner,
1654
+ "due_condition",
1655
+ "default follows an unmet stored contribution due condition",
1656
+ ),
1657
+ requireIdentPolicy(
1658
+ entries.get("guarantee_policy"),
1659
+ owner,
1660
+ "funded_only",
1661
+ "a guarantee changes money only through an explicit funded contribution",
1662
+ ),
1663
+ requireIdentPolicy(
1664
+ entries.get("exit_policy"),
1665
+ owner,
1666
+ "before_activation_only",
1667
+ "membership and order freeze before the first contribution",
1668
+ ),
1669
+ ].every(Boolean);
1670
+ let rosterSound = true;
1671
+ if (members && payoutOrder) {
1672
+ const memberSet = new Set(members);
1673
+ if (
1674
+ payoutOrder.length !== members.length ||
1675
+ payoutOrder.some((name) => !memberSet.has(name))
1676
+ ) {
1677
+ error(
1678
+ entries.get("payout_order")?.span ?? decl.span,
1679
+ `${owner} payout_order must contain every member exactly once`,
1680
+ );
1681
+ rosterSound = false;
1682
+ }
1683
+ }
1684
+ if (members && schedule && schedule.count !== members.length) {
1685
+ error(
1686
+ entries.get("count")?.span ?? decl.span,
1687
+ `${owner} count ${schedule.count} must equal its ${members.length}-member roster`,
1688
+ );
1689
+ rosterSound = false;
1690
+ }
1691
+ if (members && guarantor && members.includes(guarantor)) {
1692
+ error(
1693
+ entries.get("guarantor")?.span ?? decl.span,
1694
+ `${owner} guarantor must differ from every member`,
1695
+ );
1696
+ rosterSound = false;
1697
+ }
1698
+ if (members && guarantor && members.length > 4) {
1699
+ error(
1700
+ entries.get("members")?.span ?? decl.span,
1701
+ `${owner} supports at most 4 members with a guarantor so every generated child binding stays within the 8-entry cap`,
1702
+ );
1703
+ rosterSound = false;
1704
+ }
1705
+ if (
1706
+ members &&
1707
+ payoutOrder &&
1708
+ contribution &&
1709
+ schedule &&
1710
+ policiesSound &&
1711
+ rosterSound
1712
+ ) {
1713
+ settlements.push({
1714
+ archetype: "rotating_pool",
1715
+ contribution,
1716
+ defaultPolicy: "due_condition",
1717
+ exitPolicy: "before_activation_only",
1718
+ guaranteePolicy: "funded_only",
1719
+ ...(guarantor ? { guarantor } : {}),
1720
+ members,
1721
+ name: decl.name.name,
1722
+ origin: decl.span,
1723
+ payoutOrder,
1724
+ schedule,
1725
+ } satisfies CheckedRotatingPool);
1726
+ }
1727
+ break;
1728
+ }
917
1729
  case "swap": {
918
1730
  const betweenEntry = entries.get("between");
919
1731
  let sideA: string | undefined;
@@ -1151,18 +1963,91 @@ export function checkProgram(program: Program): CheckResult {
1151
1963
  [payer, carrier].filter((name): name is string => name !== undefined),
1152
1964
  "this settlement's payer or carrier",
1153
1965
  );
1154
- if (payer && carrier && amount && bind && sound) {
1155
- settlements.push({
1966
+ const extensionKeys = [
1967
+ "policy_ref",
1968
+ "renewal_due",
1969
+ "renewal_policy",
1970
+ "endorsement",
1971
+ "endorsement_policy",
1972
+ "lapse_policy",
1973
+ ];
1974
+ const extendsPolicy = extensionKeys.some((key) => entries.has(key));
1975
+ if (extendsPolicy) {
1976
+ for (const key of extensionKeys) {
1977
+ if (!entries.has(key))
1978
+ error(decl.span, `${owner} is missing ${key}`);
1979
+ }
1980
+ }
1981
+ const policyReferenceField = extendsPolicy
1982
+ ? parseFieldName(entries.get("policy_ref"), owner)
1983
+ : undefined;
1984
+ const renewalDueField = extendsPolicy
1985
+ ? parseDateField(entries.get("renewal_due"), owner)
1986
+ : undefined;
1987
+ const endorsement = extendsPolicy
1988
+ ? parsePortEntry(
1989
+ entries.get("endorsement"),
1990
+ owner,
1991
+ "endorsement evidence",
1992
+ )
1993
+ : undefined;
1994
+ const extensionPoliciesSound = !extendsPolicy
1995
+ ? true
1996
+ : [
1997
+ requireIdentPolicy(
1998
+ entries.get("renewal_policy"),
1999
+ owner,
2000
+ "explicit_new_forward",
2001
+ "each renewal creates a new premium_forward instead of moving money from policy state",
2002
+ ),
2003
+ requireIdentPolicy(
2004
+ entries.get("endorsement_policy"),
2005
+ owner,
2006
+ "non_money_only",
2007
+ "a money adjustment needs a separate corrected movement",
2008
+ ),
2009
+ requireIdentPolicy(
2010
+ entries.get("lapse_policy"),
2011
+ owner,
2012
+ "due_condition",
2013
+ "lapse follows the stored renewal due condition",
2014
+ ),
2015
+ ].every(Boolean);
2016
+ if (
2017
+ payer &&
2018
+ carrier &&
2019
+ amount &&
2020
+ bind &&
2021
+ sound &&
2022
+ extensionPoliciesSound &&
2023
+ (!extendsPolicy ||
2024
+ (policyReferenceField && renewalDueField && endorsement))
2025
+ ) {
2026
+ const checked: CheckedPremiumForward = {
1156
2027
  amount,
1157
2028
  archetype: "premium_forward",
1158
2029
  bind,
1159
2030
  carrier,
1160
2031
  commissionBps,
2032
+ ...(endorsement ? { endorsement } : {}),
2033
+ ...(extendsPolicy &&
2034
+ policyReferenceField &&
2035
+ renewalDueField &&
2036
+ endorsement
2037
+ ? {
2038
+ endorsementPolicy: "non_money_only" as const,
2039
+ lapsePolicy: "due_condition" as const,
2040
+ policyReferenceField,
2041
+ renewalDueField,
2042
+ renewalPolicy: "explicit_new_forward" as const,
2043
+ }
2044
+ : {}),
1161
2045
  name: decl.name.name,
1162
2046
  ...(onCancel ? { onCancel } : {}),
1163
2047
  origin: decl.span,
1164
2048
  payer,
1165
- });
2049
+ };
2050
+ settlements.push(checked);
1166
2051
  }
1167
2052
  break;
1168
2053
  }
@@ -1211,7 +2096,16 @@ export function checkProgram(program: Program): CheckResult {
1211
2096
  `pays ${payer} from ${payer}; payer and payee must differ`,
1212
2097
  );
1213
2098
  const amount = parseAmount(entries.get("amount"), owner);
1214
- const schedule = parseSchedule(entries, owner, decl.span);
2099
+ const requestedMode = entries.get("mode")?.value;
2100
+ const obligationModeRequested =
2101
+ requestedMode?.kind === "ident" &&
2102
+ requestedMode.name === "obligation";
2103
+ const schedule = parseSchedule(
2104
+ entries,
2105
+ owner,
2106
+ decl.span,
2107
+ obligationModeRequested ? 7 : 12,
2108
+ );
1215
2109
  if (amount && schedule && amount.name === schedule.firstDueField) {
1216
2110
  error(
1217
2111
  schedule.origin,
@@ -1219,10 +2113,126 @@ export function checkProgram(program: Program): CheckResult {
1219
2113
  );
1220
2114
  break;
1221
2115
  }
1222
- if (payer && payee && amount && schedule && sound) {
2116
+ const modeEntry = entries.get("mode");
2117
+ const obligationKeys = [
2118
+ "advance_to",
2119
+ "debtor",
2120
+ "delinquency_policy",
2121
+ "partial_payment",
2122
+ "refund_policy",
2123
+ "repayment_matching",
2124
+ "reschedule_policy",
2125
+ ];
2126
+ const hasObligationKey = obligationKeys.some((key) => entries.has(key));
2127
+ if (!modeEntry && hasObligationKey) {
2128
+ error(
2129
+ decl.span,
2130
+ `${owner} uses obligation policies without mode: obligation`,
2131
+ );
2132
+ break;
2133
+ }
2134
+ if (!modeEntry) {
2135
+ if (payer && payee && amount && schedule && sound) {
2136
+ settlements.push({
2137
+ amount,
2138
+ archetype: "scheduled",
2139
+ mode: "transfer",
2140
+ name: decl.name.name,
2141
+ origin: decl.span,
2142
+ payee,
2143
+ payer,
2144
+ schedule,
2145
+ });
2146
+ }
2147
+ break;
2148
+ }
2149
+ if (
2150
+ !requireIdentPolicy(
2151
+ modeEntry,
2152
+ owner,
2153
+ "obligation",
2154
+ "omit mode for a fixed transfer schedule",
2155
+ )
2156
+ ) {
2157
+ break;
2158
+ }
2159
+ const requiredPolicies = [
2160
+ "debtor",
2161
+ "partial_payment",
2162
+ "repayment_matching",
2163
+ "refund_policy",
2164
+ "reschedule_policy",
2165
+ "delinquency_policy",
2166
+ ];
2167
+ for (const key of requiredPolicies) {
2168
+ if (!entries.has(key)) error(decl.span, `${owner} is missing ${key}`);
2169
+ }
2170
+ const debtor = party("debtor", "debtor");
2171
+ const advanceTo = entries.has("advance_to")
2172
+ ? party("advance_to", "advance recipient")
2173
+ : undefined;
2174
+ const policiesSound = [
2175
+ requireIdentPolicy(
2176
+ entries.get("partial_payment"),
2177
+ owner,
2178
+ "anchor_bound",
2179
+ "payments must bind to one stored installment anchor",
2180
+ ),
2181
+ requireIdentPolicy(
2182
+ entries.get("repayment_matching"),
2183
+ owner,
2184
+ "obligation_and_anchor",
2185
+ "each payment must name both the obligation and its installment anchor; fuzzy or balance-wide matching is refused",
2186
+ ),
2187
+ requireIdentPolicy(
2188
+ entries.get("refund_policy"),
2189
+ owner,
2190
+ "full_payment_only",
2191
+ "a refund reverses one stored payment whole so it cannot exceed that payment",
2192
+ ),
2193
+ requireIdentPolicy(
2194
+ entries.get("reschedule_policy"),
2195
+ owner,
2196
+ "refuse",
2197
+ "forward-version carryover is not mechanically proven; create a new obligation after closing this one",
2198
+ ),
2199
+ requireIdentPolicy(
2200
+ entries.get("delinquency_policy"),
2201
+ owner,
2202
+ "due_condition",
2203
+ "delinquency must come from an unmet stored due anchor",
2204
+ ),
2205
+ ].every(Boolean);
2206
+ const rolesSound = [
2207
+ distinct(
2208
+ debtor,
2209
+ payee,
2210
+ `${owner} names ${debtor} as both debtor and settlement recipient; they must differ`,
2211
+ ),
2212
+ advanceTo
2213
+ ? distinct(
2214
+ advanceTo,
2215
+ debtor,
2216
+ `${owner} advances to its debtor ${debtor}; advance_to must name a distinct recipient`,
2217
+ )
2218
+ : true,
2219
+ ].every(Boolean);
2220
+ if (
2221
+ payer &&
2222
+ payee &&
2223
+ amount &&
2224
+ schedule &&
2225
+ sound &&
2226
+ debtor &&
2227
+ policiesSound &&
2228
+ rolesSound
2229
+ ) {
1223
2230
  settlements.push({
2231
+ ...(advanceTo ? { advanceTo } : {}),
1224
2232
  amount,
1225
2233
  archetype: "scheduled",
2234
+ debtor,
2235
+ mode: "obligation",
1226
2236
  name: decl.name.name,
1227
2237
  origin: decl.span,
1228
2238
  payee,
@@ -1279,6 +2289,7 @@ export function checkProgram(program: Program): CheckResult {
1279
2289
  const closeByField = parseDateField(entries.get("close_by"), owner);
1280
2290
  const rates: MeterRate[] = [];
1281
2291
  const ratesEntry = entries.get("rates");
2292
+ let withinEventCap = true;
1282
2293
  if (ratesEntry) {
1283
2294
  noQualifiers(ratesEntry, owner);
1284
2295
  if (ratesEntry.value.kind !== "block") {
@@ -1287,6 +2298,16 @@ export function checkProgram(program: Program): CheckResult {
1287
2298
  `rates is a block of per-unit prices, like: rates { api_call: unitFee: money(SAR) }`,
1288
2299
  );
1289
2300
  } else {
2301
+ const maxRates =
2302
+ ARCHETYPE_DEFINITIONS.metered.maxRates ??
2303
+ ARCHETYPE_DEFINITIONS.metered.eventCap;
2304
+ if (ratesEntry.value.entries.length > maxRates) {
2305
+ error(
2306
+ ratesEntry.value.span,
2307
+ `${owner} prices ${ratesEntry.value.entries.length} meters, but metered allows at most ${maxRates} so one settlement stays within its ${ARCHETYPE_DEFINITIONS.metered.eventCap}-event cap`,
2308
+ );
2309
+ withinEventCap = false;
2310
+ }
1290
2311
  for (const rate of ratesEntry.value.entries) {
1291
2312
  if (!SNAKE_CASE.test(rate.key.name)) {
1292
2313
  error(
@@ -1338,7 +2359,14 @@ export function checkProgram(program: Program): CheckResult {
1338
2359
  );
1339
2360
  break;
1340
2361
  }
1341
- if (payer && payee && closeByField && rates.length > 0 && sound) {
2362
+ if (
2363
+ payer &&
2364
+ payee &&
2365
+ closeByField &&
2366
+ rates.length > 0 &&
2367
+ sound &&
2368
+ withinEventCap
2369
+ ) {
1342
2370
  settlements.push({
1343
2371
  archetype: "metered",
1344
2372
  closeByField,
@@ -1376,6 +2404,16 @@ export function checkProgram(program: Program): CheckResult {
1376
2404
  );
1377
2405
  break;
1378
2406
  }
2407
+ const maxRecipients =
2408
+ ARCHETYPE_DEFINITIONS.pooled_split.maxRecipients ??
2409
+ Math.floor(ARCHETYPE_DEFINITIONS.pooled_split.eventCap / 2);
2410
+ const withinEventCap = !split || split.shares.length <= maxRecipients;
2411
+ if (!withinEventCap) {
2412
+ error(
2413
+ splitEntry!.value.span,
2414
+ `${owner} splits to ${split!.shares.length} recipients, but pooled_split allows at most ${maxRecipients} so funding and payout stay within its ${ARCHETYPE_DEFINITIONS.pooled_split.eventCap}-event cap`,
2415
+ );
2416
+ }
1379
2417
  // The lowering names each share field ${camelCase(party)}ShareAmount;
1380
2418
  // camelCasing is not injective (a_2b and a2b collide), so two shares
1381
2419
  // must never map onto one generated field.
@@ -1410,7 +2448,7 @@ export function checkProgram(program: Program): CheckResult {
1410
2448
  );
1411
2449
  break;
1412
2450
  }
1413
- if (payer && amount && distributeDueField && split) {
2451
+ if (payer && amount && distributeDueField && split && withinEventCap) {
1414
2452
  settlements.push({
1415
2453
  amount,
1416
2454
  archetype: "pooled_split",
@@ -1522,6 +2560,120 @@ export function checkProgram(program: Program): CheckResult {
1522
2560
 
1523
2561
  const checkedPortByName = new Map(ports.map((port) => [port.name, port]));
1524
2562
  for (const settlement of settlements) {
2563
+ if (settlement.archetype === "captured_payment") {
2564
+ const correctionPort = checkedPortByName.get(settlement.correction.port);
2565
+ if (
2566
+ correctionPort &&
2567
+ (correctionPort.allowed.length !== 1 ||
2568
+ correctionPort.allowed[0] !== settlement.payee)
2569
+ ) {
2570
+ error(
2571
+ correctionPort.origin,
2572
+ `captured_payment ${settlement.name} correction port ${correctionPort.name} must allow only its payee ${settlement.payee}`,
2573
+ );
2574
+ }
2575
+ const reversalPort = checkedPortByName.get(
2576
+ settlement.externalReversal.port,
2577
+ );
2578
+ if (
2579
+ reversalPort &&
2580
+ !reversalPort.fields.some(
2581
+ (field) =>
2582
+ field.name === "externalReference" && field.type.kind === "text",
2583
+ )
2584
+ ) {
2585
+ error(
2586
+ reversalPort.origin,
2587
+ `captured_payment ${settlement.name} external reversal port ${reversalPort.name} needs shape { externalReference: text }`,
2588
+ );
2589
+ }
2590
+ continue;
2591
+ }
2592
+ if (settlement.archetype === "settlement_batch") {
2593
+ const acknowledgementPort = checkedPortByName.get(
2594
+ settlement.payoutAcknowledgement.port,
2595
+ );
2596
+ if (
2597
+ acknowledgementPort &&
2598
+ !acknowledgementPort.fields.some(
2599
+ (field) =>
2600
+ field.name === "acknowledgementReference" &&
2601
+ field.type.kind === "text",
2602
+ )
2603
+ ) {
2604
+ error(
2605
+ acknowledgementPort.origin,
2606
+ `settlement_batch ${settlement.name} payout acknowledgement port ${acknowledgementPort.name} needs shape { acknowledgementReference: text }`,
2607
+ );
2608
+ }
2609
+ continue;
2610
+ }
2611
+ if (settlement.archetype === "weighted_distribution") {
2612
+ const port = checkedPortByName.get(settlement.snapshot.port);
2613
+ if (
2614
+ port &&
2615
+ !port.fields.some(
2616
+ (field) =>
2617
+ field.name === "evidenceReference" && field.type.kind === "text",
2618
+ )
2619
+ ) {
2620
+ error(
2621
+ port.origin,
2622
+ `weighted_distribution ${settlement.name} snapshot port ${port.name} needs shape { evidenceReference: text }`,
2623
+ );
2624
+ }
2625
+ continue;
2626
+ }
2627
+ if (settlement.archetype === "recurring_collection") {
2628
+ const port = checkedPortByName.get(settlement.mandate.port);
2629
+ if (
2630
+ port &&
2631
+ !port.fields.some(
2632
+ (field) =>
2633
+ field.name === "evidenceReference" && field.type.kind === "text",
2634
+ )
2635
+ ) {
2636
+ error(
2637
+ port.origin,
2638
+ `recurring_collection ${settlement.name} mandate port ${port.name} needs shape { evidenceReference: text }`,
2639
+ );
2640
+ }
2641
+ continue;
2642
+ }
2643
+ if (settlement.archetype === "conditional_disbursement") {
2644
+ const port = checkedPortByName.get(settlement.decision.port);
2645
+ if (
2646
+ port &&
2647
+ (!port.fields.some(
2648
+ (field) =>
2649
+ field.name === "evidenceReference" && field.type.kind === "text",
2650
+ ) ||
2651
+ port.allowed.length !== 1 ||
2652
+ port.allowed[0] !== settlement.source)
2653
+ ) {
2654
+ error(
2655
+ port.origin,
2656
+ `conditional_disbursement ${settlement.name} decision port ${port.name} must allow only ${settlement.source} and needs shape { evidenceReference: text }`,
2657
+ );
2658
+ }
2659
+ continue;
2660
+ }
2661
+ if (settlement.archetype === "premium_forward" && settlement.endorsement) {
2662
+ const port = checkedPortByName.get(settlement.endorsement.port);
2663
+ if (
2664
+ port &&
2665
+ !port.fields.some(
2666
+ (field) =>
2667
+ field.name === "evidenceReference" && field.type.kind === "text",
2668
+ )
2669
+ ) {
2670
+ error(
2671
+ port.origin,
2672
+ `premium_forward ${settlement.name} endorsement port ${port.name} needs shape { evidenceReference: text }`,
2673
+ );
2674
+ }
2675
+ continue;
2676
+ }
1525
2677
  if (settlement.archetype !== "swap") continue;
1526
2678
  const parties = new Set(settlement.sides.map((side) => side.party));
1527
2679
  const decisions = [
@@ -1544,11 +2696,76 @@ export function checkProgram(program: Program): CheckResult {
1544
2696
  }
1545
2697
  }
1546
2698
 
2699
+ const scheduledObligations = new Map(
2700
+ settlements
2701
+ .filter(
2702
+ (settlement) =>
2703
+ settlement.archetype === "scheduled" &&
2704
+ settlement.mode === "obligation",
2705
+ )
2706
+ .map((settlement) => [settlement.name, settlement]),
2707
+ );
2708
+ const collectionOwnerByObligation = new Map<string, string>();
2709
+ for (const settlement of settlements) {
2710
+ if (
2711
+ settlement.archetype !== "credit_facility" &&
2712
+ settlement.archetype !== "recurring_collection"
2713
+ ) {
2714
+ continue;
2715
+ }
2716
+ const target = scheduledObligations.get(settlement.obligation.settlement);
2717
+ const owner = `settlement ${settlement.name}`;
2718
+ if (!target) {
2719
+ error(
2720
+ settlement.obligation.origin,
2721
+ `${owner} must compose with scheduled in mode: obligation; ${settlement.obligation.settlement} is not one`,
2722
+ );
2723
+ continue;
2724
+ }
2725
+ if (settlement.archetype === "credit_facility") {
2726
+ if (target.advanceTo) {
2727
+ error(
2728
+ settlement.obligation.origin,
2729
+ `${owner} references ${target.name}, which already owns an advance; facility draws and obligation repayment must have one disbursement owner`,
2730
+ );
2731
+ }
2732
+ if (
2733
+ target.payee !== settlement.lender ||
2734
+ target.debtor !== settlement.borrower ||
2735
+ target.amount.currency !== settlement.limit.currency
2736
+ ) {
2737
+ error(
2738
+ settlement.obligation.origin,
2739
+ `${owner} must link an obligation whose payee is ${settlement.lender}, debtor is ${settlement.borrower}, and currency is ${settlement.limit.currency}`,
2740
+ );
2741
+ }
2742
+ continue;
2743
+ }
2744
+ const mandatePort = checkedPortByName.get(settlement.mandate.port);
2745
+ const firstCollection = collectionOwnerByObligation.get(target.name);
2746
+ if (firstCollection) {
2747
+ error(
2748
+ settlement.obligation.origin,
2749
+ `${owner} and settlement ${firstCollection} both collect ${target.name}; one obligation may have one collection policy owner`,
2750
+ );
2751
+ } else {
2752
+ collectionOwnerByObligation.set(target.name, settlement.name);
2753
+ }
2754
+ if (
2755
+ mandatePort &&
2756
+ (mandatePort.allowed.length !== 1 ||
2757
+ mandatePort.allowed[0] !== target.payer)
2758
+ ) {
2759
+ error(
2760
+ mandatePort.origin,
2761
+ `${owner} mandate port ${mandatePort.name} must allow only the obligation payer ${target.payer}`,
2762
+ );
2763
+ }
2764
+ }
2765
+
1547
2766
  // --- Carved advances: the hold each one draws against -----------------------
1548
- // `against: retention.release` is the only cross-declaration reference in the
1549
- // language, so it is the only place a settlement's terms are judged against
1550
- // another's. Everything here resolves at check time; nothing is left for a
1551
- // caller to pick.
2767
+ // The generic reference checker above has resolved the named target and exit.
2768
+ // This pass proves the business terms required by an advance carve.
1552
2769
  const heldByName = new Map(
1553
2770
  settlements
1554
2771
  .filter((settlement) => settlement.archetype === "held_payment")
@@ -1561,23 +2778,13 @@ export function checkProgram(program: Program): CheckResult {
1561
2778
  const owner = `settlement ${settlement.name}`;
1562
2779
  const target = settlement.source.settlement;
1563
2780
  const origin = settlement.source.origin;
1564
- const targetDecl = settlementDecls.find(
1565
- (decl) => decl.name.name === target,
1566
- );
1567
- if (!targetDecl) {
1568
- const other = declared.get(target);
1569
- error(
1570
- origin,
1571
- other
1572
- ? `${owner} draws against ${target}, which is a ${other.kind}; an advance carves a held payment's release`
1573
- : `${owner} draws against ${target}, but no settlement with that name is declared`,
1574
- );
1575
- continue;
1576
- }
1577
- if (targetDecl.archetype.name !== "held_payment") {
2781
+ if (
2782
+ settlement.source.targetArchetype !== "held_payment" ||
2783
+ settlement.source.exit !== "release"
2784
+ ) {
1578
2785
  error(
1579
2786
  origin,
1580
- `${owner} draws against ${target}, which is a ${targetDecl.archetype.name}; only a held payment has a release to carve`,
2787
+ `${owner} can only carve held_payment.release; ${target}.${settlement.source.exit} is a ${settlement.source.targetArchetype} exit`,
1581
2788
  );
1582
2789
  continue;
1583
2790
  }
@@ -1593,6 +2800,34 @@ export function checkProgram(program: Program): CheckResult {
1593
2800
  );
1594
2801
  continue;
1595
2802
  }
2803
+ if (hold.amount.name !== settlement.amount.name) {
2804
+ error(
2805
+ origin,
2806
+ `${owner} advances field ${settlement.amount.name}, but ${target}.release carries ${hold.amount.name}; a carve must name the same money field`,
2807
+ );
2808
+ continue;
2809
+ }
2810
+ if (hold.amount.currency !== settlement.amount.currency) {
2811
+ error(
2812
+ origin,
2813
+ `${owner} advances ${settlement.amount.currency}, but ${target}.release carries ${hold.amount.currency}; a carve must use one currency`,
2814
+ );
2815
+ continue;
2816
+ }
2817
+ if (settlement.feeBps > 0) {
2818
+ error(
2819
+ origin,
2820
+ `${owner} adds a fee to a carved advance, but ${target}.release can only prove repayment of the principal field; use a fee-free carve or a scheduled advance`,
2821
+ );
2822
+ continue;
2823
+ }
2824
+ if (hold.fees.some((fee) => fee.bearer === hold.payee && fee.bps > 0)) {
2825
+ error(
2826
+ origin,
2827
+ `${owner} carves ${target}.release after a payee fee reduces it; a carved hold must release the full principal field to the funder`,
2828
+ );
2829
+ continue;
2830
+ }
1596
2831
  if (hold.payer === settlement.funder) {
1597
2832
  error(
1598
2833
  origin,
@@ -1617,8 +2852,11 @@ export function checkProgram(program: Program): CheckResult {
1617
2852
  const recourse = settlements.some(
1618
2853
  (other) =>
1619
2854
  other.archetype === "scheduled" &&
2855
+ other.mode === "transfer" &&
1620
2856
  other.payer === settlement.advanced &&
1621
- other.payee === settlement.funder,
2857
+ other.payee === settlement.funder &&
2858
+ other.amount.name === settlement.amount.name &&
2859
+ other.amount.currency === settlement.amount.currency,
1622
2860
  );
1623
2861
  if (recourse) continue;
1624
2862
  const repayment = `add a scheduled settlement collecting from the ${settlement.advanced.replaceAll("_", " ")} to the ${settlement.funder.replaceAll("_", " ")}`;
@@ -1667,6 +2905,7 @@ export function checkProgram(program: Program): CheckResult {
1667
2905
  diagnostics,
1668
2906
  program: {
1669
2907
  assets: [...assets.values()],
2908
+ derivedAmounts,
1670
2909
  name: header.name.name,
1671
2910
  parties: [...parties.values()],
1672
2911
  ports,