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

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/dist/src/check.js CHANGED
@@ -5,70 +5,9 @@
5
5
  * exactly when nothing error-severity was found. Warnings are the compiler's
6
6
  * lint voice and never block.
7
7
  */
8
+ import { ARCHETYPE_DEFINITIONS, SETTLEMENT_ARCHETYPES, } from "./archetypes.js";
8
9
  import { ASSET_KINDS, PARTY_KINDS, } from "./model.js";
9
- /** Every archetype the settlement stdlib ships; all of them lower today. */
10
- export const SETTLEMENT_ARCHETYPES = [
11
- "advance",
12
- "deposit",
13
- "held_payment",
14
- "instant_transfer",
15
- "metered",
16
- "pooled_split",
17
- "premium_forward",
18
- "scheduled",
19
- "swap",
20
- ];
21
- /** Each archetype's parameter surface: the entries its body understands. */
22
- const ARCHETYPE_SURFACES = {
23
- advance: {
24
- keys: [
25
- "against",
26
- "amount",
27
- "count",
28
- "every",
29
- "fee",
30
- "first_due",
31
- "funder",
32
- "to",
33
- ],
34
- // The repayment source is one of two shapes (a schedule, or a carve out of
35
- // a hold's release), so the advance case below states that requirement
36
- // itself rather than listing either shape's keys here.
37
- required: ["funder", "to", "amount"],
38
- },
39
- deposit: {
40
- keys: ["amount", "claim", "holder", "payer", "return"],
41
- required: ["payer", "holder", "amount", "claim", "return"],
42
- },
43
- held_payment: {
44
- keys: ["amount", "fees", "on_cancel", "payer", "payee", "release"],
45
- required: ["payer", "payee", "amount", "release"],
46
- },
47
- instant_transfer: {
48
- keys: ["amount", "fees", "payer", "payee"],
49
- required: ["payer", "payee", "amount"],
50
- },
51
- metered: {
52
- keys: ["close_by", "payer", "payee", "rates"],
53
- required: ["payer", "payee", "rates", "close_by"],
54
- },
55
- pooled_split: {
56
- keys: ["amount", "payer", "payout_due", "split"],
57
- required: ["payer", "amount", "split", "payout_due"],
58
- },
59
- premium_forward: {
60
- keys: ["amount", "bind", "carrier", "commission", "on_cancel", "payer"],
61
- required: ["payer", "carrier", "amount", "bind"],
62
- },
63
- scheduled: {
64
- keys: ["amount", "count", "every", "first_due", "payer", "payee"],
65
- required: ["payer", "payee", "amount", "count", "every", "first_due"],
66
- },
67
- swap: {
68
- keys: ["amounts", "between", "dispute", "fees", "release"],
69
- required: ["between", "amounts", "release"],
70
- },
71
- };
10
+ export { SETTLEMENT_ARCHETYPES } from "./archetypes.js";
72
11
  /** The three terms that together declare a finite schedule. */
73
12
  const SCHEDULE_KEYS = ["count", "every", "first_due"];
74
13
  const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
@@ -83,6 +22,7 @@ export function checkProgram(program) {
83
22
  const warning = (span, message) => {
84
23
  diagnostics.push({ message, severity: "warning", span });
85
24
  };
25
+ const derivedAmounts = [];
86
26
  // --- Header ---------------------------------------------------------------
87
27
  const headers = program.decls.filter((decl) => decl.kind === "program");
88
28
  const header = headers[0];
@@ -245,6 +185,7 @@ export function checkProgram(program) {
245
185
  /^piece\d+Amount$/,
246
186
  /^installment\d+Amount$/,
247
187
  /^repayment\d+Amount$/,
188
+ /^carve(?:Hold|Recourse\d+)Id$/,
248
189
  /ShareAmount$/,
249
190
  /AccountId$/,
250
191
  ];
@@ -320,31 +261,31 @@ export function checkProgram(program) {
320
261
  port: value.name.name,
321
262
  };
322
263
  };
323
- const parseDisputeEntry = (entry, owner) => {
264
+ const parseDisputeEntry = (entry, owner, label = "dispute") => {
324
265
  if (!entry)
325
266
  return undefined;
326
267
  noQualifiers(entry, owner);
327
268
  const value = entry.value;
328
269
  if (value.kind !== "port_ref") {
329
- error(value.span, `${owner} needs one whole-trade dispute decision, like: dispute: port resolve_dispute within P14D`);
270
+ error(value.span, `${owner} needs one ${label} decision through a fixed windowed port`);
330
271
  return undefined;
331
272
  }
332
273
  if (!portNames.has(value.name.name)) {
333
- error(value.name.span, `${owner} disputes through port ${value.name.name}, but no port with that name is declared`);
274
+ error(value.name.span, `${owner} routes ${label} through port ${value.name.name}, but no port with that name is declared`);
334
275
  return undefined;
335
276
  }
336
277
  if (!value.within) {
337
- error(value.span, `${owner} dispute needs a fixed window, like: dispute: port ${value.name.name} within P14D`);
278
+ error(value.span, `${owner} ${label} needs a fixed window, like: ${entry.key.name}: port ${value.name.name} within P14D`);
338
279
  return undefined;
339
280
  }
340
281
  const raw = value.within.name;
341
282
  const match = /^P([1-9]\d{0,3})([DW])$/.exec(raw);
342
- if (raw !== "P0D" && !match) {
343
- error(value.within.span, `dispute on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`);
283
+ if (!match) {
284
+ error(value.within.span, `${label} on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`);
344
285
  return undefined;
345
286
  }
346
- const magnitude = match ? Number(match[1]) : 0;
347
- const days = match?.[2] === "W" ? magnitude * 7 : magnitude;
287
+ const magnitude = Number(match[1]);
288
+ const days = match[2] === "W" ? magnitude * 7 : magnitude;
348
289
  referencedPorts.add(value.name.name);
349
290
  return {
350
291
  origin: value.span,
@@ -532,7 +473,59 @@ export function checkProgram(program) {
532
473
  error(entry.value.span, `${entry.key.name} on ${owner} names a camelCase date field, like: ${entry.key.name}: dueDate`);
533
474
  return undefined;
534
475
  };
535
- const parseSchedule = (entries, owner, origin) => {
476
+ const parseFieldName = (entry, owner) => {
477
+ if (!entry)
478
+ return undefined;
479
+ noQualifiers(entry, owner);
480
+ if (entry.value.kind === "ident" && CAMEL_CASE.test(entry.value.name)) {
481
+ return reservedFieldName(entry.value.name, entry.value.span, owner)
482
+ ? undefined
483
+ : entry.value.name;
484
+ }
485
+ error(entry.value.span, `${entry.key.name} on ${owner} names a camelCase lineage field, like: ${entry.key.name}: captureReference`);
486
+ return undefined;
487
+ };
488
+ const requireIdentPolicy = (entry, owner, expected, refusal) => {
489
+ if (!entry)
490
+ return false;
491
+ noQualifiers(entry, owner);
492
+ if (entry.value.kind === "ident" && entry.value.name === expected) {
493
+ return true;
494
+ }
495
+ error(entry.value.span, `${entry.key.name} on ${owner} is ${expected}; ${refusal}`);
496
+ return false;
497
+ };
498
+ const parseLiteralCount = (entry, owner, label, minimum, maximum) => {
499
+ if (!entry)
500
+ return undefined;
501
+ noQualifiers(entry, owner);
502
+ const parsed = entry.value.kind === "number" ? Number(entry.value.raw) : Number.NaN;
503
+ if (Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum) {
504
+ return parsed;
505
+ }
506
+ error(entry.value.span, `${label} on ${owner} is a literal integer from ${minimum} through ${maximum}`);
507
+ return undefined;
508
+ };
509
+ const parsePartyList = (entry, owner, label, minimum, maximum) => {
510
+ if (!entry)
511
+ return undefined;
512
+ noQualifiers(entry, owner);
513
+ if (entry.value.kind !== "list" ||
514
+ entry.value.items.length < minimum ||
515
+ entry.value.items.length > maximum) {
516
+ error(entry.value.span, `${label} on ${owner} lists ${minimum} through ${maximum} declared parties`);
517
+ return undefined;
518
+ }
519
+ const names = entry.value.items
520
+ .map((item) => partyRef(item, `${owner} ${label}`))
521
+ .filter((name) => name !== undefined);
522
+ if (new Set(names).size !== names.length) {
523
+ error(entry.value.span, `${label} on ${owner} must not repeat a party`);
524
+ return undefined;
525
+ }
526
+ return names.length === entry.value.items.length ? names : undefined;
527
+ };
528
+ const parseSchedule = (entries, owner, origin, maxCount = 12) => {
536
529
  const countEntry = entries.get("count");
537
530
  const everyEntry = entries.get("every");
538
531
  let count;
@@ -540,11 +533,11 @@ export function checkProgram(program) {
540
533
  noQualifiers(countEntry, owner);
541
534
  const value = countEntry.value;
542
535
  const parsed = value.kind === "number" ? Number(value.raw) : Number.NaN;
543
- if (Number.isInteger(parsed) && parsed >= 2 && parsed <= 12) {
536
+ if (Number.isInteger(parsed) && parsed >= 2 && parsed <= maxCount) {
544
537
  count = parsed;
545
538
  }
546
539
  else {
547
- error(value.span, `count on ${owner} is a literal number of anchors between 2 and 12; the schedule stays finite by construction`);
540
+ error(value.span, `count on ${owner} is a literal number of anchors between 2 and ${maxCount}; the schedule stays finite by construction`);
548
541
  }
549
542
  }
550
543
  let every;
@@ -573,6 +566,42 @@ export function checkProgram(program) {
573
566
  return undefined;
574
567
  return { count, every, firstDueField, origin };
575
568
  };
569
+ const parseSettlementReference = (entry, owner) => {
570
+ noQualifiers(entry, owner);
571
+ const value = entry.value;
572
+ if (value.kind !== "settlement_ref") {
573
+ error(value.span, `${owner} draws against a held payment's release, like: against: retention.release`);
574
+ return undefined;
575
+ }
576
+ const target = value.owner.name;
577
+ const targetDecl = settlementDecls.find((decl) => decl.name.name === target);
578
+ if (!targetDecl) {
579
+ const other = declared.get(target);
580
+ error(value.owner.span, other
581
+ ? `${owner} references ${target}, which is a ${other.kind}; settlement exits belong to settlements`
582
+ : `${owner} references ${target}, but no settlement with that name is declared`);
583
+ return undefined;
584
+ }
585
+ const targetArchetype = targetDecl.archetype.name;
586
+ if (!SETTLEMENT_ARCHETYPES.includes(targetArchetype)) {
587
+ return undefined;
588
+ }
589
+ const exits = ARCHETYPE_DEFINITIONS[targetArchetype]
590
+ .referenceableExits;
591
+ if (!exits.includes(value.member.name)) {
592
+ const offered = exits.length > 0
593
+ ? `it exposes ${exits.join(", ")}`
594
+ : "it exposes no referenceable exits";
595
+ error(value.member.span, `${owner} references ${target}.${value.member.name}, but ${targetArchetype} does not expose that exit; ${offered}`);
596
+ return undefined;
597
+ }
598
+ return {
599
+ exit: value.member.name,
600
+ origin: value.span,
601
+ settlement: target,
602
+ targetArchetype: targetArchetype,
603
+ };
604
+ };
576
605
  /**
577
606
  * An advance draws its repayment from exactly one source: its own finite
578
607
  * schedule, or a carve out of the release of a hold the advanced party is
@@ -598,17 +627,8 @@ export function checkProgram(program) {
598
627
  const schedule = parseSchedule(entries, owner, origin);
599
628
  return schedule ? { kind: "schedule", schedule } : undefined;
600
629
  }
601
- noQualifiers(againstEntry, owner);
602
- const value = againstEntry.value;
603
- if (value.kind !== "settlement_ref") {
604
- error(value.span, `${owner} draws against a held payment's release, like: against: retention.release`);
605
- return undefined;
606
- }
607
- if (value.member.name !== "release") {
608
- error(value.member.span, `an advance carves a hold's release; there is no exit named ${value.member.name} on ${value.owner.name} to draw against`);
609
- return undefined;
610
- }
611
- return { kind: "carve", origin: value.span, settlement: value.owner.name };
630
+ const reference = parseSettlementReference(againstEntry, owner);
631
+ return reference ? { kind: "carve", ...reference } : undefined;
612
632
  };
613
633
  // --- Settlements: one checker per archetype ---------------------------------
614
634
  for (const decl of settlementDecls) {
@@ -620,8 +640,8 @@ export function checkProgram(program) {
620
640
  continue;
621
641
  }
622
642
  const owner = `settlement ${decl.name.name}`;
623
- const surface = ARCHETYPE_SURFACES[archetype];
624
- const entries = entriesOf(decl.body, surface.keys, owner);
643
+ const surface = ARCHETYPE_DEFINITIONS[archetype];
644
+ const entries = entriesOf(decl.body, [...surface.keys, "derived_amount"], owner);
625
645
  for (const required of surface.required) {
626
646
  if (!entries.has(required)) {
627
647
  error(decl.span, `${owner} is missing ${required}`);
@@ -641,7 +661,429 @@ export function checkProgram(program) {
641
661
  }
642
662
  return true;
643
663
  };
664
+ const derived = entries.get("derived_amount");
665
+ if (derived) {
666
+ noQualifiers(derived, owner);
667
+ if (derived.value.kind !== "block") {
668
+ error(derived.value.span, "derived_amount is a block with field, source, rule, and bearer");
669
+ }
670
+ else {
671
+ const terms = entriesOf(derived.value, ["field", "source", "rule", "bearer"], `${owner} derived_amount`);
672
+ for (const key of ["field", "source", "rule", "bearer"]) {
673
+ if (!terms.has(key))
674
+ error(derived.span, `${owner} derived_amount is missing ${key}`);
675
+ }
676
+ const fieldValue = terms.get("field")?.value;
677
+ const baseValue = terms.get("source")?.value;
678
+ const ruleValue = terms.get("rule")?.value;
679
+ const bearerValue = terms.get("bearer")?.value;
680
+ const field = fieldValue?.kind === "ident" ? fieldValue.name : undefined;
681
+ const baseField = baseValue?.kind === "ident" ? baseValue.name : undefined;
682
+ const bearer = bearerValue
683
+ ? partyRef(bearerValue, `${owner} derived amount bearer`)
684
+ : undefined;
685
+ if (fieldValue && fieldValue.kind !== "ident")
686
+ error(fieldValue.span, "derived amount field must be a camelCase identifier");
687
+ if (baseValue && baseValue.kind !== "ident")
688
+ error(baseValue.span, "derived amount source must name one stored money field");
689
+ if (ruleValue && ruleValue.kind !== "percent") {
690
+ error(ruleValue.span, "derived_amount proves percentage-of rules only; fixed and tiered rules are refused");
691
+ }
692
+ if (ruleValue?.kind === "percent" &&
693
+ (ruleValue.bps <= 0 || ruleValue.bps >= TOTAL_BPS)) {
694
+ error(ruleValue.span, "derived amount percentage must be above 0% and below 100%");
695
+ }
696
+ if (field && !CAMEL_CASE.test(field))
697
+ error(fieldValue.span, `derived amount field ${field} must be camelCase`);
698
+ if (baseField && !CAMEL_CASE.test(baseField))
699
+ error(baseValue.span, `derived amount source ${baseField} must be camelCase`);
700
+ if (field && baseField && field === baseField)
701
+ error(derived.span, "derived amount field must differ from its source field");
702
+ if (field &&
703
+ baseField &&
704
+ bearer &&
705
+ ruleValue?.kind === "percent" &&
706
+ ruleValue.bps > 0 &&
707
+ ruleValue.bps < TOTAL_BPS) {
708
+ derivedAmounts.push({
709
+ baseField,
710
+ bearer,
711
+ bps: ruleValue.bps,
712
+ field,
713
+ origin: derived.span,
714
+ settlement: decl.name.name,
715
+ });
716
+ }
717
+ }
718
+ }
644
719
  switch (archetype) {
720
+ case "captured_payment": {
721
+ const payer = party("payer", "payer");
722
+ const payee = party("payee", "payee");
723
+ const sound = distinct(payer, payee, `pays ${payer} from ${payer}; payer and payee must differ`);
724
+ const amount = parseAmount(entries.get("amount"), owner);
725
+ const reserveUntilField = parseDateField(entries.get("reserve_until"), owner);
726
+ const correction = parsePortEntry(entries.get("correction"), owner, "payee correction");
727
+ const externalReversal = parseDisputeEntry(entries.get("external_reversal"), owner, "external reversal");
728
+ const captureInSlices = requireIdentPolicy(entries.get("capture_mode"), owner, "partial_then_full", "capture calls must leave a remainder and settle posts the final remainder");
729
+ const fullCorrection = requireIdentPolicy(entries.get("correction_mode"), owner, "full_only", "repeated partial corrections are not representable yet, so the checker refuses them");
730
+ const noNegativePosition = requireIdentPolicy(entries.get("negative_position"), owner, "reject", "a correction or reversal must fail when the payee cannot fund it");
731
+ const timeoutRejects = requireIdentPolicy(entries.get("timeout"), owner, "reject", "a timeout records no money movement; only a confirmed decision may reverse funds");
732
+ if (entries.has("fees")) {
733
+ error(entries.get("fees").span, `${owner} does not price fees inside captured_payment; compose a separate fee settlement so capture and correction amounts stay exact`);
734
+ }
735
+ if (amount && reserveUntilField === amount.name) {
736
+ error(entries.get("reserve_until")?.span ?? decl.span, `${owner} uses ${amount.name} as both the reserved amount and reserve_until date field; they need distinct names`);
737
+ break;
738
+ }
739
+ if (payer &&
740
+ payee &&
741
+ amount &&
742
+ reserveUntilField &&
743
+ correction &&
744
+ externalReversal &&
745
+ captureInSlices &&
746
+ fullCorrection &&
747
+ noNegativePosition &&
748
+ timeoutRejects &&
749
+ sound) {
750
+ settlements.push({
751
+ amount,
752
+ archetype: "captured_payment",
753
+ correction,
754
+ externalReversal,
755
+ name: decl.name.name,
756
+ origin: decl.span,
757
+ payee,
758
+ payer,
759
+ reserveUntilField,
760
+ });
761
+ }
762
+ break;
763
+ }
764
+ case "settlement_batch": {
765
+ const settlementAccount = party("settlement_account", "settlement account");
766
+ const payoutDestination = party("payout_destination", "payout destination");
767
+ const sound = distinct(settlementAccount, payoutDestination, `routes payout from ${settlementAccount} back to itself; settlement account and payout destination must differ`);
768
+ const sourceCaptureReferenceField = parseFieldName(entries.get("source_capture_refs"), owner);
769
+ const feeReferenceField = parseFieldName(entries.get("fee_entries"), owner);
770
+ const externalReversalReferenceField = parseFieldName(entries.get("external_reversal_offsets"), owner);
771
+ const closeTriggerField = parseDateField(entries.get("close_trigger"), owner);
772
+ const payoutAcknowledgement = parsePortEntry(entries.get("payout_acknowledgement"), owner, "payout acknowledgement");
773
+ const payoutBeneficiaryReferenceField = parseFieldName(entries.get("payout_beneficiary_ref"), owner);
774
+ const rejectsNegative = requireIdentPolicy(entries.get("negative_position"), owner, "reject", "offsets beyond gross capture entries must stop calculation and post no payout");
775
+ const lineageFields = [
776
+ sourceCaptureReferenceField,
777
+ feeReferenceField,
778
+ externalReversalReferenceField,
779
+ closeTriggerField,
780
+ payoutBeneficiaryReferenceField,
781
+ ].filter((field) => field !== undefined);
782
+ if (new Set(lineageFields).size !== lineageFields.length) {
783
+ error(decl.span, `${owner} needs distinct source_capture_refs, fee_entries, external_reversal_offsets, close_trigger, and payout_beneficiary_ref field names`);
784
+ break;
785
+ }
786
+ if (settlementAccount &&
787
+ payoutDestination &&
788
+ sourceCaptureReferenceField &&
789
+ feeReferenceField &&
790
+ externalReversalReferenceField &&
791
+ closeTriggerField &&
792
+ payoutAcknowledgement &&
793
+ payoutBeneficiaryReferenceField &&
794
+ rejectsNegative &&
795
+ sound) {
796
+ settlements.push({
797
+ archetype: "settlement_batch",
798
+ closeTriggerField,
799
+ externalReversalReferenceField,
800
+ feeReferenceField,
801
+ name: decl.name.name,
802
+ origin: decl.span,
803
+ payoutDestination,
804
+ payoutAcknowledgement,
805
+ payoutBeneficiaryReferenceField,
806
+ settlementAccount,
807
+ sourceCaptureReferenceField,
808
+ });
809
+ }
810
+ break;
811
+ }
812
+ case "funding_round": {
813
+ const contributor = party("contributor", "contributor");
814
+ const beneficiary = party("beneficiary", "beneficiary");
815
+ const target = parseAmount(entries.get("target"), owner);
816
+ const commitment = parseAmount(entries.get("commitment"), owner);
817
+ const maxContributors = parseLiteralCount(entries.get("max_contributors"), owner, "max_contributors", 2, 10_000);
818
+ const closeByField = parseDateField(entries.get("close_by"), owner);
819
+ const policiesSound = [
820
+ requireIdentPolicy(entries.get("close_policy"), owner, "threshold", "close must follow the locked committed sum at the stored close date"),
821
+ requireIdentPolicy(entries.get("overfund_policy"), owner, "reject", "a commitment past the remaining target headroom must refuse"),
822
+ requireIdentPolicy(entries.get("cancel_policy"), owner, "before_close", "a commitment may cancel only while its parent remains open"),
823
+ requireIdentPolicy(entries.get("fail_policy"), owner, "whole_commitment_refund", "failure refunds each stored commitment whole"),
824
+ ].every(Boolean);
825
+ const sound = distinct(contributor, beneficiary, `uses ${contributor} as both contributor and beneficiary; the roles must differ`);
826
+ if (target && commitment && target.currency !== commitment.currency) {
827
+ error(commitment.origin, `${owner} mixes ${target.currency} and ${commitment.currency}; target and commitment need one currency`);
828
+ }
829
+ if (target && commitment && target.name === commitment.name) {
830
+ error(commitment.origin, `${owner} uses ${target.name} for both target and commitment; the parent and child amounts need distinct fields`);
831
+ }
832
+ if (contributor &&
833
+ beneficiary &&
834
+ target &&
835
+ commitment &&
836
+ target.currency === commitment.currency &&
837
+ target.name !== commitment.name &&
838
+ maxContributors &&
839
+ closeByField &&
840
+ policiesSound &&
841
+ sound) {
842
+ settlements.push({
843
+ archetype: "funding_round",
844
+ beneficiary,
845
+ cancelPolicy: "before_close",
846
+ closeByField,
847
+ closePolicy: "threshold",
848
+ commitment,
849
+ contributor,
850
+ failPolicy: "whole_commitment_refund",
851
+ maxContributors,
852
+ name: decl.name.name,
853
+ origin: decl.span,
854
+ overfundPolicy: "reject",
855
+ target,
856
+ });
857
+ }
858
+ break;
859
+ }
860
+ case "weighted_distribution": {
861
+ const source = party("source", "source");
862
+ const recipient = party("recipient", "recipient template");
863
+ const amount = parseAmount(entries.get("amount"), owner);
864
+ const weight = parseAmount(entries.get("weight"), owner);
865
+ const maxRecipients = parseLiteralCount(entries.get("max_recipients"), owner, "max_recipients", 2, 10_000);
866
+ const recordAtField = parseDateField(entries.get("record_at"), owner);
867
+ const snapshot = parsePortEntry(entries.get("snapshot"), owner, "entitlement snapshot");
868
+ const policiesSound = [
869
+ requireIdentPolicy(entries.get("rounding_policy"), owner, "largest_remainder", "floor shares use a deterministic largest-remainder allocation with noun id as the tie-break"),
870
+ requireIdentPolicy(entries.get("withholding_policy"), owner, "refuse", "withholding needs its own proved retained-amount mechanism"),
871
+ requireIdentPolicy(entries.get("correction_policy"), owner, "new_distribution", "post-payout correction is a new linked distribution, never a rewrite or caller amount"),
872
+ ].every(Boolean);
873
+ const sound = distinct(source, recipient, `uses ${source} as both source and recipient; the roles must differ`);
874
+ if (amount && weight && amount.currency !== weight.currency) {
875
+ error(weight.origin, `${owner} mixes ${amount.currency} and ${weight.currency}; pool and stored weights need one denomination`);
876
+ }
877
+ if (amount && weight && amount.name === weight.name) {
878
+ error(weight.origin, `${owner} uses ${amount.name} for both pool and weight; the fields must differ`);
879
+ }
880
+ if (source &&
881
+ recipient &&
882
+ amount &&
883
+ weight &&
884
+ amount.currency === weight.currency &&
885
+ amount.name !== weight.name &&
886
+ maxRecipients &&
887
+ recordAtField &&
888
+ snapshot &&
889
+ policiesSound &&
890
+ sound) {
891
+ settlements.push({
892
+ amount,
893
+ archetype: "weighted_distribution",
894
+ correctionPolicy: "new_distribution",
895
+ maxRecipients,
896
+ name: decl.name.name,
897
+ origin: decl.span,
898
+ recipient,
899
+ recordAtField,
900
+ roundingPolicy: "largest_remainder",
901
+ snapshot,
902
+ source,
903
+ weight,
904
+ withholdingPolicy: "refuse",
905
+ });
906
+ }
907
+ break;
908
+ }
909
+ case "credit_facility": {
910
+ const lender = party("lender", "lender");
911
+ const borrower = party("borrower", "borrower");
912
+ const drawDestination = party("draw_destination", "draw destination");
913
+ const limit = parseAmount(entries.get("limit"), owner);
914
+ const expiresAtField = parseDateField(entries.get("expires_at"), owner);
915
+ const obligationEntry = entries.get("obligation");
916
+ const obligation = obligationEntry
917
+ ? parseSettlementReference(obligationEntry, owner)
918
+ : undefined;
919
+ const availabilityEntry = entries.get("availability_policy");
920
+ let availabilityPolicy;
921
+ if (availabilityEntry?.value.kind === "ident" &&
922
+ (availabilityEntry.value.name === "revolving" ||
923
+ availabilityEntry.value.name === "non_revolving")) {
924
+ availabilityPolicy = availabilityEntry.value.name;
925
+ }
926
+ else if (availabilityEntry) {
927
+ error(availabilityEntry.value.span, `availability_policy on ${owner} is revolving or non_revolving; the factpack does not justify an implicit choice`);
928
+ }
929
+ const policiesSound = [
930
+ requireIdentPolicy(entries.get("expiry_policy"), owner, "freeze_draws", "expiry stops new draws without closing linked debt"),
931
+ requireIdentPolicy(entries.get("close_policy"), owner, "no_open_draws", "the facility closes only after every linked obligation resolves"),
932
+ ].every(Boolean);
933
+ const rolesSound = [
934
+ distinct(lender, borrower, `uses ${lender} as both lender and borrower; the roles must differ`),
935
+ distinct(lender, drawDestination, `draws back to its lender ${lender}; draw_destination must differ`),
936
+ ].every(Boolean);
937
+ if (lender &&
938
+ borrower &&
939
+ drawDestination &&
940
+ limit &&
941
+ expiresAtField &&
942
+ obligation &&
943
+ availabilityPolicy &&
944
+ policiesSound &&
945
+ rolesSound) {
946
+ settlements.push({
947
+ archetype: "credit_facility",
948
+ availabilityPolicy,
949
+ borrower,
950
+ closePolicy: "no_open_draws",
951
+ drawDestination,
952
+ expiresAtField,
953
+ expiryPolicy: "freeze_draws",
954
+ lender,
955
+ limit,
956
+ name: decl.name.name,
957
+ obligation,
958
+ origin: decl.span,
959
+ });
960
+ }
961
+ break;
962
+ }
963
+ case "recurring_collection": {
964
+ const obligationEntry = entries.get("obligation");
965
+ const obligation = obligationEntry
966
+ ? parseSettlementReference(obligationEntry, owner)
967
+ : undefined;
968
+ const mandate = parsePortEntry(entries.get("mandate"), owner, "mandate evidence");
969
+ const policiesSound = [
970
+ requireIdentPolicy(entries.get("attempt_policy"), owner, "explicit", "every attempt is a separate receipted call"),
971
+ requireIdentPolicy(entries.get("period_idempotency"), owner, "obligation_and_anchor", "the parent obligation and stored anchor form the period identity"),
972
+ requireIdentPolicy(entries.get("retry_policy"), owner, "explicit_attempt", "PRINCIPLES.md bans hidden retries; a retry needs a new explicit idempotency key"),
973
+ requireIdentPolicy(entries.get("failure_policy"), owner, "parent_delinquency", "the scheduled obligation owns due state and delinquency"),
974
+ ].every(Boolean);
975
+ if (obligation && mandate && policiesSound) {
976
+ settlements.push({
977
+ archetype: "recurring_collection",
978
+ attemptPolicy: "explicit",
979
+ failurePolicy: "parent_delinquency",
980
+ mandate,
981
+ name: decl.name.name,
982
+ obligation,
983
+ origin: decl.span,
984
+ periodIdempotency: "obligation_and_anchor",
985
+ retryPolicy: "explicit_attempt",
986
+ });
987
+ }
988
+ break;
989
+ }
990
+ case "conditional_disbursement": {
991
+ const source = party("source", "source");
992
+ const destination = party("destination", "destination");
993
+ const cap = parseAmount(entries.get("cap"), owner);
994
+ const amount = parseAmount(entries.get("amount"), owner);
995
+ const decision = parsePortEntry(entries.get("decision"), owner, "capped disbursement decision");
996
+ const policiesSound = [
997
+ requireIdentPolicy(entries.get("reopen_policy"), owner, "refuse", "a closed decision cannot be reopened; create a new linked disbursement"),
998
+ requireIdentPolicy(entries.get("recovery_policy"), owner, "separate_transfer", "recovery is a new money movement with its own receipt"),
999
+ ].every(Boolean);
1000
+ const sound = distinct(source, destination, `pays ${source} from itself; source and destination must differ`);
1001
+ if (cap && amount && cap.currency !== amount.currency) {
1002
+ error(amount.origin, `${owner} mixes ${cap.currency} and ${amount.currency}; cap and approved amount need one currency`);
1003
+ }
1004
+ if (cap && amount && cap.name === amount.name) {
1005
+ error(amount.origin, `${owner} uses ${cap.name} for both cap and approved amount; the fields must differ`);
1006
+ }
1007
+ if (source &&
1008
+ destination &&
1009
+ cap &&
1010
+ amount &&
1011
+ cap.currency === amount.currency &&
1012
+ cap.name !== amount.name &&
1013
+ decision &&
1014
+ policiesSound &&
1015
+ sound) {
1016
+ settlements.push({
1017
+ amount,
1018
+ archetype: "conditional_disbursement",
1019
+ cap,
1020
+ decision,
1021
+ destination,
1022
+ name: decl.name.name,
1023
+ origin: decl.span,
1024
+ recoveryPolicy: "separate_transfer",
1025
+ reopenPolicy: "refuse",
1026
+ source,
1027
+ });
1028
+ }
1029
+ break;
1030
+ }
1031
+ case "rotating_pool": {
1032
+ const members = parsePartyList(entries.get("members"), owner, "members", 2, 5);
1033
+ const payoutOrder = parsePartyList(entries.get("payout_order"), owner, "payout_order", 2, 5);
1034
+ const contribution = parseAmount(entries.get("contribution"), owner);
1035
+ const schedule = parseSchedule(entries, owner, decl.span, 12);
1036
+ const guarantor = entries.has("guarantor")
1037
+ ? party("guarantor", "guarantor")
1038
+ : undefined;
1039
+ const policiesSound = [
1040
+ requireIdentPolicy(entries.get("default_policy"), owner, "due_condition", "default follows an unmet stored contribution due condition"),
1041
+ requireIdentPolicy(entries.get("guarantee_policy"), owner, "funded_only", "a guarantee changes money only through an explicit funded contribution"),
1042
+ requireIdentPolicy(entries.get("exit_policy"), owner, "before_activation_only", "membership and order freeze before the first contribution"),
1043
+ ].every(Boolean);
1044
+ let rosterSound = true;
1045
+ if (members && payoutOrder) {
1046
+ const memberSet = new Set(members);
1047
+ if (payoutOrder.length !== members.length ||
1048
+ payoutOrder.some((name) => !memberSet.has(name))) {
1049
+ error(entries.get("payout_order")?.span ?? decl.span, `${owner} payout_order must contain every member exactly once`);
1050
+ rosterSound = false;
1051
+ }
1052
+ }
1053
+ if (members && schedule && schedule.count !== members.length) {
1054
+ error(entries.get("count")?.span ?? decl.span, `${owner} count ${schedule.count} must equal its ${members.length}-member roster`);
1055
+ rosterSound = false;
1056
+ }
1057
+ if (members && guarantor && members.includes(guarantor)) {
1058
+ error(entries.get("guarantor")?.span ?? decl.span, `${owner} guarantor must differ from every member`);
1059
+ rosterSound = false;
1060
+ }
1061
+ if (members && guarantor && members.length > 4) {
1062
+ error(entries.get("members")?.span ?? decl.span, `${owner} supports at most 4 members with a guarantor so every generated child binding stays within the 8-entry cap`);
1063
+ rosterSound = false;
1064
+ }
1065
+ if (members &&
1066
+ payoutOrder &&
1067
+ contribution &&
1068
+ schedule &&
1069
+ policiesSound &&
1070
+ rosterSound) {
1071
+ settlements.push({
1072
+ archetype: "rotating_pool",
1073
+ contribution,
1074
+ defaultPolicy: "due_condition",
1075
+ exitPolicy: "before_activation_only",
1076
+ guaranteePolicy: "funded_only",
1077
+ ...(guarantor ? { guarantor } : {}),
1078
+ members,
1079
+ name: decl.name.name,
1080
+ origin: decl.span,
1081
+ payoutOrder,
1082
+ schedule,
1083
+ });
1084
+ }
1085
+ break;
1086
+ }
645
1087
  case "swap": {
646
1088
  const betweenEntry = entries.get("between");
647
1089
  let sideA;
@@ -796,18 +1238,70 @@ export function checkProgram(program) {
796
1238
  const commissionBps = parsePercentEntry(entries.get("commission"), owner, "commission") ??
797
1239
  0;
798
1240
  const onCancel = parseCancelPolicy(entries.get("on_cancel"), owner, [payer, carrier].filter((name) => name !== undefined), "this settlement's payer or carrier");
799
- if (payer && carrier && amount && bind && sound) {
800
- settlements.push({
1241
+ const extensionKeys = [
1242
+ "policy_ref",
1243
+ "renewal_due",
1244
+ "renewal_policy",
1245
+ "endorsement",
1246
+ "endorsement_policy",
1247
+ "lapse_policy",
1248
+ ];
1249
+ const extendsPolicy = extensionKeys.some((key) => entries.has(key));
1250
+ if (extendsPolicy) {
1251
+ for (const key of extensionKeys) {
1252
+ if (!entries.has(key))
1253
+ error(decl.span, `${owner} is missing ${key}`);
1254
+ }
1255
+ }
1256
+ const policyReferenceField = extendsPolicy
1257
+ ? parseFieldName(entries.get("policy_ref"), owner)
1258
+ : undefined;
1259
+ const renewalDueField = extendsPolicy
1260
+ ? parseDateField(entries.get("renewal_due"), owner)
1261
+ : undefined;
1262
+ const endorsement = extendsPolicy
1263
+ ? parsePortEntry(entries.get("endorsement"), owner, "endorsement evidence")
1264
+ : undefined;
1265
+ const extensionPoliciesSound = !extendsPolicy
1266
+ ? true
1267
+ : [
1268
+ requireIdentPolicy(entries.get("renewal_policy"), owner, "explicit_new_forward", "each renewal creates a new premium_forward instead of moving money from policy state"),
1269
+ requireIdentPolicy(entries.get("endorsement_policy"), owner, "non_money_only", "a money adjustment needs a separate corrected movement"),
1270
+ requireIdentPolicy(entries.get("lapse_policy"), owner, "due_condition", "lapse follows the stored renewal due condition"),
1271
+ ].every(Boolean);
1272
+ if (payer &&
1273
+ carrier &&
1274
+ amount &&
1275
+ bind &&
1276
+ sound &&
1277
+ extensionPoliciesSound &&
1278
+ (!extendsPolicy ||
1279
+ (policyReferenceField && renewalDueField && endorsement))) {
1280
+ const checked = {
801
1281
  amount,
802
1282
  archetype: "premium_forward",
803
1283
  bind,
804
1284
  carrier,
805
1285
  commissionBps,
1286
+ ...(endorsement ? { endorsement } : {}),
1287
+ ...(extendsPolicy &&
1288
+ policyReferenceField &&
1289
+ renewalDueField &&
1290
+ endorsement
1291
+ ? {
1292
+ endorsementPolicy: "non_money_only",
1293
+ lapsePolicy: "due_condition",
1294
+ policyReferenceField,
1295
+ renewalDueField,
1296
+ renewalPolicy: "explicit_new_forward",
1297
+ }
1298
+ : {}),
806
1299
  name: decl.name.name,
807
1300
  ...(onCancel ? { onCancel } : {}),
808
1301
  origin: decl.span,
809
1302
  payer,
810
- });
1303
+ };
1304
+ settlements.push(checked);
811
1305
  }
812
1306
  break;
813
1307
  }
@@ -841,15 +1335,90 @@ export function checkProgram(program) {
841
1335
  const payee = party("payee", "payee");
842
1336
  const sound = distinct(payer, payee, `pays ${payer} from ${payer}; payer and payee must differ`);
843
1337
  const amount = parseAmount(entries.get("amount"), owner);
844
- const schedule = parseSchedule(entries, owner, decl.span);
1338
+ const requestedMode = entries.get("mode")?.value;
1339
+ const obligationModeRequested = requestedMode?.kind === "ident" &&
1340
+ requestedMode.name === "obligation";
1341
+ const schedule = parseSchedule(entries, owner, decl.span, obligationModeRequested ? 7 : 12);
845
1342
  if (amount && schedule && amount.name === schedule.firstDueField) {
846
1343
  error(schedule.origin, `${owner} uses ${amount.name} as both the amount and the first_due date field; they need distinct names`);
847
1344
  break;
848
1345
  }
849
- if (payer && payee && amount && schedule && sound) {
1346
+ const modeEntry = entries.get("mode");
1347
+ const obligationKeys = [
1348
+ "advance_to",
1349
+ "debtor",
1350
+ "delinquency_policy",
1351
+ "partial_payment",
1352
+ "refund_policy",
1353
+ "repayment_matching",
1354
+ "reschedule_policy",
1355
+ ];
1356
+ const hasObligationKey = obligationKeys.some((key) => entries.has(key));
1357
+ if (!modeEntry && hasObligationKey) {
1358
+ error(decl.span, `${owner} uses obligation policies without mode: obligation`);
1359
+ break;
1360
+ }
1361
+ if (!modeEntry) {
1362
+ if (payer && payee && amount && schedule && sound) {
1363
+ settlements.push({
1364
+ amount,
1365
+ archetype: "scheduled",
1366
+ mode: "transfer",
1367
+ name: decl.name.name,
1368
+ origin: decl.span,
1369
+ payee,
1370
+ payer,
1371
+ schedule,
1372
+ });
1373
+ }
1374
+ break;
1375
+ }
1376
+ if (!requireIdentPolicy(modeEntry, owner, "obligation", "omit mode for a fixed transfer schedule")) {
1377
+ break;
1378
+ }
1379
+ const requiredPolicies = [
1380
+ "debtor",
1381
+ "partial_payment",
1382
+ "repayment_matching",
1383
+ "refund_policy",
1384
+ "reschedule_policy",
1385
+ "delinquency_policy",
1386
+ ];
1387
+ for (const key of requiredPolicies) {
1388
+ if (!entries.has(key))
1389
+ error(decl.span, `${owner} is missing ${key}`);
1390
+ }
1391
+ const debtor = party("debtor", "debtor");
1392
+ const advanceTo = entries.has("advance_to")
1393
+ ? party("advance_to", "advance recipient")
1394
+ : undefined;
1395
+ const policiesSound = [
1396
+ requireIdentPolicy(entries.get("partial_payment"), owner, "anchor_bound", "payments must bind to one stored installment anchor"),
1397
+ requireIdentPolicy(entries.get("repayment_matching"), owner, "obligation_and_anchor", "each payment must name both the obligation and its installment anchor; fuzzy or balance-wide matching is refused"),
1398
+ requireIdentPolicy(entries.get("refund_policy"), owner, "full_payment_only", "a refund reverses one stored payment whole so it cannot exceed that payment"),
1399
+ requireIdentPolicy(entries.get("reschedule_policy"), owner, "refuse", "forward-version carryover is not mechanically proven; create a new obligation after closing this one"),
1400
+ requireIdentPolicy(entries.get("delinquency_policy"), owner, "due_condition", "delinquency must come from an unmet stored due anchor"),
1401
+ ].every(Boolean);
1402
+ const rolesSound = [
1403
+ distinct(debtor, payee, `${owner} names ${debtor} as both debtor and settlement recipient; they must differ`),
1404
+ advanceTo
1405
+ ? distinct(advanceTo, debtor, `${owner} advances to its debtor ${debtor}; advance_to must name a distinct recipient`)
1406
+ : true,
1407
+ ].every(Boolean);
1408
+ if (payer &&
1409
+ payee &&
1410
+ amount &&
1411
+ schedule &&
1412
+ sound &&
1413
+ debtor &&
1414
+ policiesSound &&
1415
+ rolesSound) {
850
1416
  settlements.push({
1417
+ ...(advanceTo ? { advanceTo } : {}),
851
1418
  amount,
852
1419
  archetype: "scheduled",
1420
+ debtor,
1421
+ mode: "obligation",
853
1422
  name: decl.name.name,
854
1423
  origin: decl.span,
855
1424
  payee,
@@ -893,12 +1462,19 @@ export function checkProgram(program) {
893
1462
  const closeByField = parseDateField(entries.get("close_by"), owner);
894
1463
  const rates = [];
895
1464
  const ratesEntry = entries.get("rates");
1465
+ let withinEventCap = true;
896
1466
  if (ratesEntry) {
897
1467
  noQualifiers(ratesEntry, owner);
898
1468
  if (ratesEntry.value.kind !== "block") {
899
1469
  error(ratesEntry.value.span, `rates is a block of per-unit prices, like: rates { api_call: unitFee: money(SAR) }`);
900
1470
  }
901
1471
  else {
1472
+ const maxRates = ARCHETYPE_DEFINITIONS.metered.maxRates ??
1473
+ ARCHETYPE_DEFINITIONS.metered.eventCap;
1474
+ if (ratesEntry.value.entries.length > maxRates) {
1475
+ error(ratesEntry.value.span, `${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`);
1476
+ withinEventCap = false;
1477
+ }
902
1478
  for (const rate of ratesEntry.value.entries) {
903
1479
  if (!SNAKE_CASE.test(rate.key.name)) {
904
1480
  error(rate.key.span, `meter names are snake_case; "${rate.key.name}" is not`);
@@ -932,7 +1508,12 @@ export function checkProgram(program) {
932
1508
  error(decl.span, `${owner} uses ${closeByField} as both a rate field and the close_by date field; they need distinct names`);
933
1509
  break;
934
1510
  }
935
- if (payer && payee && closeByField && rates.length > 0 && sound) {
1511
+ if (payer &&
1512
+ payee &&
1513
+ closeByField &&
1514
+ rates.length > 0 &&
1515
+ sound &&
1516
+ withinEventCap) {
936
1517
  settlements.push({
937
1518
  archetype: "metered",
938
1519
  closeByField,
@@ -958,6 +1539,12 @@ export function checkProgram(program) {
958
1539
  error(splitEntry.value.span, `${owner} splits to a single recipient; a pool distributes between at least two`);
959
1540
  break;
960
1541
  }
1542
+ const maxRecipients = ARCHETYPE_DEFINITIONS.pooled_split.maxRecipients ??
1543
+ Math.floor(ARCHETYPE_DEFINITIONS.pooled_split.eventCap / 2);
1544
+ const withinEventCap = !split || split.shares.length <= maxRecipients;
1545
+ if (!withinEventCap) {
1546
+ error(splitEntry.value.span, `${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`);
1547
+ }
961
1548
  // The lowering names each share field ${camelCase(party)}ShareAmount;
962
1549
  // camelCasing is not injective (a_2b and a2b collide), so two shares
963
1550
  // must never map onto one generated field.
@@ -982,7 +1569,7 @@ export function checkProgram(program) {
982
1569
  error(decl.span, `${owner} uses ${amount.name} as both the amount and the payout_due date field; they need distinct names`);
983
1570
  break;
984
1571
  }
985
- if (payer && amount && distributeDueField && split) {
1572
+ if (payer && amount && distributeDueField && split && withinEventCap) {
986
1573
  settlements.push({
987
1574
  amount,
988
1575
  archetype: "pooled_split",
@@ -1074,6 +1661,63 @@ export function checkProgram(program) {
1074
1661
  }
1075
1662
  const checkedPortByName = new Map(ports.map((port) => [port.name, port]));
1076
1663
  for (const settlement of settlements) {
1664
+ if (settlement.archetype === "captured_payment") {
1665
+ const correctionPort = checkedPortByName.get(settlement.correction.port);
1666
+ if (correctionPort &&
1667
+ (correctionPort.allowed.length !== 1 ||
1668
+ correctionPort.allowed[0] !== settlement.payee)) {
1669
+ error(correctionPort.origin, `captured_payment ${settlement.name} correction port ${correctionPort.name} must allow only its payee ${settlement.payee}`);
1670
+ }
1671
+ const reversalPort = checkedPortByName.get(settlement.externalReversal.port);
1672
+ if (reversalPort &&
1673
+ !reversalPort.fields.some((field) => field.name === "externalReference" && field.type.kind === "text")) {
1674
+ error(reversalPort.origin, `captured_payment ${settlement.name} external reversal port ${reversalPort.name} needs shape { externalReference: text }`);
1675
+ }
1676
+ continue;
1677
+ }
1678
+ if (settlement.archetype === "settlement_batch") {
1679
+ const acknowledgementPort = checkedPortByName.get(settlement.payoutAcknowledgement.port);
1680
+ if (acknowledgementPort &&
1681
+ !acknowledgementPort.fields.some((field) => field.name === "acknowledgementReference" &&
1682
+ field.type.kind === "text")) {
1683
+ error(acknowledgementPort.origin, `settlement_batch ${settlement.name} payout acknowledgement port ${acknowledgementPort.name} needs shape { acknowledgementReference: text }`);
1684
+ }
1685
+ continue;
1686
+ }
1687
+ if (settlement.archetype === "weighted_distribution") {
1688
+ const port = checkedPortByName.get(settlement.snapshot.port);
1689
+ if (port &&
1690
+ !port.fields.some((field) => field.name === "evidenceReference" && field.type.kind === "text")) {
1691
+ error(port.origin, `weighted_distribution ${settlement.name} snapshot port ${port.name} needs shape { evidenceReference: text }`);
1692
+ }
1693
+ continue;
1694
+ }
1695
+ if (settlement.archetype === "recurring_collection") {
1696
+ const port = checkedPortByName.get(settlement.mandate.port);
1697
+ if (port &&
1698
+ !port.fields.some((field) => field.name === "evidenceReference" && field.type.kind === "text")) {
1699
+ error(port.origin, `recurring_collection ${settlement.name} mandate port ${port.name} needs shape { evidenceReference: text }`);
1700
+ }
1701
+ continue;
1702
+ }
1703
+ if (settlement.archetype === "conditional_disbursement") {
1704
+ const port = checkedPortByName.get(settlement.decision.port);
1705
+ if (port &&
1706
+ (!port.fields.some((field) => field.name === "evidenceReference" && field.type.kind === "text") ||
1707
+ port.allowed.length !== 1 ||
1708
+ port.allowed[0] !== settlement.source)) {
1709
+ error(port.origin, `conditional_disbursement ${settlement.name} decision port ${port.name} must allow only ${settlement.source} and needs shape { evidenceReference: text }`);
1710
+ }
1711
+ continue;
1712
+ }
1713
+ if (settlement.archetype === "premium_forward" && settlement.endorsement) {
1714
+ const port = checkedPortByName.get(settlement.endorsement.port);
1715
+ if (port &&
1716
+ !port.fields.some((field) => field.name === "evidenceReference" && field.type.kind === "text")) {
1717
+ error(port.origin, `premium_forward ${settlement.name} endorsement port ${port.name} needs shape { evidenceReference: text }`);
1718
+ }
1719
+ continue;
1720
+ }
1077
1721
  if (settlement.archetype !== "swap")
1078
1722
  continue;
1079
1723
  const parties = new Set(settlement.sides.map((side) => side.party));
@@ -1094,11 +1738,50 @@ export function checkProgram(program) {
1094
1738
  }
1095
1739
  }
1096
1740
  }
1741
+ const scheduledObligations = new Map(settlements
1742
+ .filter((settlement) => settlement.archetype === "scheduled" &&
1743
+ settlement.mode === "obligation")
1744
+ .map((settlement) => [settlement.name, settlement]));
1745
+ const collectionOwnerByObligation = new Map();
1746
+ for (const settlement of settlements) {
1747
+ if (settlement.archetype !== "credit_facility" &&
1748
+ settlement.archetype !== "recurring_collection") {
1749
+ continue;
1750
+ }
1751
+ const target = scheduledObligations.get(settlement.obligation.settlement);
1752
+ const owner = `settlement ${settlement.name}`;
1753
+ if (!target) {
1754
+ error(settlement.obligation.origin, `${owner} must compose with scheduled in mode: obligation; ${settlement.obligation.settlement} is not one`);
1755
+ continue;
1756
+ }
1757
+ if (settlement.archetype === "credit_facility") {
1758
+ if (target.advanceTo) {
1759
+ error(settlement.obligation.origin, `${owner} references ${target.name}, which already owns an advance; facility draws and obligation repayment must have one disbursement owner`);
1760
+ }
1761
+ if (target.payee !== settlement.lender ||
1762
+ target.debtor !== settlement.borrower ||
1763
+ target.amount.currency !== settlement.limit.currency) {
1764
+ error(settlement.obligation.origin, `${owner} must link an obligation whose payee is ${settlement.lender}, debtor is ${settlement.borrower}, and currency is ${settlement.limit.currency}`);
1765
+ }
1766
+ continue;
1767
+ }
1768
+ const mandatePort = checkedPortByName.get(settlement.mandate.port);
1769
+ const firstCollection = collectionOwnerByObligation.get(target.name);
1770
+ if (firstCollection) {
1771
+ error(settlement.obligation.origin, `${owner} and settlement ${firstCollection} both collect ${target.name}; one obligation may have one collection policy owner`);
1772
+ }
1773
+ else {
1774
+ collectionOwnerByObligation.set(target.name, settlement.name);
1775
+ }
1776
+ if (mandatePort &&
1777
+ (mandatePort.allowed.length !== 1 ||
1778
+ mandatePort.allowed[0] !== target.payer)) {
1779
+ error(mandatePort.origin, `${owner} mandate port ${mandatePort.name} must allow only the obligation payer ${target.payer}`);
1780
+ }
1781
+ }
1097
1782
  // --- Carved advances: the hold each one draws against -----------------------
1098
- // `against: retention.release` is the only cross-declaration reference in the
1099
- // language, so it is the only place a settlement's terms are judged against
1100
- // another's. Everything here resolves at check time; nothing is left for a
1101
- // caller to pick.
1783
+ // The generic reference checker above has resolved the named target and exit.
1784
+ // This pass proves the business terms required by an advance carve.
1102
1785
  const heldByName = new Map(settlements
1103
1786
  .filter((settlement) => settlement.archetype === "held_payment")
1104
1787
  .map((settlement) => [settlement.name, settlement]));
@@ -1111,16 +1794,9 @@ export function checkProgram(program) {
1111
1794
  const owner = `settlement ${settlement.name}`;
1112
1795
  const target = settlement.source.settlement;
1113
1796
  const origin = settlement.source.origin;
1114
- const targetDecl = settlementDecls.find((decl) => decl.name.name === target);
1115
- if (!targetDecl) {
1116
- const other = declared.get(target);
1117
- error(origin, other
1118
- ? `${owner} draws against ${target}, which is a ${other.kind}; an advance carves a held payment's release`
1119
- : `${owner} draws against ${target}, but no settlement with that name is declared`);
1120
- continue;
1121
- }
1122
- if (targetDecl.archetype.name !== "held_payment") {
1123
- error(origin, `${owner} draws against ${target}, which is a ${targetDecl.archetype.name}; only a held payment has a release to carve`);
1797
+ if (settlement.source.targetArchetype !== "held_payment" ||
1798
+ settlement.source.exit !== "release") {
1799
+ error(origin, `${owner} can only carve held_payment.release; ${target}.${settlement.source.exit} is a ${settlement.source.targetArchetype} exit`);
1124
1800
  continue;
1125
1801
  }
1126
1802
  const hold = heldByName.get(target);
@@ -1132,6 +1808,22 @@ export function checkProgram(program) {
1132
1808
  error(origin, `${owner} advances ${settlement.advanced}, but ${target} releases to ${hold.payee}; an advance carves the release of the party it finances`);
1133
1809
  continue;
1134
1810
  }
1811
+ if (hold.amount.name !== settlement.amount.name) {
1812
+ error(origin, `${owner} advances field ${settlement.amount.name}, but ${target}.release carries ${hold.amount.name}; a carve must name the same money field`);
1813
+ continue;
1814
+ }
1815
+ if (hold.amount.currency !== settlement.amount.currency) {
1816
+ error(origin, `${owner} advances ${settlement.amount.currency}, but ${target}.release carries ${hold.amount.currency}; a carve must use one currency`);
1817
+ continue;
1818
+ }
1819
+ if (settlement.feeBps > 0) {
1820
+ error(origin, `${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`);
1821
+ continue;
1822
+ }
1823
+ if (hold.fees.some((fee) => fee.bearer === hold.payee && fee.bps > 0)) {
1824
+ error(origin, `${owner} carves ${target}.release after a payee fee reduces it; a carved hold must release the full principal field to the funder`);
1825
+ continue;
1826
+ }
1135
1827
  if (hold.payer === settlement.funder) {
1136
1828
  error(origin, `${settlement.funder} funds ${target} and finances it too; the party who pays the hold cannot be the funder its release repays`);
1137
1829
  continue;
@@ -1147,8 +1839,11 @@ export function checkProgram(program) {
1147
1839
  // the author chose to declare, so an uncovered one is an error; the
1148
1840
  // pre-funding abandonment every hold carries is a lint.
1149
1841
  const recourse = settlements.some((other) => other.archetype === "scheduled" &&
1842
+ other.mode === "transfer" &&
1150
1843
  other.payer === settlement.advanced &&
1151
- other.payee === settlement.funder);
1844
+ other.payee === settlement.funder &&
1845
+ other.amount.name === settlement.amount.name &&
1846
+ other.amount.currency === settlement.amount.currency);
1152
1847
  if (recourse)
1153
1848
  continue;
1154
1849
  const repayment = `add a scheduled settlement collecting from the ${settlement.advanced.replaceAll("_", " ")} to the ${settlement.funder.replaceAll("_", " ")}`;
@@ -1182,6 +1877,7 @@ export function checkProgram(program) {
1182
1877
  diagnostics,
1183
1878
  program: {
1184
1879
  assets: [...assets.values()],
1880
+ derivedAmounts,
1185
1881
  name: header.name.name,
1186
1882
  parties: [...parties.values()],
1187
1883
  ports,