@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/CHANGELOG.md +88 -1
- package/README.md +1 -1
- package/dist/src/archetypes.d.ts +17 -0
- package/dist/src/archetypes.d.ts.map +1 -0
- package/dist/src/archetypes.js +327 -0
- package/dist/src/archetypes.js.map +1 -0
- package/dist/src/ast.d.ts +2 -3
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/check.d.ts +1 -2
- package/dist/src/check.d.ts.map +1 -1
- package/dist/src/check.js +805 -109
- package/dist/src/check.js.map +1 -1
- package/dist/src/entry-overrides.d.ts +44 -0
- package/dist/src/entry-overrides.d.ts.map +1 -0
- package/dist/src/entry-overrides.js +139 -0
- package/dist/src/entry-overrides.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lower.d.ts +57 -4
- package/dist/src/lower.d.ts.map +1 -1
- package/dist/src/lower.js +2587 -98
- package/dist/src/lower.js.map +1 -1
- package/dist/src/model.d.ts +167 -7
- package/dist/src/model.d.ts.map +1 -1
- package/dist/src/model.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/spec/hsx-ir.schema.json +406 -8
- package/src/archetypes.ts +340 -0
- package/src/ast.ts +2 -3
- package/src/check.ts +1363 -124
- package/src/entry-overrides.ts +213 -0
- package/src/index.ts +7 -0
- package/src/lower.ts +2922 -125
- package/src/model.ts +187 -8
- package/src/version.ts +1 -1
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
|
-
|
|
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
|
|
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}
|
|
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}
|
|
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 (
|
|
343
|
-
error(value.within.span,
|
|
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 =
|
|
347
|
-
const days = match
|
|
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
|
|
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 <=
|
|
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
|
|
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
|
-
|
|
602
|
-
|
|
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 =
|
|
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
|
-
|
|
800
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
1099
|
-
//
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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,
|