@hyperscale0/udl 1.0.0-alpha.4 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validation.ts CHANGED
@@ -75,7 +75,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
75
75
  issues: [
76
76
  {
77
77
  code: "resource_limit",
78
- message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns or fewer reference gates`,
78
+ message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns, reference gates, or payout intents`,
79
79
  path: "$.nouns",
80
80
  },
81
81
  ],
@@ -130,6 +130,31 @@ export function validateUdlSchemaValue(
130
130
  return { errors, valid: errors.length === 0 };
131
131
  }
132
132
 
133
+ /**
134
+ * Validates one schema document against the sealed UDL JSON Schema subset
135
+ * without applying it to a value. Frozen tenant contracts use this at their
136
+ * persistence boundary so an unreadable schema cannot become immutable state.
137
+ */
138
+ export function validateUdlJsonSchema(schema: unknown): readonly UdlIssue[] {
139
+ const admissionIssue = structuralBudgetIssue(schema);
140
+ if (admissionIssue) return [admissionIssue];
141
+ if (!isRecord(schema)) {
142
+ return [
143
+ {
144
+ code: "invalid_shape",
145
+ message: "JSON Schema must be an object",
146
+ path: "$",
147
+ },
148
+ ];
149
+ }
150
+
151
+ const issues: UdlIssue[] = [];
152
+ validateJsonSchema(schema, [], (path, message) => {
153
+ issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
154
+ });
155
+ return issues;
156
+ }
157
+
133
158
  function admissionValidationResult(
134
159
  issue: UdlIssue,
135
160
  invalidKeyword: string,
@@ -152,6 +177,7 @@ type AddIssue = (path: readonly PropertyKey[], message: string) => void;
152
177
 
153
178
  const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
154
179
  const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
180
+ const currencyPattern = "^[A-Z]{3}$";
155
181
  const sealedDateTimeFormat = "hyperscale-date-time";
156
182
  const jsonSchemaFormats = new Set([
157
183
  "hyperscale-date",
@@ -902,26 +928,37 @@ function validateSetsAt(
902
928
  base: readonly PropertyKey[],
903
929
  add: AddIssue,
904
930
  ): void {
905
- const writers = new Map<string, string>();
931
+ const writers = new Map<string, string[]>();
932
+ const markerFields = new Set<string>();
906
933
  for (const [verbName, verb] of Object.entries(noun.verbs)) {
907
934
  if (!verb.setsAt) continue;
908
935
  const path = [...base, "verbs", verbName, "setsAt"] as const;
909
- const { field, offset } = verb.setsAt;
936
+ const { field, marker, offset } = verb.setsAt;
937
+ if (marker) markerFields.add(field);
910
938
  if (verbName === "create") {
911
939
  add(
912
940
  path,
913
941
  "setsAt requires a lifecycle transition and cannot run on create",
914
942
  );
915
943
  }
916
- const earlierWriter = writers.get(field);
917
- if (earlierWriter) {
918
- add(
919
- [...path, "field"],
920
- `${field} is already written by verb ${earlierWriter}`,
944
+ const fieldWriters = writers.get(field) ?? [];
945
+ if (fieldWriters.length > 0) {
946
+ const alternatives = [...fieldWriters, verbName];
947
+ const targets = new Set(
948
+ alternatives.map((writer) => noun.lifecycle.transitions[writer]?.to),
921
949
  );
922
- } else {
923
- writers.set(field, verbName);
950
+ const reentrant = alternatives.some((writer) => {
951
+ const transition = noun.lifecycle.transitions[writer];
952
+ return transition?.from.includes(transition.to) === true;
953
+ });
954
+ if (targets.size !== 1 || targets.has(undefined) || reentrant) {
955
+ add(
956
+ [...path, "field"],
957
+ `${field} has multiple writers without one shared one-way destination`,
958
+ );
959
+ }
924
960
  }
961
+ writers.set(field, [...fieldWriters, verbName]);
925
962
 
926
963
  const schema = noun.fields[field];
927
964
  if (!schema) {
@@ -947,6 +984,15 @@ function validateSetsAt(
947
984
  ([, candidate]) =>
948
985
  candidate.due?.field === field || candidate.deadline?.field === field,
949
986
  );
987
+ if (marker) {
988
+ if (readers.length > 0) {
989
+ add(
990
+ path,
991
+ `setsAt marker field ${field} cannot drive a due condition or deadline`,
992
+ );
993
+ }
994
+ continue;
995
+ }
950
996
  if (readers.length === 0) {
951
997
  add(
952
998
  path,
@@ -954,28 +1000,33 @@ function validateSetsAt(
954
1000
  );
955
1001
  continue;
956
1002
  }
1003
+ }
1004
+ for (const [field, fieldWriters] of writers) {
1005
+ if (markerFields.has(field)) continue;
1006
+ const readers = Object.entries(noun.verbs).filter(
1007
+ ([, candidate]) =>
1008
+ candidate.due?.field === field || candidate.deadline?.field === field,
1009
+ );
957
1010
  for (const [readerName] of readers) {
958
- if (
959
- readerName === verbName ||
960
- !verbDominatesReader(noun, verbName, readerName)
961
- ) {
1011
+ if (!verbGroupDominatesReader(noun, fieldWriters, readerName)) {
962
1012
  add(
963
1013
  [...base, "verbs", readerName],
964
- `verb ${readerName} can read ${field} before writer ${verbName}`,
1014
+ `verb ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`,
965
1015
  );
966
1016
  }
967
1017
  }
968
1018
  }
969
1019
  }
970
1020
 
971
- function verbDominatesReader(
1021
+ function verbGroupDominatesReader(
972
1022
  noun: UdlNoun,
973
- writer: string,
1023
+ writers: readonly string[],
974
1024
  reader: string,
975
1025
  ): boolean {
976
- if (!noun.lifecycle.transitions[writer]) return false;
1026
+ if (writers.length === 0) return false;
977
1027
  const readerTransition = noun.lifecycle.transitions[reader];
978
1028
  if (!readerTransition) return false;
1029
+ const removed = new Set(writers);
979
1030
  const reachable = new Set([noun.lifecycle.initial]);
980
1031
  const pending = [noun.lifecycle.initial];
981
1032
  while (pending.length > 0) {
@@ -984,7 +1035,7 @@ function verbDominatesReader(
984
1035
  noun.lifecycle.transitions,
985
1036
  )) {
986
1037
  if (
987
- verb === writer ||
1038
+ removed.has(verb) ||
988
1039
  !transition.from.includes(state) ||
989
1040
  reachable.has(transition.to)
990
1041
  ) {
@@ -1112,6 +1163,7 @@ function validateVerbs(
1112
1163
  references: ReferenceShapeBudget,
1113
1164
  add: AddIssue,
1114
1165
  ): void {
1166
+ validatePayoutsAndSettlement(noun, base, references, add);
1115
1167
  for (const [verb, definition] of Object.entries(noun.verbs)) {
1116
1168
  const verbBase = [...base, "verbs", verb] as const;
1117
1169
  if (verb === "create" && definition.input) {
@@ -1126,6 +1178,17 @@ function validateVerbs(
1126
1178
  "verb input must declare an object-shaped JSON Schema",
1127
1179
  );
1128
1180
  }
1181
+ const inputFields = recordValue(definition.input?.properties);
1182
+ for (const [refKey, inputKey] of Object.entries(
1183
+ definition.captureInput ?? {},
1184
+ )) {
1185
+ if (!Object.hasOwn(inputFields, inputKey)) {
1186
+ add(
1187
+ [...verbBase, "captureInput", refKey],
1188
+ `captured receipt input references undeclared verb input field ${inputKey}`,
1189
+ );
1190
+ }
1191
+ }
1129
1192
 
1130
1193
  addDuplicateIssues(
1131
1194
  definition.moves.map((move) => move.key),
@@ -1229,7 +1292,178 @@ function validateVerbs(
1229
1292
  }
1230
1293
  }
1231
1294
 
1295
+ if (definition.signedSum) {
1296
+ const sumBase = [...verbBase, "signedSum"] as const;
1297
+ if (verb === "create") {
1298
+ add(
1299
+ sumBase,
1300
+ "signedSum cannot run on create: no parent row exists yet",
1301
+ );
1302
+ }
1303
+ const generatedRefs = [
1304
+ {
1305
+ key: definition.signedSum.amountRef,
1306
+ path: [...sumBase, "amountRef"] as const,
1307
+ },
1308
+ ...definition.signedSum.sources.map((source, sourceIndex) => ({
1309
+ key: source.subtotalRef,
1310
+ path: [...sumBase, "sources", sourceIndex, "subtotalRef"] as const,
1311
+ })),
1312
+ ];
1313
+ addDuplicateIssues(
1314
+ generatedRefs.map((ref) => ref.key),
1315
+ sumBase,
1316
+ "signed sum ref",
1317
+ add,
1318
+ );
1319
+ const existingRefs = new Set([
1320
+ ...Object.entries(noun.verbs).flatMap(([candidateVerb, candidate]) => [
1321
+ ...Object.keys(candidate.captureInput ?? {}),
1322
+ ...[...candidate.steps, ...candidate.moves].flatMap((step) =>
1323
+ Object.keys(step.capture ?? {}),
1324
+ ),
1325
+ ...(candidateVerb !== verb && candidate.signedSum
1326
+ ? [
1327
+ candidate.signedSum.amountRef,
1328
+ ...candidate.signedSum.sources.map(
1329
+ (source) => source.subtotalRef,
1330
+ ),
1331
+ ]
1332
+ : []),
1333
+ ]),
1334
+ ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1335
+ ...(noun.subject ? ["subject"] : []),
1336
+ ]);
1337
+ for (const ref of generatedRefs) {
1338
+ if (existingRefs.has(ref.key)) {
1339
+ add(
1340
+ ref.path,
1341
+ `signed sum ref ${ref.key} collides with an existing noun ref key`,
1342
+ );
1343
+ }
1344
+ }
1345
+ if (
1346
+ !definition.signedSum.sources.some((source) => source.sign === "add")
1347
+ ) {
1348
+ add([...sumBase, "sources"], "signedSum needs at least one add source");
1349
+ }
1350
+ const parentCurrencies = Object.entries(noun.fields).filter(
1351
+ ([, schema]) => isCurrencySchema(schema),
1352
+ );
1353
+ if (parentCurrencies.length !== 1) {
1354
+ add(
1355
+ sumBase,
1356
+ `signedSum parent ${noun.id} needs exactly one currency field`,
1357
+ );
1358
+ }
1359
+ const sourceKeys = definition.signedSum.sources.map(
1360
+ (source) =>
1361
+ `${source.nounId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`,
1362
+ );
1363
+ addDuplicateIssues(
1364
+ sourceKeys,
1365
+ [...sumBase, "sources"],
1366
+ "signed sum source",
1367
+ add,
1368
+ );
1369
+ definition.signedSum.sources.forEach((source, sourceIndex) => {
1370
+ for (let priorIndex = 0; priorIndex < sourceIndex; priorIndex += 1) {
1371
+ const prior = definition.signedSum?.sources[priorIndex];
1372
+ if (
1373
+ !prior ||
1374
+ prior.nounId !== source.nounId ||
1375
+ prior.refField !== source.refField ||
1376
+ prior.amountField !== source.amountField ||
1377
+ prior.sign !== source.sign
1378
+ ) {
1379
+ continue;
1380
+ }
1381
+ const priorStatuses = new Set(prior.statuses);
1382
+ const overlap = source.statuses.filter((status) =>
1383
+ priorStatuses.has(status),
1384
+ );
1385
+ if (overlap.length === 0) continue;
1386
+ add(
1387
+ [...sumBase, "sources", sourceIndex, "statuses"],
1388
+ `signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`,
1389
+ );
1390
+ }
1391
+ });
1392
+ definition.signedSum.sources.forEach((source, sourceIndex) => {
1393
+ const sourceBase = [...sumBase, "sources", sourceIndex] as const;
1394
+ const child = nouns.get(source.nounId);
1395
+ if (!child) {
1396
+ add(
1397
+ [...sourceBase, "nounId"],
1398
+ `signedSum references unknown noun ${source.nounId}`,
1399
+ );
1400
+ return;
1401
+ }
1402
+ const refSchema = child.fields[source.refField];
1403
+ if (!refSchema || !references.accepts(refSchema, noun.idPrefix)) {
1404
+ add(
1405
+ [...sourceBase, "refField"],
1406
+ `${source.nounId}.${source.refField} must reference ${noun.id}`,
1407
+ );
1408
+ }
1409
+ const amountSchema = child.fields[source.amountField];
1410
+ if (!amountSchema || !isMoneySchema(amountSchema)) {
1411
+ add(
1412
+ [...sourceBase, "amountField"],
1413
+ `${source.nounId}.${source.amountField} must be a money field`,
1414
+ );
1415
+ }
1416
+ const childCurrencies = Object.entries(child.fields).filter(
1417
+ ([, schema]) => isCurrencySchema(schema),
1418
+ );
1419
+ if (childCurrencies.length !== 1) {
1420
+ add(
1421
+ [...sourceBase, "nounId"],
1422
+ `signedSum source ${source.nounId} needs exactly one currency field`,
1423
+ );
1424
+ }
1425
+ addDuplicateIssues(
1426
+ source.statuses,
1427
+ [...sourceBase, "statuses"],
1428
+ "signed sum status",
1429
+ add,
1430
+ );
1431
+ source.statuses.forEach((status, statusIndex) => {
1432
+ if (!child.lifecycle.states.includes(status)) {
1433
+ add(
1434
+ [...sourceBase, "statuses", statusIndex],
1435
+ `signedSum status ${status} is not declared by ${source.nounId}`,
1436
+ );
1437
+ }
1438
+ });
1439
+ });
1440
+ const amountPath = `refs.${definition.signedSum.amountRef}`;
1441
+ const payoutMoves = Object.values(noun.verbs).flatMap((candidate) =>
1442
+ candidate.moves.filter(
1443
+ (move) =>
1444
+ move.operation === "internal_transfer.create" &&
1445
+ move.bind.amount?.from === "instance" &&
1446
+ move.bind.amount.path === amountPath,
1447
+ ),
1448
+ );
1449
+ const payouts = Object.values(noun.verbs).filter(
1450
+ (candidate) => candidate.payout?.amount === amountPath,
1451
+ );
1452
+ if (payoutMoves.length + payouts.length !== 1) {
1453
+ add(
1454
+ sumBase,
1455
+ `signedSum requires exactly one payout or noun transfer whose amount is ${amountPath}`,
1456
+ );
1457
+ }
1458
+ }
1459
+
1232
1460
  if (definition.due) {
1461
+ if (definition.publicIntent) {
1462
+ add(
1463
+ [...verbBase, "publicIntent"],
1464
+ "a system due verb cannot declare a public intent",
1465
+ );
1466
+ }
1233
1467
  const transition = noun.lifecycle.transitions[verb];
1234
1468
  if (definition.due.every) {
1235
1469
  // A recurring due verb is stationary: each occurrence fires in place
@@ -1321,6 +1555,236 @@ function validateVerbs(
1321
1555
  }
1322
1556
  }
1323
1557
 
1558
+ function validatePayoutsAndSettlement(
1559
+ noun: UdlNoun,
1560
+ base: readonly PropertyKey[],
1561
+ references: ReferenceShapeBudget,
1562
+ add: AddIssue,
1563
+ ): void {
1564
+ const payoutEntries = Object.entries(noun.verbs).filter(
1565
+ ([, definition]) => definition.payout !== undefined,
1566
+ );
1567
+ const settlementEntries = Object.entries(noun.verbs).filter(
1568
+ ([, definition]) => definition.requiresSettlement !== undefined,
1569
+ );
1570
+ if (payoutEntries.length > 0 && settlementEntries.length !== 1) {
1571
+ add(
1572
+ [...base, "verbs"],
1573
+ `payout-owning noun must declare exactly one settlement gate; found ${settlementEntries.length}`,
1574
+ );
1575
+ }
1576
+ const settlementRequirement =
1577
+ settlementEntries.length === 1
1578
+ ? settlementEntries[0]?.[1].requiresSettlement
1579
+ : undefined;
1580
+ if (settlementRequirement) {
1581
+ for (const [verb, definition] of payoutEntries) {
1582
+ if (definition.payout?.capture === settlementRequirement.payoutRef) {
1583
+ continue;
1584
+ }
1585
+ add(
1586
+ [...base, "verbs", verb, "payout", "capture"],
1587
+ `verb ${verb} payout capture ${definition.payout?.capture} is not covered by settlement payoutRef ${settlementRequirement.payoutRef}`,
1588
+ );
1589
+ }
1590
+ }
1591
+
1592
+ const reservedRefs = new Set([
1593
+ ...Object.values(noun.verbs).flatMap((verb) =>
1594
+ Object.keys(verb.captureInput ?? {}),
1595
+ ),
1596
+ ...Object.values(noun.verbs).flatMap((verb) =>
1597
+ [...verb.steps, ...verb.moves].flatMap((step) =>
1598
+ Object.keys(step.capture ?? {}),
1599
+ ),
1600
+ ),
1601
+ ...Object.values(noun.verbs).flatMap((verb) =>
1602
+ verb.signedSum
1603
+ ? [
1604
+ verb.signedSum.amountRef,
1605
+ ...verb.signedSum.sources.map((source) => source.subtotalRef),
1606
+ ]
1607
+ : [],
1608
+ ),
1609
+ ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1610
+ ...(noun.subject ? ["subject"] : []),
1611
+ ]);
1612
+ const moneyWriters = moneyRefWriters(noun);
1613
+ const payoutWriters = new Map<string, string[]>();
1614
+ for (const [verb, definition] of Object.entries(noun.verbs)) {
1615
+ if (!definition.payout) continue;
1616
+ const payout = definition.payout;
1617
+ const payoutBase = [...base, "verbs", verb, "payout"] as const;
1618
+ if (verb === "create") {
1619
+ add(payoutBase, "create cannot declare a payout intent");
1620
+ }
1621
+ if (definition.steps.length > 0 || definition.moves.length > 0) {
1622
+ add(
1623
+ payoutBase,
1624
+ `verb ${verb} payout intent cannot combine with kernel steps or moves`,
1625
+ );
1626
+ }
1627
+ const prior = payoutWriters.get(payout.capture) ?? [];
1628
+ if (reservedRefs.has(payout.capture) || prior.length > 0) {
1629
+ add(
1630
+ [...payoutBase, "capture"],
1631
+ `payout capture ${payout.capture} collides with an existing noun ref key`,
1632
+ );
1633
+ }
1634
+ payoutWriters.set(payout.capture, [...prior, verb]);
1635
+
1636
+ const amount = payout.amount;
1637
+ const [scope, key] = amount.split(".") as [string, string];
1638
+ const amountWriters = moneyWriters.get(key) ?? [];
1639
+ const amountDeclared =
1640
+ scope === "fields"
1641
+ ? isMoneySchema(noun.fields[key] ?? {})
1642
+ : scope === "refs" && amountWriters.length > 0;
1643
+ if (!amountDeclared) {
1644
+ add(
1645
+ [...payoutBase, "amount"],
1646
+ `payout amount ${amount} must name a declared money field or money ref`,
1647
+ );
1648
+ } else if (
1649
+ scope === "refs" &&
1650
+ !amountWriters.includes(verb) &&
1651
+ !amountWriters.includes("create") &&
1652
+ !verbGroupDominatesReader(noun, amountWriters, verb)
1653
+ ) {
1654
+ add(
1655
+ [...payoutBase, "amount"],
1656
+ `payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`,
1657
+ );
1658
+ }
1659
+
1660
+ const currency = noun.fields[payout.currencyField];
1661
+ if (!currency || !isCurrencySchema(currency)) {
1662
+ add(
1663
+ [...payoutBase, "currencyField"],
1664
+ `payout currencyField ${payout.currencyField} must name a currency field`,
1665
+ );
1666
+ }
1667
+ const source = noun.fields[payout.sourceAccountField];
1668
+ if (!source || !references.accepts(source, "acct")) {
1669
+ add(
1670
+ [...payoutBase, "sourceAccountField"],
1671
+ `payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`,
1672
+ );
1673
+ }
1674
+ const beneficiary = noun.fields[payout.beneficiaryField];
1675
+ if (!beneficiary || !references.accepts(beneficiary, "ben")) {
1676
+ add(
1677
+ [...payoutBase, "beneficiaryField"],
1678
+ `payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`,
1679
+ );
1680
+ }
1681
+ const beneficiaryPartyField = noun.parties?.beneficiary;
1682
+ if (!beneficiaryPartyField) {
1683
+ add(
1684
+ [...payoutBase, "beneficiaryPartyField"],
1685
+ "payout requires parties.beneficiary to bind its destination party",
1686
+ );
1687
+ } else if (payout.beneficiaryPartyField !== beneficiaryPartyField) {
1688
+ add(
1689
+ [...payoutBase, "beneficiaryPartyField"],
1690
+ `payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`,
1691
+ );
1692
+ }
1693
+ }
1694
+
1695
+ const settlementCaptures = new Set<string>();
1696
+ for (const [verb, definition] of Object.entries(noun.verbs)) {
1697
+ if (!definition.requiresSettlement) continue;
1698
+ const requirement = definition.requiresSettlement;
1699
+ const requirementBase = [
1700
+ ...base,
1701
+ "verbs",
1702
+ verb,
1703
+ "requiresSettlement",
1704
+ ] as const;
1705
+ if (
1706
+ reservedRefs.has(requirement.capture) ||
1707
+ payoutWriters.has(requirement.capture) ||
1708
+ settlementCaptures.has(requirement.capture)
1709
+ ) {
1710
+ add(
1711
+ [...requirementBase, "capture"],
1712
+ `settlement capture ${requirement.capture} collides with an existing noun ref key`,
1713
+ );
1714
+ }
1715
+ settlementCaptures.add(requirement.capture);
1716
+
1717
+ const writers = payoutWriters.get(requirement.payoutRef) ?? [];
1718
+ if (writers.length === 0) {
1719
+ add(
1720
+ [...requirementBase, "payoutRef"],
1721
+ `requiresSettlement payoutRef ${requirement.payoutRef} is not captured by a payout intent`,
1722
+ );
1723
+ } else if (!verbGroupDominatesReader(noun, writers, verb)) {
1724
+ add(
1725
+ requirementBase,
1726
+ `requiresSettlement can read ${requirement.payoutRef} before payout writers ${writers.join(" or ")}`,
1727
+ );
1728
+ }
1729
+
1730
+ if (!noun.lifecycle.transitions[verb]) {
1731
+ add(
1732
+ requirementBase,
1733
+ "requiresSettlement needs a lifecycle transition and cannot run on create",
1734
+ );
1735
+ }
1736
+ const callerFacets = [
1737
+ definition.port ? "port" : undefined,
1738
+ definition.publicIntent ? "publicIntent" : undefined,
1739
+ definition.input ? "input" : undefined,
1740
+ definition.captureInput ? "captureInput" : undefined,
1741
+ definition.due ? "due" : undefined,
1742
+ definition.deadline ? "deadline" : undefined,
1743
+ ].filter((facet): facet is string => facet !== undefined);
1744
+ for (const facet of callerFacets) {
1745
+ add(
1746
+ [...base, "verbs", verb, facet],
1747
+ `requiresSettlement is system-only and cannot declare ${facet}`,
1748
+ );
1749
+ }
1750
+ if (definition.steps.length > 0) {
1751
+ add(
1752
+ [...base, "verbs", verb, "steps"],
1753
+ "requiresSettlement cannot add kernel steps",
1754
+ );
1755
+ }
1756
+ if (definition.moves.length > 0) {
1757
+ add(
1758
+ [...base, "verbs", verb, "moves"],
1759
+ "requiresSettlement cannot move money",
1760
+ );
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
+ function moneyRefWriters(noun: UdlNoun): ReadonlyMap<string, string[]> {
1766
+ const writers = new Map<string, string[]>();
1767
+ for (const [verbName, verb] of Object.entries(noun.verbs)) {
1768
+ const refs = [
1769
+ ...(verb.signedSum
1770
+ ? [
1771
+ verb.signedSum.amountRef,
1772
+ ...verb.signedSum.sources.map((source) => source.subtotalRef),
1773
+ ]
1774
+ : []),
1775
+ ...[...verb.steps, ...verb.moves].flatMap((step) =>
1776
+ Object.entries(step.capture ?? {}).flatMap(([ref, output]) =>
1777
+ output === "postedAmount" ? [ref] : [],
1778
+ ),
1779
+ ),
1780
+ ];
1781
+ for (const ref of refs) {
1782
+ writers.set(ref, [...(writers.get(ref) ?? []), verbName]);
1783
+ }
1784
+ }
1785
+ return writers;
1786
+ }
1787
+
1324
1788
  /**
1325
1789
  * Shape rules for one resolved reference gate: derived bindings and the
1326
1790
  * uniqueness claim are create-only, bind keys must be declared immutable
@@ -1415,11 +1879,28 @@ function validateGateShape(
1415
1879
  /** Every `refs.<key>` a noun's instances can legitimately carry. */
1416
1880
  function declaredRefKeys(noun: UdlNoun): ReadonlySet<string> {
1417
1881
  return new Set([
1882
+ ...Object.values(noun.verbs).flatMap((verb) =>
1883
+ verb.payout ? [verb.payout.capture] : [],
1884
+ ),
1885
+ ...Object.values(noun.verbs).flatMap((verb) =>
1886
+ verb.requiresSettlement ? [verb.requiresSettlement.capture] : [],
1887
+ ),
1888
+ ...Object.values(noun.verbs).flatMap((verb) =>
1889
+ Object.keys(verb.captureInput ?? {}),
1890
+ ),
1418
1891
  ...Object.values(noun.verbs).flatMap((verb) =>
1419
1892
  [...verb.steps, ...verb.moves].flatMap((step) =>
1420
1893
  Object.keys(step.capture ?? {}),
1421
1894
  ),
1422
1895
  ),
1896
+ ...Object.values(noun.verbs).flatMap((verb) =>
1897
+ verb.signedSum
1898
+ ? [
1899
+ verb.signedSum.amountRef,
1900
+ ...verb.signedSum.sources.map((source) => source.subtotalRef),
1901
+ ]
1902
+ : [],
1903
+ ),
1423
1904
  ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1424
1905
  ...(noun.subject ? ["subject"] : []),
1425
1906
  ]);
@@ -1837,6 +2318,10 @@ function isMoneySchema(schema: Readonly<Record<string, unknown>>): boolean {
1837
2318
  );
1838
2319
  }
1839
2320
 
2321
+ function isCurrencySchema(schema: Readonly<Record<string, unknown>>): boolean {
2322
+ return schema.pattern === currencyPattern;
2323
+ }
2324
+
1840
2325
  /**
1841
2326
  * One budget's worth of reference-shape classification: the memo of the answers
1842
2327
  * already bought and what is left of the probe budget. What is guaranteed is