@hyperscale0/udl 1.0.0-beta.1 → 1.0.0

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.
Files changed (179) hide show
  1. package/CHANGELOG.md +78 -25
  2. package/README.md +37 -99
  3. package/conformance/README.md +44 -41
  4. package/conformance/evolution/action-contract.expected.json +10 -0
  5. package/conformance/evolution/action-contract.live.udl +44 -0
  6. package/conformance/evolution/action-contract.next.udl +45 -0
  7. package/conformance/evolution/product-identity.expected.json +10 -0
  8. package/conformance/evolution/product-identity.live.udl +55 -0
  9. package/conformance/evolution/product-identity.next.udl +55 -0
  10. package/conformance/evolution/version-required.expected.json +10 -0
  11. package/conformance/evolution/version-required.live.udl +55 -0
  12. package/conformance/evolution/version-required.next.udl +58 -0
  13. package/conformance/invalid/action-without-transition.expected.json +10 -0
  14. package/conformance/invalid/{verb-without-transition.udl → action-without-transition.udl} +7 -2
  15. package/conformance/invalid/agent-description-too-long.expected.json +10 -0
  16. package/conformance/invalid/agent-description-too-long.udl +57 -0
  17. package/conformance/invalid/blank-title.expected.json +1 -1
  18. package/conformance/invalid/blank-title.udl +6 -2
  19. package/conformance/invalid/composition-dial-duplicate-key.expected.json +10 -0
  20. package/conformance/invalid/composition-dial-duplicate-key.udl +73 -0
  21. package/conformance/invalid/depth-budget.expected.json +1 -1
  22. package/conformance/invalid/duplicate-subject.expected.json +10 -0
  23. package/conformance/invalid/duplicate-subject.udl +230 -0
  24. package/conformance/invalid/forged-effects.expected.json +10 -0
  25. package/conformance/invalid/forged-effects.udl +76 -0
  26. package/conformance/invalid/format-version.expected.json +1 -1
  27. package/conformance/invalid/format-version.udl +6 -2
  28. package/conformance/invalid/instrument-id-not-snake-case.expected.json +10 -0
  29. package/conformance/invalid/{noun-id-not-snake-case.udl → instrument-id-not-snake-case.udl} +6 -2
  30. package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +10 -0
  31. package/conformance/invalid/invalid-aggregate-gate-shape.udl +1819 -0
  32. package/conformance/invalid/invalid-check-duration.expected.json +10 -0
  33. package/conformance/invalid/invalid-check-duration.udl +219 -0
  34. package/conformance/invalid/invalid-dial-anchor.expected.json +10 -0
  35. package/conformance/invalid/invalid-dial-anchor.udl +219 -0
  36. package/conformance/invalid/invalid-exception-parent-ref.expected.json +10 -0
  37. package/conformance/invalid/invalid-exception-parent-ref.udl +2487 -0
  38. package/conformance/invalid/invalid-exposure-shape.expected.json +10 -0
  39. package/conformance/invalid/invalid-exposure-shape.udl +1819 -0
  40. package/conformance/invalid/invalid-remainder.expected.json +10 -0
  41. package/conformance/invalid/invalid-remainder.udl +220 -0
  42. package/conformance/invalid/invalid-schema-keyword.expected.json +10 -0
  43. package/conformance/invalid/invalid-schema-keyword.udl +220 -0
  44. package/conformance/invalid/invalid-utf8.expected.json +1 -1
  45. package/conformance/invalid/malformed-json.expected.json +1 -1
  46. package/conformance/invalid/missing-create-action.expected.json +10 -0
  47. package/conformance/invalid/{missing-create-verb.udl → missing-create-action.udl} +5 -2
  48. package/conformance/invalid/missing-exception-amount-field.expected.json +10 -0
  49. package/conformance/invalid/missing-exception-amount-field.udl +2487 -0
  50. package/conformance/invalid/missing-exception-contract.expected.json +14 -0
  51. package/conformance/invalid/missing-exception-contract.udl +2523 -0
  52. package/conformance/invalid/missing-exception-reason-field.expected.json +10 -0
  53. package/conformance/invalid/missing-exception-reason-field.udl +2487 -0
  54. package/conformance/invalid/not-an-object.expected.json +1 -1
  55. package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +10 -0
  56. package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +259 -0
  57. package/conformance/invalid/quote-freeze-set-incomplete.expected.json +10 -0
  58. package/conformance/invalid/quote-freeze-set-incomplete.udl +261 -0
  59. package/conformance/invalid/quote-named-reference-gate.expected.json +10 -0
  60. package/conformance/invalid/quote-named-reference-gate.udl +50 -0
  61. package/conformance/invalid/reconcile-named-reference-gate.expected.json +10 -0
  62. package/conformance/invalid/reconcile-named-reference-gate.udl +50 -0
  63. package/conformance/invalid/unknown-key.expected.json +1 -1
  64. package/conformance/invalid/unknown-key.udl +6 -2
  65. package/conformance/invalid/unknown-reference-gate-field.expected.json +10 -0
  66. package/conformance/invalid/unknown-reference-gate-field.udl +1819 -0
  67. package/conformance/invalid/unknown-required-field.expected.json +10 -0
  68. package/conformance/invalid/unknown-required-field.udl +220 -0
  69. package/conformance/invalid/unreachable-state.expected.json +2 -2
  70. package/conformance/invalid/unreachable-state.udl +6 -2
  71. package/conformance/invalid/wrong-exception-amount-field.expected.json +10 -0
  72. package/conformance/invalid/wrong-exception-amount-field.udl +2487 -0
  73. package/conformance/invalid/wrong-exception-reason-field.expected.json +10 -0
  74. package/conformance/invalid/wrong-exception-reason-field.udl +2487 -0
  75. package/conformance/valid/agent-description.expected.json +6 -0
  76. package/conformance/valid/agent-description.udl +66 -0
  77. package/conformance/valid/cards.expected.json +1 -0
  78. package/conformance/valid/cards.udl +843 -356
  79. package/conformance/valid/commerce-escrow.expected.json +1 -0
  80. package/conformance/valid/commerce-escrow.udl +792 -291
  81. package/conformance/valid/compiled-crowdfunding.expected.json +6 -0
  82. package/conformance/valid/compiled-crowdfunding.udl +1843 -0
  83. package/conformance/valid/compiled-watch-club.expected.json +6 -0
  84. package/conformance/valid/compiled-watch-club.udl +2486 -0
  85. package/conformance/valid/complete-contract.expected.json +6 -0
  86. package/conformance/valid/complete-contract.udl +218 -0
  87. package/conformance/valid/effect-signatures.expected.json +6 -0
  88. package/conformance/valid/effect-signatures.udl +75 -0
  89. package/conformance/valid/hand-edited.expected.json +1 -0
  90. package/conformance/valid/hand-edited.udl +1 -1
  91. package/conformance/valid/insured-car-marketplace.expected.json +1 -0
  92. package/conformance/valid/insured-car-marketplace.udl +534 -315
  93. package/conformance/valid/insured-travel.expected.json +2 -1
  94. package/conformance/valid/insured-travel.udl +1489 -641
  95. package/conformance/valid/minimal.expected.json +1 -0
  96. package/conformance/valid/minimal.udl +18 -14
  97. package/conformance/valid/protection.expected.json +1 -0
  98. package/conformance/valid/protection.udl +719 -304
  99. package/conformance/valid/string-escaping.expected.json +6 -0
  100. package/conformance/valid/string-escaping.udl +54 -0
  101. package/dist/canonical.d.ts +2 -0
  102. package/dist/canonical.d.ts.map +1 -1
  103. package/dist/canonical.js +7 -0
  104. package/dist/canonical.js.map +1 -1
  105. package/dist/check-profiles.d.ts +57 -0
  106. package/dist/check-profiles.d.ts.map +1 -0
  107. package/dist/check-profiles.js +62 -0
  108. package/dist/check-profiles.js.map +1 -0
  109. package/dist/cli.js +48 -4
  110. package/dist/cli.js.map +1 -1
  111. package/dist/diagnostics.d.ts +168 -0
  112. package/dist/diagnostics.d.ts.map +1 -0
  113. package/dist/diagnostics.js +164 -0
  114. package/dist/diagnostics.js.map +1 -0
  115. package/dist/effects.d.ts +41 -0
  116. package/dist/effects.d.ts.map +1 -0
  117. package/dist/effects.js +110 -0
  118. package/dist/effects.js.map +1 -0
  119. package/dist/evolution.d.ts +51 -18
  120. package/dist/evolution.d.ts.map +1 -1
  121. package/dist/evolution.js +244 -102
  122. package/dist/evolution.js.map +1 -1
  123. package/dist/finance.d.ts +17 -13
  124. package/dist/finance.d.ts.map +1 -1
  125. package/dist/finance.js +165 -109
  126. package/dist/finance.js.map +1 -1
  127. package/dist/index.d.ts +12 -8
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +8 -5
  130. package/dist/index.js.map +1 -1
  131. package/dist/limits.d.ts +2 -2
  132. package/dist/limits.d.ts.map +1 -1
  133. package/dist/limits.js +4 -3
  134. package/dist/limits.js.map +1 -1
  135. package/dist/parser.d.ts.map +1 -1
  136. package/dist/parser.js +6 -16
  137. package/dist/parser.js.map +1 -1
  138. package/dist/schema.d.ts +2059 -135
  139. package/dist/schema.d.ts.map +1 -1
  140. package/dist/schema.js +759 -73
  141. package/dist/schema.js.map +1 -1
  142. package/dist/validation.d.ts +22 -9
  143. package/dist/validation.d.ts.map +1 -1
  144. package/dist/validation.js +1463 -457
  145. package/dist/validation.js.map +1 -1
  146. package/docs/README.md +14 -0
  147. package/docs/guide/01-a-document.md +37 -0
  148. package/docs/guide/02-money-steps.md +23 -0
  149. package/docs/guide/03-laws.md +18 -0
  150. package/docs/guide/04-fees-and-remainder.md +15 -0
  151. package/docs/guide/05-checks-updates-dials.md +7 -0
  152. package/docs/guide/06-effects.md +11 -0
  153. package/docs/guide/07-evolution.md +11 -0
  154. package/docs/guide/08-implementing.md +13 -0
  155. package/docs/llms-full.txt +1241 -0
  156. package/docs/llms.txt +14 -0
  157. package/docs/reference/canonical.md +16 -0
  158. package/docs/reference/clauses.md +986 -0
  159. package/docs/reference/cli.md +22 -0
  160. package/docs/reference/diagnostics.md +32 -0
  161. package/package.json +6 -2
  162. package/skills/udl/SKILL.md +28 -0
  163. package/spec/README.md +82 -51
  164. package/spec/udl.schema.json +1191 -119
  165. package/src/canonical.ts +11 -0
  166. package/src/check-profiles.ts +80 -0
  167. package/src/cli.ts +49 -3
  168. package/src/diagnostics.ts +221 -0
  169. package/src/effects.ts +163 -0
  170. package/src/evolution.ts +375 -125
  171. package/src/finance.ts +255 -160
  172. package/src/index.ts +50 -14
  173. package/src/limits.ts +4 -3
  174. package/src/parser.ts +15 -18
  175. package/src/schema.ts +855 -81
  176. package/src/validation.ts +2666 -560
  177. package/conformance/invalid/missing-create-verb.expected.json +0 -10
  178. package/conformance/invalid/noun-id-not-snake-case.expected.json +0 -10
  179. package/conformance/invalid/verb-without-transition.expected.json +0 -10
@@ -1,9 +1,11 @@
1
1
  import { Validator, } from "@cfworker/json-schema";
2
2
  import { z } from "zod";
3
- import { udlDocumentSchema, } from "./schema.js";
3
+ import { udlClauseVocabulary, quoteExpiresAtRefKey, quoteFrozenRefKey, quoteSeededRefKeys, udlDocumentSchema, } from "./schema.js";
4
4
  import { fixedIsoDurationMs } from "./duration.js";
5
- import { analyzeNounFinance, financeAdmissionProblem } from "./finance.js";
5
+ import { analyzeInstrumentFinance, financeAdmissionProblem, } from "./finance.js";
6
6
  import { UDL_LIMITS } from "./limits.js";
7
+ import { deriveUdlActionEffects, udlEffectKinds } from "./effects.js";
8
+ import { issue } from "./diagnostics.js";
7
9
  export class UdlError extends Error {
8
10
  issues;
9
11
  constructor(issues) {
@@ -19,14 +21,13 @@ export function validateUdl(value) {
19
21
  const resourceIssue = structuralBudgetIssue(value);
20
22
  if (resourceIssue)
21
23
  return { issues: [resourceIssue], ok: false };
24
+ const reconcileShapeIssues = reconcileExceptionRequiredFieldIssues(value);
25
+ if (reconcileShapeIssues.length > 0)
26
+ return { issues: reconcileShapeIssues, ok: false };
22
27
  const parsed = udlDocumentSchema.safeParse(value);
23
28
  if (!parsed.success) {
24
29
  return {
25
- issues: parsed.error.issues.map((issue) => ({
26
- code: "invalid_shape",
27
- message: issue.message,
28
- path: jsonPath(issue.path),
29
- })),
30
+ issues: parsed.error.issues.map((entry) => issue(shapeIssueCode(entry.path), jsonPath(entry.path), entry.message)),
30
31
  ok: false,
31
32
  };
32
33
  }
@@ -35,11 +36,7 @@ export function validateUdl(value) {
35
36
  if (references.exhausted) {
36
37
  return {
37
38
  issues: [
38
- {
39
- code: "resource_limit",
40
- message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns, reference gates, or payout intents`,
41
- path: "$.nouns",
42
- },
39
+ issue("UDL1004", "$.instruments", `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer instruments, reference gates, or payout intents`),
43
40
  ],
44
41
  ok: false,
45
42
  };
@@ -48,6 +45,48 @@ export function validateUdl(value) {
48
45
  ? { ok: true, value: parsed.data }
49
46
  : { issues, ok: false };
50
47
  }
48
+ function shapeIssueCode(path) {
49
+ if (path.includes("requiresExposure"))
50
+ return "UDL5003";
51
+ if (path.includes("requiresAggregate"))
52
+ return "UDL5004";
53
+ return "UDL1003";
54
+ }
55
+ function reconcileExceptionRequiredFieldIssues(value) {
56
+ const issues = [];
57
+ const instruments = recordValue(value).instruments;
58
+ if (!Array.isArray(instruments))
59
+ return issues;
60
+ instruments.forEach((instrument, instrumentIndex) => {
61
+ const actions = recordValue(instrument).actions;
62
+ if (!actions || typeof actions !== "object" || Array.isArray(actions))
63
+ return;
64
+ for (const [action, definition] of Object.entries(actions)) {
65
+ const reconciles = recordValue(definition).reconcile;
66
+ if (!Array.isArray(reconciles))
67
+ continue;
68
+ reconciles.forEach((reconcile, reconcileIndex) => {
69
+ const exception = recordValue(recordValue(reconcile).exception);
70
+ const base = jsonPath([
71
+ "instruments",
72
+ instrumentIndex,
73
+ "actions",
74
+ action,
75
+ "reconcile",
76
+ reconcileIndex,
77
+ "exception",
78
+ ]);
79
+ if (!("amountField" in exception)) {
80
+ issues.push(issue("UDL5009", `${base}.amountField`));
81
+ }
82
+ if (!("reasonField" in exception)) {
83
+ issues.push(issue("UDL5011", `${base}.reasonField`));
84
+ }
85
+ });
86
+ }
87
+ });
88
+ return issues;
89
+ }
51
90
  export function assertValidUdl(value) {
52
91
  const result = validateUdl(value);
53
92
  if (!result.ok)
@@ -95,17 +134,11 @@ export function validateUdlJsonSchema(schema) {
95
134
  if (admissionIssue)
96
135
  return [admissionIssue];
97
136
  if (!isRecord(schema)) {
98
- return [
99
- {
100
- code: "invalid_shape",
101
- message: "JSON Schema must be an object",
102
- path: "$",
103
- },
104
- ];
137
+ return [issue("UDL1003", "$", "JSON Schema must be an object")];
105
138
  }
106
139
  const issues = [];
107
140
  validateJsonSchema(schema, [], (path, message) => {
108
- issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
141
+ issues.push(issue("UDL6001", jsonPath(path), message));
109
142
  });
110
143
  return issues;
111
144
  }
@@ -115,7 +148,9 @@ function admissionValidationResult(issue, invalidKeyword) {
115
148
  {
116
149
  error: issue.message,
117
150
  instanceLocation: issue.path,
118
- keyword: issue.code === "resource_limit" ? "resource_limit" : invalidKeyword,
151
+ keyword: issue.category === "resource_limit"
152
+ ? "resource_limit"
153
+ : invalidKeyword,
119
154
  keywordLocation: "$",
120
155
  },
121
156
  ],
@@ -162,13 +197,13 @@ const jsonSchemaKeywords = new Set([
162
197
  ]);
163
198
  function semanticIssues(document, references) {
164
199
  const issues = [];
165
- const add = (path, message) => {
166
- issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
200
+ const add = (path, message, code) => {
201
+ issues.push(issue(code, jsonPath(path), message));
167
202
  };
168
- document.nouns.forEach((noun, nounIndex) => {
169
- const problem = financeAdmissionProblem(noun);
203
+ document.instruments.forEach((instrument, instrumentIndex) => {
204
+ const problem = financeAdmissionProblem(instrument);
170
205
  if (problem)
171
- add(["nouns", nounIndex, "lifecycle"], problem);
206
+ add(["instruments", instrumentIndex, "lifecycle"], problem, "UDL1004");
172
207
  });
173
208
  if (issues.length > 0)
174
209
  return issues;
@@ -176,17 +211,47 @@ function semanticIssues(document, references) {
176
211
  if (issues.length > 0)
177
212
  return issues;
178
213
  addDuplicateIssues(document.subjects.map((subject) => subject.kind), ["subjects"], "subject kind", add);
179
- addDuplicateIssues(document.nouns.map((noun) => noun.id), ["nouns"], "noun id", add);
180
- addDuplicateIssues(document.nouns.map((noun) => noun.idPrefix), ["nouns"], "noun idPrefix", add);
214
+ addDuplicateIssues(document.instruments.map((instrument) => instrument.id), ["instruments"], "instrument id", add);
215
+ addDuplicateIssues(document.instruments.map((instrument) => instrument.idPrefix), ["instruments"], "instrument idPrefix", add);
216
+ validateCompositionDials(document, add);
181
217
  const subjects = new Map(document.subjects.map((subject) => [subject.kind, subject]));
182
- const nouns = new Map(document.nouns.map((noun) => [noun.id, noun]));
218
+ const instruments = new Map(document.instruments.map((instrument) => [instrument.id, instrument]));
183
219
  for (const [subjectIndex, subject] of document.subjects.entries()) {
184
220
  if (subject.schema.type !== "object") {
185
- add(["subjects", subjectIndex, "schema", "type"], "a subject attribute schema must declare type object");
221
+ add(["subjects", subjectIndex, "schema", "type"], "a subject attribute schema must declare type object", "UDL2002");
186
222
  }
187
223
  }
188
- for (const [nounIndex, noun] of document.nouns.entries()) {
189
- validateNoun(noun, nounIndex, nouns, subjects, references, add);
224
+ for (const [instrumentIndex, instrument] of document.instruments.entries()) {
225
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
226
+ if (!action.effects)
227
+ continue;
228
+ const expected = deriveUdlActionEffects(action, udlClauseVocabulary);
229
+ for (const kind of udlEffectKinds) {
230
+ const actualRows = action.effects[kind] ?? [];
231
+ const expectedRows = expected[kind] ?? [];
232
+ if (actualRows.length === expectedRows.length &&
233
+ actualRows.every((row, index) => {
234
+ const expectedRow = expectedRows[index];
235
+ return (expectedRow !== undefined &&
236
+ row.signature === expectedRow.signature &&
237
+ row.source === expectedRow.source &&
238
+ ("channel" in row ? row.channel : undefined) ===
239
+ expectedRow.channel &&
240
+ ("role" in row ? row.role : undefined) === expectedRow.role);
241
+ })) {
242
+ continue;
243
+ }
244
+ add([
245
+ "instruments",
246
+ instrumentIndex,
247
+ "actions",
248
+ actionName,
249
+ "effects",
250
+ kind,
251
+ ], `derived ${kind} effects do not match the action clauses`, "UDL2005");
252
+ }
253
+ }
254
+ validateInstrument(instrument, instrumentIndex, instruments, subjects, references, add);
190
255
  }
191
256
  return issues;
192
257
  }
@@ -194,10 +259,11 @@ function structuralBudgetIssue(value) {
194
259
  let discovered = 1;
195
260
  let nodes = 0;
196
261
  let totalStringLength = 0;
197
- const pending = [{ ancestors: new Set(), depth: 1, path: [], value }];
262
+ const pending = [{ ancestors: new Set(), countNode: true, depth: 1, path: [], value }];
198
263
  while (pending.length > 0) {
199
264
  const entry = pending.pop();
200
- nodes += 1;
265
+ if (entry.countNode)
266
+ nodes += 1;
201
267
  if (nodes > UDL_LIMITS.maxNodes) {
202
268
  return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
203
269
  }
@@ -230,13 +296,16 @@ function structuralBudgetIssue(value) {
230
296
  const childAncestors = new Set(entry.ancestors);
231
297
  childAncestors.add(entry.value);
232
298
  if (Array.isArray(entry.value)) {
233
- if (entry.value.length > UDL_LIMITS.maxNodes - discovered) {
299
+ if (entry.countNode &&
300
+ entry.value.length > UDL_LIMITS.maxNodes - discovered) {
234
301
  return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
235
302
  }
236
- discovered += entry.value.length;
303
+ if (entry.countNode)
304
+ discovered += entry.value.length;
237
305
  for (let index = entry.value.length - 1; index >= 0; index -= 1) {
238
306
  pending.push({
239
307
  ancestors: childAncestors,
308
+ countNode: entry.countNode,
240
309
  depth: entry.depth + 1,
241
310
  path: [...entry.path, index],
242
311
  value: entry.value[index],
@@ -252,7 +321,8 @@ function structuralBudgetIssue(value) {
252
321
  for (const key in entry.value) {
253
322
  if (!Object.hasOwn(entry.value, key))
254
323
  continue;
255
- childCount += 1;
324
+ if (countsAsAuthoredNode(entry, key))
325
+ childCount += 1;
256
326
  if (childCount > UDL_LIMITS.maxNodes - discovered) {
257
327
  return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
258
328
  }
@@ -270,6 +340,7 @@ function structuralBudgetIssue(value) {
270
340
  continue;
271
341
  pending.push({
272
342
  ancestors: childAncestors,
343
+ countNode: countsAsAuthoredNode(entry, key),
273
344
  depth: entry.depth + 1,
274
345
  path: [...entry.path, key],
275
346
  value: entry.value[key],
@@ -278,28 +349,34 @@ function structuralBudgetIssue(value) {
278
349
  }
279
350
  return undefined;
280
351
  }
352
+ function countsAsAuthoredNode(entry, key) {
353
+ return !(!entry.countNode ||
354
+ (key === "effects" &&
355
+ entry.path.length === 4 &&
356
+ entry.path[0] === "instruments" &&
357
+ typeof entry.path[1] === "number" &&
358
+ entry.path[2] === "actions" &&
359
+ typeof entry.path[3] === "string"));
360
+ }
281
361
  function resourceIssue(path, message) {
282
- return { code: "resource_limit", message, path: jsonPath(path) };
362
+ return issue("UDL1004", jsonPath(path), message);
283
363
  }
284
364
  function jsonValueIssue(path) {
285
- return {
286
- code: "invalid_shape",
287
- message: "UDL must contain only JSON values",
288
- path: jsonPath(path),
289
- };
365
+ return issue("UDL1003", jsonPath(path), "UDL must contain only JSON values");
290
366
  }
291
367
  function isRecord(value) {
292
368
  return value !== null && typeof value === "object" && !Array.isArray(value);
293
369
  }
294
370
  function validateDocumentSchemas(document, add) {
295
- document.subjects.forEach((subject, subjectIndex) => validateJsonSchema(subject.schema, ["subjects", subjectIndex, "schema"], add));
296
- document.nouns.forEach((noun, nounIndex) => {
297
- for (const [field, schema] of Object.entries(noun.fields)) {
298
- validateJsonSchema(schema, ["nouns", nounIndex, "fields", field], add);
371
+ const addSchemaIssue = (path, message) => add(path, message, "UDL6001");
372
+ document.subjects.forEach((subject, subjectIndex) => validateJsonSchema(subject.schema, ["subjects", subjectIndex, "schema"], addSchemaIssue));
373
+ document.instruments.forEach((instrument, instrumentIndex) => {
374
+ for (const [field, schema] of Object.entries(instrument.fields)) {
375
+ validateJsonSchema(schema, ["instruments", instrumentIndex, "fields", field], addSchemaIssue);
299
376
  }
300
- for (const [verb, definition] of Object.entries(noun.verbs)) {
377
+ for (const [action, definition] of Object.entries(instrument.actions)) {
301
378
  if (definition.input) {
302
- validateJsonSchema(definition.input, ["nouns", nounIndex, "verbs", verb, "input"], add);
379
+ validateJsonSchema(definition.input, ["instruments", instrumentIndex, "actions", action, "input"], addSchemaIssue);
303
380
  }
304
381
  }
305
382
  });
@@ -307,51 +384,51 @@ function validateDocumentSchemas(document, add) {
307
384
  function validateJsonSchema(schema, path, add) {
308
385
  for (const keyword of Object.keys(schema)) {
309
386
  if (!jsonSchemaKeywords.has(keyword)) {
310
- add([...path, keyword], `JSON Schema keyword ${keyword} is not in the UDL schema subset`);
387
+ add([...path, keyword], `JSON Schema keyword ${keyword} is not in the UDL schema subset`, "UDL6001");
311
388
  }
312
389
  }
313
390
  if (typeof schema.type !== "string" || !jsonSchemaTypes.has(schema.type)) {
314
- add([...path, "type"], `JSON Schema type must be one of ${[...jsonSchemaTypes].join(", ")}`);
391
+ add([...path, "type"], `JSON Schema type must be one of ${[...jsonSchemaTypes].join(", ")}`, "UDL6001");
315
392
  }
316
393
  for (const keyword of ["description", "title"]) {
317
394
  if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "string") {
318
- add([...path, keyword], `JSON Schema ${keyword} must be a string`);
395
+ add([...path, keyword], `JSON Schema ${keyword} must be a string`, "UDL6001");
319
396
  }
320
397
  }
321
398
  if (Object.hasOwn(schema, "enum") &&
322
399
  (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
323
- add([...path, "enum"], "JSON Schema enum must be a non-empty array");
400
+ add([...path, "enum"], "JSON Schema enum must be a non-empty array", "UDL6001");
324
401
  }
325
402
  if (Object.hasOwn(schema, "properties") &&
326
403
  (!isRecord(schema.properties) || schema.type !== "object")) {
327
- add([...path, "properties"], "JSON Schema properties require an object schema and object value");
404
+ add([...path, "properties"], "JSON Schema properties require an object schema and object value", "UDL6001");
328
405
  }
329
406
  if (Object.hasOwn(schema, "required")) {
330
407
  if (schema.type !== "object") {
331
- add([...path, "required"], "JSON Schema required is only valid on an object schema");
408
+ add([...path, "required"], "JSON Schema required is only valid on an object schema", "UDL6001");
332
409
  }
333
410
  else if (!Array.isArray(schema.required) ||
334
411
  !schema.required.every((field) => typeof field === "string")) {
335
- add([...path, "required"], "JSON Schema required must be an array of property names");
412
+ add([...path, "required"], "JSON Schema required must be an array of property names", "UDL6001");
336
413
  }
337
414
  else {
338
415
  const properties = recordValue(schema.properties);
339
416
  for (const field of schema.required) {
340
417
  if (!Object.hasOwn(properties, field)) {
341
- add([...path, "required"], `JSON Schema required references undeclared property ${field}`);
418
+ add([...path, "required"], `JSON Schema required references undeclared property ${field}`, "UDL6001");
342
419
  }
343
420
  }
344
421
  }
345
422
  }
346
423
  if (Object.hasOwn(schema, "items") &&
347
424
  (!isRecord(schema.items) || schema.type !== "array")) {
348
- add([...path, "items"], "JSON Schema items require an array schema and object value");
425
+ add([...path, "items"], "JSON Schema items require an array schema and object value", "UDL6001");
349
426
  }
350
427
  if (Object.hasOwn(schema, "additionalProperties") &&
351
428
  (schema.type !== "object" ||
352
429
  (typeof schema.additionalProperties !== "boolean" &&
353
430
  !isRecord(schema.additionalProperties)))) {
354
- add([...path, "additionalProperties"], "JSON Schema additionalProperties requires an object schema and a boolean or schema value");
431
+ add([...path, "additionalProperties"], "JSON Schema additionalProperties requires an object schema and a boolean or schema value", "UDL6001");
355
432
  }
356
433
  for (const keyword of [
357
434
  "maxItems",
@@ -367,7 +444,7 @@ function validateJsonSchema(schema, path, add) {
367
444
  typeof value !== "number" ||
368
445
  !Number.isInteger(value) ||
369
446
  value < 0)) {
370
- add([...path, keyword], `JSON Schema ${keyword} must be a non-negative integer on the matching schema type`);
447
+ add([...path, keyword], `JSON Schema ${keyword} must be a non-negative integer on the matching schema type`, "UDL6001");
371
448
  }
372
449
  }
373
450
  for (const keyword of ["maximum", "minimum"]) {
@@ -376,23 +453,23 @@ function validateJsonSchema(schema, path, add) {
376
453
  (schema.type !== "integer" ||
377
454
  typeof value !== "number" ||
378
455
  !Number.isFinite(value))) {
379
- add([...path, keyword], `JSON Schema ${keyword} must be finite on an integer schema`);
456
+ add([...path, keyword], `JSON Schema ${keyword} must be finite on an integer schema`, "UDL6001");
380
457
  }
381
458
  }
382
459
  if (typeof schema.minLength === "number" &&
383
460
  typeof schema.maxLength === "number" &&
384
461
  schema.minLength > schema.maxLength) {
385
- add([...path, "maxLength"], "JSON Schema maxLength is below minLength");
462
+ add([...path, "maxLength"], "JSON Schema maxLength is below minLength", "UDL6001");
386
463
  }
387
464
  if (typeof schema.minItems === "number" &&
388
465
  typeof schema.maxItems === "number" &&
389
466
  schema.minItems > schema.maxItems) {
390
- add([...path, "maxItems"], "JSON Schema maxItems is below minItems");
467
+ add([...path, "maxItems"], "JSON Schema maxItems is below minItems", "UDL6001");
391
468
  }
392
469
  if (typeof schema.minimum === "number" &&
393
470
  typeof schema.maximum === "number" &&
394
471
  schema.minimum > schema.maximum) {
395
- add([...path, "maximum"], "JSON Schema maximum is below minimum");
472
+ add([...path, "maximum"], "JSON Schema maximum is below minimum", "UDL6001");
396
473
  }
397
474
  if (Object.hasOwn(schema, "x-hyperscale-reference-filter")) {
398
475
  const filter = schema["x-hyperscale-reference-filter"];
@@ -402,29 +479,29 @@ function validateJsonSchema(schema, path, add) {
402
479
  typeof filter.column !== "string" ||
403
480
  !Array.isArray(filter.values) ||
404
481
  !filter.values.every((value) => typeof value === "string")) {
405
- add([...path, "x-hyperscale-reference-filter"], "x-hyperscale-reference-filter requires only string column and string-array values");
482
+ add([...path, "x-hyperscale-reference-filter"], "x-hyperscale-reference-filter requires only string column and string-array values", "UDL6001");
406
483
  }
407
484
  }
408
485
  if (Object.hasOwn(schema, "x-hyperscale-fee-collection-port") &&
409
486
  (schema.type !== "string" ||
410
487
  schema["x-hyperscale-fee-collection-port"] !== true)) {
411
- add([...path, "x-hyperscale-fee-collection-port"], "x-hyperscale-fee-collection-port must be true on a string schema");
488
+ add([...path, "x-hyperscale-fee-collection-port"], "x-hyperscale-fee-collection-port must be true on a string schema", "UDL6001");
412
489
  }
413
490
  if (Object.hasOwn(schema, "pattern")) {
414
491
  if (schema.type !== "string" || typeof schema.pattern !== "string") {
415
- add([...path, "pattern"], "JSON Schema pattern must be a string");
492
+ add([...path, "pattern"], "JSON Schema pattern must be a string", "UDL6001");
416
493
  }
417
494
  else {
418
495
  const problem = regexProblem(schema.pattern);
419
496
  if (problem)
420
- add([...path, "pattern"], problem);
497
+ add([...path, "pattern"], problem, "UDL6001");
421
498
  }
422
499
  }
423
500
  if (Object.hasOwn(schema, "format") &&
424
501
  (schema.type !== "string" ||
425
502
  typeof schema.format !== "string" ||
426
503
  !jsonSchemaFormats.has(schema.format))) {
427
- add([...path, "format"], `JSON Schema format must be one of ${[...jsonSchemaFormats].join(", ")}`);
504
+ add([...path, "format"], `JSON Schema format must be one of ${[...jsonSchemaFormats].join(", ")}`, "UDL6001");
428
505
  }
429
506
  const properties = recordValue(schema.properties);
430
507
  for (const [property, child] of Object.entries(properties)) {
@@ -432,7 +509,7 @@ function validateJsonSchema(schema, path, add) {
432
509
  validateJsonSchema(child, [...path, "properties", property], add);
433
510
  }
434
511
  else {
435
- add([...path, "properties", property], "JSON Schema property must be a schema object");
512
+ add([...path, "properties", property], "JSON Schema property must be a schema object", "UDL6001");
436
513
  }
437
514
  }
438
515
  if (isRecord(schema.items)) {
@@ -590,39 +667,52 @@ function isEscapedRegexToken(pattern, index) {
590
667
  }
591
668
  return slashes % 2 === 1;
592
669
  }
593
- function validateNoun(noun, nounIndex, nouns, subjects, references, add) {
594
- const base = ["nouns", nounIndex];
595
- const states = new Set(noun.lifecycle.states);
596
- addDuplicateIssues(noun.lifecycle.states, [...base, "lifecycle", "states"], "lifecycle state", add);
597
- if (!states.has(noun.lifecycle.initial)) {
598
- add([...base, "lifecycle", "initial"], `initial state ${noun.lifecycle.initial} is not declared in lifecycle.states`);
670
+ function validateInstrument(instrument, instrumentIndex, instruments, subjects, references, add) {
671
+ const base = ["instruments", instrumentIndex];
672
+ const states = new Set(instrument.lifecycle.states);
673
+ const orderedActions = new Set(instrument.actionOrder);
674
+ addDuplicateIssues(instrument.actionOrder, [...base, "actionOrder"], "action id", add);
675
+ instrument.actionOrder.forEach((action, actionIndex) => {
676
+ if (!Object.hasOwn(instrument.actions, action)) {
677
+ add([...base, "actionOrder", actionIndex], `action order references unknown action ${action}`, "UDL3001");
678
+ }
679
+ });
680
+ for (const action of Object.keys(instrument.actions)) {
681
+ if (!orderedActions.has(action)) {
682
+ add([...base, "actions", action], `action ${action} is missing from actionOrder`, "UDL3001");
683
+ }
684
+ }
685
+ addDuplicateIssues(instrument.lifecycle.states, [...base, "lifecycle", "states"], "lifecycle state", add);
686
+ if (!states.has(instrument.lifecycle.initial)) {
687
+ add([...base, "lifecycle", "initial"], `initial state ${instrument.lifecycle.initial} is not declared in lifecycle.states`, "UDL3001");
599
688
  }
600
- if (!Object.hasOwn(noun.verbs, "create")) {
601
- add([...base, "verbs"], "every noun must declare the create verb");
689
+ if (!Object.hasOwn(instrument.actions, "create")) {
690
+ add([...base, "actions"], "every instrument must declare the create action", "UDL3001");
602
691
  }
603
- if (Object.hasOwn(noun.lifecycle.transitions, "create")) {
604
- add([...base, "lifecycle", "transitions", "create"], "create lands on lifecycle.initial and must not declare a transition");
692
+ if (Object.hasOwn(instrument.lifecycle.transitions, "create")) {
693
+ add([...base, "lifecycle", "transitions", "create"], "create lands on lifecycle.initial and must not declare a transition", "UDL3001");
605
694
  }
606
- for (const [verb, transition] of Object.entries(noun.lifecycle.transitions)) {
607
- if (!Object.hasOwn(noun.verbs, verb)) {
608
- add([...base, "lifecycle", "transitions", verb], `transition ${verb} has no matching verb`);
695
+ for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
696
+ if (!Object.hasOwn(instrument.actions, action)) {
697
+ add([...base, "lifecycle", "transitions", action], `transition ${action} has no matching action`, "UDL3001");
609
698
  }
610
- addDuplicateIssues(transition.from, [...base, "lifecycle", "transitions", verb, "from"], "source state", add);
699
+ addDuplicateIssues(transition.from, [...base, "lifecycle", "transitions", action, "from"], "source state", add);
611
700
  transition.from.forEach((from, fromIndex) => {
612
701
  if (!states.has(from)) {
613
- add([...base, "lifecycle", "transitions", verb, "from", fromIndex], `source state ${from} is not declared in lifecycle.states`);
702
+ add([...base, "lifecycle", "transitions", action, "from", fromIndex], `source state ${from} is not declared in lifecycle.states`, "UDL3001");
614
703
  }
615
704
  });
616
705
  if (!states.has(transition.to)) {
617
- add([...base, "lifecycle", "transitions", verb, "to"], `target state ${transition.to} is not declared in lifecycle.states`);
706
+ add([...base, "lifecycle", "transitions", action, "to"], `target state ${transition.to} is not declared in lifecycle.states`, "UDL3001");
618
707
  }
619
708
  }
620
- for (const verb of Object.keys(noun.verbs)) {
621
- if (verb !== "create" && !Object.hasOwn(noun.lifecycle.transitions, verb)) {
622
- add([...base, "verbs", verb], `verb ${verb} must declare a lifecycle transition`);
709
+ for (const action of Object.keys(instrument.actions)) {
710
+ if (action !== "create" &&
711
+ !Object.hasOwn(instrument.lifecycle.transitions, action)) {
712
+ add([...base, "actions", action], `action ${action} must declare a lifecycle transition`, "UDL3001");
623
713
  }
624
714
  }
625
- validateReachability(noun, base, add);
715
+ validateReachability(instrument, base, add);
626
716
  const reservedFields = new Set([
627
717
  "createdAt",
628
718
  "id",
@@ -630,114 +720,238 @@ function validateNoun(noun, nounIndex, nouns, subjects, references, add) {
630
720
  "refs",
631
721
  "status",
632
722
  ]);
633
- for (const field of Object.keys(noun.fields)) {
723
+ for (const field of Object.keys(instrument.fields)) {
634
724
  if (reservedFields.has(field)) {
635
- add([...base, "fields", field], `${field} is an envelope field and cannot be authored`);
725
+ add([...base, "fields", field], `${field} is an envelope field and cannot be authored`, "UDL2002");
636
726
  }
637
727
  }
638
- addDuplicateIssues(noun.required, [...base, "required"], "required field", add);
639
- noun.required.forEach((field, fieldIndex) => {
640
- if (!Object.hasOwn(noun.fields, field)) {
641
- add([...base, "required", fieldIndex], `required references unknown field ${field}`);
728
+ addDuplicateIssues(instrument.required, [...base, "required"], "required field", add);
729
+ instrument.required.forEach((field, fieldIndex) => {
730
+ if (!Object.hasOwn(instrument.fields, field)) {
731
+ add([...base, "required", fieldIndex], `required references unknown field ${field}`, "UDL2002");
642
732
  }
643
733
  });
644
- if (noun.subject) {
645
- addDuplicateIssues(noun.subject.kinds, [...base, "subject", "kinds"], "subject kind", add);
646
- noun.subject.kinds.forEach((kind, kindIndex) => {
734
+ if (instrument.subject) {
735
+ addDuplicateIssues(instrument.subject.kinds, [...base, "subject", "kinds"], "subject kind", add);
736
+ instrument.subject.kinds.forEach((kind, kindIndex) => {
647
737
  if (!subjects.has(kind)) {
648
- add([...base, "subject", "kinds", kindIndex], `noun subject references unknown kind ${kind}`);
738
+ add([...base, "subject", "kinds", kindIndex], `instrument subject references unknown kind ${kind}`, "UDL2002");
649
739
  }
650
740
  });
651
741
  }
652
- validateParties(noun, base, references, add);
653
- validateUpdate(noun, base, states, add);
654
- validateSetsAt(noun, base, add);
655
- validateVerbs(noun, base, nouns, references, add);
656
- validateUnwind(noun, base, references, add);
657
- for (const issue of analyzeNounFinance(noun)) {
658
- add([...base, ...issue.path], issue.message);
659
- }
660
- validateAggregates(noun, base, nouns, references, add);
742
+ validateParties(instrument, base, references, add);
743
+ validateUpdate(instrument, base, states, add);
744
+ validateDials(instrument, base, add);
745
+ validateCallerParkedStates(instrument, base, states, add);
746
+ validateFeeRules(instrument, base, references, add);
747
+ validateSetsAt(instrument, base, add);
748
+ validateActions(instrument, base, instruments, references, add);
749
+ validateQuoteCommit(instrument, base, references, add);
750
+ for (const issue of analyzeInstrumentFinance(instrument)) {
751
+ add([...base, ...issue.path], issue.message, issue.code);
752
+ }
753
+ validateAggregates(instrument, base, instruments, references, add);
754
+ }
755
+ function validateFeeRules(instrument, base, references, add) {
756
+ const feeRules = instrument.feeRules ?? [];
757
+ if (feeRules.length === 0)
758
+ return;
759
+ if (Object.keys(instrument.fields).length > 24) {
760
+ add([...base, "fields"], "fee-bearing instruments must not declare more than 24 fields", "UDL4001");
761
+ }
762
+ addDuplicateIssues(feeRules.map((fee) => fee.amountField), [...base, "feeRules"], "fee amount field", add);
763
+ const currencyFields = Object.entries(instrument.fields)
764
+ .filter(([, schema]) => isCurrencySchema(schema))
765
+ .map(([field]) => field);
766
+ const instrumentCurrency = currencyFields.length === 1 ? currencyFields[0] : undefined;
767
+ const mutableFields = new Set(instrument.update?.fields ?? []);
768
+ const partitions = instrument.partitions ?? [];
769
+ feeRules.forEach((fee, feeIndex) => {
770
+ const feeBase = [...base, "feeRules", feeIndex];
771
+ const amountSchema = instrument.fields[fee.amountField];
772
+ const baseSchema = instrument.fields[fee.baseField];
773
+ if (!amountSchema || !isMoneySchema(amountSchema)) {
774
+ add([...feeBase, "amountField"], `fee amount ${fee.amountField} must be a declared money field`, "UDL4001");
775
+ }
776
+ if (!baseSchema || !isMoneySchema(baseSchema)) {
777
+ add([...feeBase, "baseField"], `fee base ${fee.baseField} must be a declared money field`, "UDL4001");
778
+ }
779
+ if (!instrument.required.includes(fee.baseField)) {
780
+ add([...feeBase, "baseField"], `fee base ${fee.baseField} must be required`, "UDL4001");
781
+ }
782
+ if (mutableFields.has(fee.baseField)) {
783
+ add([...feeBase, "baseField"], `fee base ${fee.baseField} cannot be mutable`, "UDL4001");
784
+ }
785
+ const bearerSchema = instrument.fields[fee.bearerField];
786
+ if (!bearerSchema || !references.accepts(bearerSchema, "acct")) {
787
+ add([...feeBase, "bearerField"], `fee bearer ${fee.bearerField} must be a declared account-id field`, "UDL4001");
788
+ }
789
+ else if (!instrument.required.includes(fee.bearerField)) {
790
+ add([...feeBase, "bearerField"], `fee bearer ${fee.bearerField} must be required`, "UDL4001");
791
+ }
792
+ const validateExact = (rule, rulePath, direct) => {
793
+ if (direct && rule.field !== fee.amountField) {
794
+ add([...rulePath, "field"], `exact fee field ${rule.field} must equal amountField ${fee.amountField}`, "UDL4001");
795
+ }
796
+ if (!direct && rule.field === fee.amountField) {
797
+ add([...rulePath, "field"], `tiered fee output ${fee.amountField} must differ from exact source ${rule.field}`, "UDL4001");
798
+ }
799
+ const exactSchema = instrument.fields[rule.field];
800
+ if (!exactSchema || !isMoneySchema(exactSchema)) {
801
+ add([...rulePath, "field"], `exact fee field ${rule.field} must be a declared money field`, "UDL4001");
802
+ }
803
+ if (!instrument.required.includes(rule.field)) {
804
+ add([...rulePath, "field"], `exact fee field ${rule.field} must be required`, "UDL4001");
805
+ }
806
+ if (mutableFields.has(rule.field)) {
807
+ add([...rulePath, "field"], `exact fee field ${rule.field} cannot be mutable`, "UDL4001");
808
+ }
809
+ if (instrumentCurrency === undefined ||
810
+ rule.currencyField !== instrumentCurrency) {
811
+ add([...rulePath, "currencyField"], `exact fee currency ${rule.currencyField} must equal the fee base currency field`, "UDL4001");
812
+ }
813
+ };
814
+ if (fee.rule.kind === "exact") {
815
+ validateExact(fee.rule, [...feeBase, "rule"], true);
816
+ }
817
+ else {
818
+ if (instrument.required.includes(fee.amountField)) {
819
+ add([...feeBase, "amountField"], `computed fee amount ${fee.amountField} must not be required at create`, "UDL4001");
820
+ }
821
+ if (fee.rule.kind === "tiered") {
822
+ const tiers = fee.rule.tiers;
823
+ const openEnded = tiers.filter((tier) => tier.toExclusive === undefined).length;
824
+ if (openEnded !== 1) {
825
+ add([...feeBase, "rule", "tiers"], `tiered fee must declare exactly one open-ended tier; found ${openEnded}`, "UDL4001");
826
+ }
827
+ tiers.forEach((tier, tierIndex) => {
828
+ const tierPath = [...feeBase, "rule", "tiers", tierIndex];
829
+ const from = BigInt(tier.fromInclusive);
830
+ if (tierIndex === 0 && from !== 0n) {
831
+ add([...tierPath, "fromInclusive"], "tiered fee coverage must start at 0", "UDL4001");
832
+ }
833
+ if (tier.toExclusive !== undefined) {
834
+ const to = BigInt(tier.toExclusive);
835
+ if (to <= from) {
836
+ add([...tierPath, "toExclusive"], "tier upper bound must be greater than its lower bound", "UDL4001");
837
+ }
838
+ }
839
+ else if (tierIndex !== tiers.length - 1) {
840
+ add([...tierPath, "toExclusive"], "only the final fee tier may be open-ended", "UDL4001");
841
+ }
842
+ if (tierIndex > 0) {
843
+ const previous = tiers[tierIndex - 1];
844
+ if (previous?.toExclusive === undefined) {
845
+ add([...tierPath, "fromInclusive"], "a fee tier cannot follow an open-ended tier", "UDL4001");
846
+ }
847
+ else {
848
+ const previousEnd = BigInt(previous.toExclusive);
849
+ if (from > previousEnd) {
850
+ add([...tierPath, "fromInclusive"], `fee tiers have a gap before ${tier.fromInclusive}`, "UDL4001");
851
+ }
852
+ else if (from < previousEnd) {
853
+ add([...tierPath, "fromInclusive"], `fee tiers overlap at ${tier.fromInclusive}`, "UDL4001");
854
+ }
855
+ }
856
+ }
857
+ if (tier.rule.kind === "exact") {
858
+ validateExact(tier.rule, [...tierPath, "rule"], false);
859
+ }
860
+ });
861
+ }
862
+ }
863
+ const basePartitions = partitions.filter((partition) => partition.totalField === fee.baseField);
864
+ const direct = basePartitions.some((partition) => partition.pieceFields.includes(fee.amountField));
865
+ const nested = partitions.filter((partition) => partition.totalField === fee.amountField &&
866
+ basePartitions.some((basePartition) => partition.pieceFields.every((piece) => basePartition.pieceFields.includes(piece))));
867
+ const carvedInsideBase = direct || nested.length === 1;
868
+ if (fee.position === "carved" && !carvedInsideBase) {
869
+ add([...feeBase, "position"], `carved fee ${fee.amountField} must form part of a partition of ${fee.baseField}`, "UDL4001");
870
+ }
871
+ if (fee.position === "on_top" && carvedInsideBase) {
872
+ add([...feeBase, "position"], `on-top fee ${fee.amountField} must stay outside the partition of ${fee.baseField}`, "UDL4001");
873
+ }
874
+ });
661
875
  }
662
- function validateSetsAt(noun, base, add) {
876
+ function validateSetsAt(instrument, base, add) {
663
877
  const writers = new Map();
664
878
  const markerFields = new Set();
665
- for (const [verbName, verb] of Object.entries(noun.verbs)) {
666
- if (!verb.setsAt)
879
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
880
+ if (!action.setsAt)
667
881
  continue;
668
- const path = [...base, "verbs", verbName, "setsAt"];
669
- const { field, marker, offset } = verb.setsAt;
882
+ const path = [...base, "actions", actionName, "setsAt"];
883
+ const { field, marker, offset } = action.setsAt;
670
884
  if (marker)
671
885
  markerFields.add(field);
672
- if (verbName === "create") {
673
- add(path, "setsAt requires a lifecycle transition and cannot run on create");
886
+ if (actionName === "create") {
887
+ add(path, "setsAt requires a lifecycle transition and cannot run on create", "UDL3001");
674
888
  }
675
889
  const fieldWriters = writers.get(field) ?? [];
676
890
  if (fieldWriters.length > 0) {
677
- const alternatives = [...fieldWriters, verbName];
678
- const targets = new Set(alternatives.map((writer) => noun.lifecycle.transitions[writer]?.to));
891
+ const alternatives = [...fieldWriters, actionName];
892
+ const targets = new Set(alternatives.map((writer) => instrument.lifecycle.transitions[writer]?.to));
679
893
  const reentrant = alternatives.some((writer) => {
680
- const transition = noun.lifecycle.transitions[writer];
894
+ const transition = instrument.lifecycle.transitions[writer];
681
895
  return transition?.from.includes(transition.to) === true;
682
896
  });
683
897
  if (targets.size !== 1 || targets.has(undefined) || reentrant) {
684
- add([...path, "field"], `${field} has multiple writers without one shared one-way destination`);
898
+ add([...path, "field"], `${field} has multiple writers without one shared one-way destination`, "UDL3001");
685
899
  }
686
900
  }
687
- writers.set(field, [...fieldWriters, verbName]);
688
- const schema = noun.fields[field];
901
+ writers.set(field, [...fieldWriters, actionName]);
902
+ const schema = instrument.fields[field];
689
903
  if (!schema) {
690
- add([...path, "field"], `setsAt references unknown field ${field}`);
904
+ add([...path, "field"], `setsAt references unknown field ${field}`, "UDL3001");
691
905
  }
692
906
  else if (schema.type !== "string" || !isDateTimeFormat(schema.format)) {
693
- add([...path, "field"], "setsAt must target a date-time field");
907
+ add([...path, "field"], "setsAt must target a date-time field", "UDL3001");
694
908
  }
695
- if (noun.required.includes(field)) {
696
- add([...path, "field"], "setsAt target must be optional at create");
909
+ if (instrument.required.includes(field)) {
910
+ add([...path, "field"], "setsAt target must be optional at create", "UDL3001");
697
911
  }
698
- if (noun.update?.fields.includes(field)) {
699
- add([...path, "field"], "setsAt target cannot also be mutable");
912
+ if (instrument.update?.fields.includes(field)) {
913
+ add([...path, "field"], "setsAt target cannot also be mutable", "UDL3001");
700
914
  }
701
915
  const offsetMs = fixedIsoDurationMs(offset);
702
916
  if (offsetMs === null || offsetMs <= 0) {
703
- add([...path, "offset"], "setsAt.offset must be a positive fixed ISO-8601 duration");
917
+ add([...path, "offset"], "setsAt.offset must be a positive fixed ISO-8601 duration", "UDL3001");
704
918
  }
705
- const readers = Object.entries(noun.verbs).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
919
+ const readers = Object.entries(instrument.actions).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
706
920
  if (marker) {
707
921
  if (readers.length > 0) {
708
- add(path, `setsAt marker field ${field} cannot drive a due condition or deadline`);
922
+ add(path, `setsAt marker field ${field} cannot drive a due condition or deadline`, "UDL3001");
709
923
  }
710
924
  continue;
711
925
  }
712
926
  if (readers.length === 0) {
713
- add(path, `setsAt field ${field} must anchor at least one due condition or deadline`);
927
+ add(path, `setsAt field ${field} must anchor at least one due condition or deadline`, "UDL3001");
714
928
  continue;
715
929
  }
716
930
  }
717
931
  for (const [field, fieldWriters] of writers) {
718
932
  if (markerFields.has(field))
719
933
  continue;
720
- const readers = Object.entries(noun.verbs).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
934
+ const readers = Object.entries(instrument.actions).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
721
935
  for (const [readerName] of readers) {
722
- if (!verbGroupDominatesReader(noun, fieldWriters, readerName)) {
723
- add([...base, "verbs", readerName], `verb ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`);
936
+ if (!actionGroupDominatesReader(instrument, fieldWriters, readerName)) {
937
+ add([...base, "actions", readerName], `action ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`, "UDL3001");
724
938
  }
725
939
  }
726
940
  }
727
941
  }
728
- function verbGroupDominatesReader(noun, writers, reader) {
942
+ function actionGroupDominatesReader(instrument, writers, reader) {
729
943
  if (writers.length === 0)
730
944
  return false;
731
- const readerTransition = noun.lifecycle.transitions[reader];
945
+ const readerTransition = instrument.lifecycle.transitions[reader];
732
946
  if (!readerTransition)
733
947
  return false;
734
948
  const removed = new Set(writers);
735
- const reachable = new Set([noun.lifecycle.initial]);
736
- const pending = [noun.lifecycle.initial];
949
+ const reachable = new Set([instrument.lifecycle.initial]);
950
+ const pending = [instrument.lifecycle.initial];
737
951
  while (pending.length > 0) {
738
952
  const state = pending.shift();
739
- for (const [verb, transition] of Object.entries(noun.lifecycle.transitions)) {
740
- if (removed.has(verb) ||
953
+ for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
954
+ if (removed.has(action) ||
741
955
  !transition.from.includes(state) ||
742
956
  reachable.has(transition.to)) {
743
957
  continue;
@@ -748,12 +962,12 @@ function verbGroupDominatesReader(noun, writers, reader) {
748
962
  }
749
963
  return readerTransition.from.every((state) => !reachable.has(state));
750
964
  }
751
- function validateReachability(noun, base, add) {
752
- const reachable = new Set([noun.lifecycle.initial]);
965
+ function validateReachability(instrument, base, add) {
966
+ const reachable = new Set([instrument.lifecycle.initial]);
753
967
  let changed = true;
754
968
  while (changed) {
755
969
  changed = false;
756
- for (const transition of Object.values(noun.lifecycle.transitions)) {
970
+ for (const transition of Object.values(instrument.lifecycle.transitions)) {
757
971
  if (transition.from.some((state) => reachable.has(state)) &&
758
972
  !reachable.has(transition.to)) {
759
973
  reachable.add(transition.to);
@@ -761,113 +975,404 @@ function validateReachability(noun, base, add) {
761
975
  }
762
976
  }
763
977
  }
764
- noun.lifecycle.states.forEach((state, stateIndex) => {
978
+ instrument.lifecycle.states.forEach((state, stateIndex) => {
765
979
  if (!reachable.has(state)) {
766
- add([...base, "lifecycle", "states", stateIndex], `lifecycle state ${state} is unreachable from ${noun.lifecycle.initial}`);
980
+ add([...base, "lifecycle", "states", stateIndex], `lifecycle state ${state} is unreachable from ${instrument.lifecycle.initial}`, "UDL3001");
767
981
  }
768
982
  });
769
983
  }
770
- function validateParties(noun, base, references, add) {
771
- if (!noun.parties)
984
+ function validateParties(instrument, base, references, add) {
985
+ if (!instrument.parties)
772
986
  return;
773
- const entries = Object.entries(noun.parties).filter((entry) => typeof entry[1] === "string");
987
+ const entries = Object.entries(instrument.parties).filter((entry) => typeof entry[1] === "string");
774
988
  if (entries.length === 0) {
775
- add([...base, "parties"], "parties must contain at least one declaration");
989
+ add([...base, "parties"], "parties must contain at least one declaration", "UDL2002");
776
990
  return;
777
991
  }
778
- if (noun.distinctParties && entries.length < 2) {
779
- add([...base, "distinctParties"], "distinctParties requires at least two declared party roles");
992
+ if (instrument.distinctParties && entries.length < 2) {
993
+ add([...base, "distinctParties"], "distinctParties requires at least two declared party roles", "UDL2002");
780
994
  }
781
995
  addDuplicateIssues(entries.map(([, field]) => field), [...base, "parties"], "party field", add);
782
996
  for (const [role, field] of entries) {
783
- const schema = noun.fields[field];
997
+ const schema = instrument.fields[field];
784
998
  if (!schema) {
785
- add([...base, "parties", role], `party role ${role} references unknown field ${field}`);
999
+ add([...base, "parties", role], `party role ${role} references unknown field ${field}`, "UDL2002");
786
1000
  }
787
1001
  else if (!references.accepts(schema, "acct")) {
788
- add([...base, "parties", role], `party role ${role} must reference an account-id field`);
1002
+ add([...base, "parties", role], `party role ${role} must reference an account-id field`, "UDL2002");
789
1003
  }
790
1004
  }
791
1005
  }
792
- function validateUpdate(noun, base, states, add) {
793
- if (!noun.update)
1006
+ function validateUpdate(instrument, base, states, add) {
1007
+ if (!instrument.update)
794
1008
  return;
795
- addDuplicateIssues(noun.update.fields, [...base, "update", "fields"], "field", add);
796
- addDuplicateIssues(noun.update.states, [...base, "update", "states"], "state", add);
797
- noun.update.fields.forEach((field, index) => {
798
- if (!Object.hasOwn(noun.fields, field)) {
799
- add([...base, "update", "fields", index], `update references unknown field ${field}`);
1009
+ addDuplicateIssues(instrument.update.fields, [...base, "update", "fields"], "field", add);
1010
+ addDuplicateIssues(instrument.update.states, [...base, "update", "states"], "state", add);
1011
+ instrument.update.fields.forEach((field, index) => {
1012
+ if (!Object.hasOwn(instrument.fields, field)) {
1013
+ add([...base, "update", "fields", index], `update references unknown field ${field}`, "UDL2002");
800
1014
  }
801
1015
  });
802
- noun.update.states.forEach((state, index) => {
1016
+ instrument.update.states.forEach((state, index) => {
803
1017
  if (!states.has(state)) {
804
- add([...base, "update", "states", index], `update references unknown state ${state}`);
1018
+ add([...base, "update", "states", index], `update references unknown state ${state}`, "UDL2002");
805
1019
  }
806
1020
  });
1021
+ if (instrument.update.examples) {
1022
+ addDuplicateIssues(instrument.update.examples.map((example) => example.name), [...base, "update", "examples"], "example name", add);
1023
+ const schema = {
1024
+ additionalProperties: false,
1025
+ properties: Object.fromEntries(instrument.update.fields.flatMap((field) => {
1026
+ const fieldSchema = instrument.fields[field];
1027
+ return fieldSchema ? [[field, fieldSchema]] : [];
1028
+ })),
1029
+ type: "object",
1030
+ };
1031
+ instrument.update.examples.forEach((example, exampleIndex) => {
1032
+ const input = exampleInputForUdlValidation(instrument, example.input);
1033
+ for (const error of validateUdlSchemaValue(schema, input).errors) {
1034
+ add([...base, "update", "examples", exampleIndex, "input"], `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`, "UDL2002");
1035
+ }
1036
+ });
1037
+ }
807
1038
  }
808
- function validateVerbs(noun, base, nouns, references, add) {
809
- validatePayoutsAndSettlement(noun, base, references, add);
810
- for (const [verb, definition] of Object.entries(noun.verbs)) {
811
- const verbBase = [...base, "verbs", verb];
812
- if (verb === "create" && definition.input) {
813
- add([...verbBase, "input"], "create already consumes noun fields and must not declare input");
1039
+ function validateCallerParkedStates(instrument, base, states, add) {
1040
+ const parked = instrument.callerParkedStates;
1041
+ if (parked === undefined)
1042
+ return;
1043
+ const exitsByState = new Map(instrument.lifecycle.states.map((state) => [state, []]));
1044
+ for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
1045
+ const stationary = instrument.actions[action]?.due?.every !== undefined &&
1046
+ transition.from.includes(transition.to);
1047
+ if (stationary)
1048
+ continue;
1049
+ for (const state of transition.from) {
1050
+ if (state !== transition.to)
1051
+ exitsByState.get(state)?.push(action);
1052
+ }
1053
+ }
1054
+ const isUnconditionallyTimeDriven = (action) => {
1055
+ const due = instrument.actions[action]?.due;
1056
+ if (!due || !isDateTimeFormat(instrument.fields[due.field]?.format)) {
1057
+ return false;
1058
+ }
1059
+ if (instrument.required.includes(due.field))
1060
+ return true;
1061
+ const writers = Object.entries(instrument.actions).flatMap(([writer, definition]) => definition.setsAt?.field === due.field ? [writer] : []);
1062
+ return actionGroupDominatesReader(instrument, writers, action);
1063
+ };
1064
+ for (const state of Object.keys(parked)) {
1065
+ const stateBase = [...base, "callerParkedStates", state];
1066
+ if (!states.has(state)) {
1067
+ add(stateBase, `callerParkedStates references unknown state ${state}`, "UDL3001");
1068
+ continue;
1069
+ }
1070
+ const exits = exitsByState.get(state) ?? [];
1071
+ if (exits.length === 0) {
1072
+ add(stateBase, `terminal state ${state} cannot be caller-parked`, "UDL3001");
1073
+ }
1074
+ else if (exits.some(isUnconditionallyTimeDriven)) {
1075
+ add(stateBase, `caller-parked state ${state} has a time-driven exit`, "UDL3001");
1076
+ }
1077
+ }
1078
+ for (const [state, exits] of exitsByState) {
1079
+ if (exits.length > 0 &&
1080
+ !exits.some(isUnconditionallyTimeDriven) &&
1081
+ !Object.hasOwn(parked, state)) {
1082
+ add([...base, "callerParkedStates"], `state ${state} has only caller-driven exits and needs a caller-parked reason`, "UDL3001");
1083
+ }
1084
+ }
1085
+ }
1086
+ /**
1087
+ * Composition dials sit on the document, not on an instrument: a confirmation
1088
+ * threshold is a property of the whole product. Keys are unique across the
1089
+ * document the same way instrument dial keys are unique within an instrument.
1090
+ */
1091
+ function validateCompositionDials(document, add) {
1092
+ const dials = document.dials ?? [];
1093
+ addDuplicateIssues(dials.map((dial) => dial.key), ["dials"], "composition dial key", add);
1094
+ const kinds = new Map();
1095
+ dials.forEach((dial, dialIndex) => {
1096
+ const prior = kinds.get(dial.kind);
1097
+ if (prior !== undefined) {
1098
+ add(["dials", dialIndex, "kind"], `composition dial kind ${dial.kind} duplicates dials[${prior}]`, "UDL2001");
1099
+ return;
1100
+ }
1101
+ kinds.set(dial.kind, dialIndex);
1102
+ });
1103
+ }
1104
+ function validateDials(instrument, base, add) {
1105
+ const dials = instrument.dials ?? [];
1106
+ addDuplicateIssues(dials.map((dial) => dial.key), [...base, "dials"], "dial key", add);
1107
+ const targets = new Map();
1108
+ dials.forEach((dial, dialIndex) => {
1109
+ const dialBase = [...base, "dials", dialIndex];
1110
+ const target = dial.kind === "window"
1111
+ ? `window:${dial.field}`
1112
+ : dial.kind === "decision_deadline_ms"
1113
+ ? `decision:${dial.action}`
1114
+ : dial.kind === "reconcile_tolerance"
1115
+ ? `reconcile_tolerance:${dial.key}`
1116
+ : "unwind_penalty";
1117
+ const prior = targets.get(target);
1118
+ if (prior !== undefined) {
1119
+ add([...dialBase, "key"], `dial target ${target} duplicates dials[${prior}]`, "UDL5008");
1120
+ }
1121
+ else {
1122
+ targets.set(target, dialIndex);
1123
+ }
1124
+ if (dial.kind === "window") {
1125
+ const anchorsAction = Object.values(instrument.actions).some((definition) => definition.due?.field === dial.field ||
1126
+ definition.deadline?.field === dial.field);
1127
+ if (!anchorsAction) {
1128
+ add([...dialBase, "field"], `window dial field ${dial.field} anchors no action deadline or due condition`, "UDL5008");
1129
+ }
1130
+ const min = dial.minOffset === undefined
1131
+ ? undefined
1132
+ : fixedIsoDurationMs(dial.minOffset);
1133
+ const max = dial.maxOffset === undefined
1134
+ ? undefined
1135
+ : fixedIsoDurationMs(dial.maxOffset);
1136
+ if (dial.minOffset !== undefined && min === null) {
1137
+ add([...dialBase, "minOffset"], "dial minOffset must be a fixed ISO-8601 duration", "UDL5008");
1138
+ }
1139
+ if (dial.maxOffset !== undefined && max === null) {
1140
+ add([...dialBase, "maxOffset"], "dial maxOffset must be a fixed ISO-8601 duration", "UDL5008");
1141
+ }
1142
+ if (min !== undefined &&
1143
+ min !== null &&
1144
+ max !== undefined &&
1145
+ max !== null &&
1146
+ min > max) {
1147
+ add([...dialBase, "maxOffset"], "dial minOffset exceeds maxOffset", "UDL5008");
1148
+ }
1149
+ return;
1150
+ }
1151
+ if (dial.kind === "decision_deadline_ms") {
1152
+ if (!instrument.actions[dial.action]?.decision) {
1153
+ add([...dialBase, "action"], `decision deadline dial action ${dial.action} declares no decision`, "UDL5008");
1154
+ }
1155
+ if (dial.minMs !== undefined &&
1156
+ dial.maxMs !== undefined &&
1157
+ dial.minMs > dial.maxMs) {
1158
+ add([...dialBase, "maxMs"], "dial minMs exceeds maxMs", "UDL5008");
1159
+ }
1160
+ return;
1161
+ }
1162
+ if (dial.kind === "reconcile_tolerance") {
1163
+ const forgives = Object.values(instrument.actions).some((action) => (action.reconcile ?? []).some((reconcile) => reconcile.match.law === "tolerance" &&
1164
+ reconcile.match.dial === dial.key));
1165
+ if (!forgives) {
1166
+ add(dialBase, `reconcile tolerance dial ${dial.key} bounds no reconcile`, "UDL5008");
1167
+ }
1168
+ return;
1169
+ }
1170
+ if (!Object.values(instrument.actions).some((action) => action.quote)) {
1171
+ add(dialBase, "unwind penalty dial requires a quoting action", "UDL5008");
1172
+ }
1173
+ });
1174
+ }
1175
+ function validateActionUpdates(instrument, action, definition, actionBase, add) {
1176
+ if (!definition.updates)
1177
+ return;
1178
+ if (action === "create") {
1179
+ add([...actionBase, "updates"], "create cannot declare updates", "UDL5008");
1180
+ }
1181
+ addDuplicateIssues(definition.updates, [...actionBase, "updates"], "updated field", add);
1182
+ const inputFields = recordValue(definition.input?.properties);
1183
+ const stepBoundFields = new Set(Object.values(instrument.actions).flatMap((candidate) => [...candidate.steps, ...candidate.moves].flatMap((step) => Object.values(step.bind).flatMap((binding) => binding.from === "instance" && binding.path.startsWith("fields.")
1184
+ ? [binding.path.slice("fields.".length)]
1185
+ : []))));
1186
+ const setsAtFields = new Set(Object.values(instrument.actions).flatMap((candidate) => candidate.setsAt ? [candidate.setsAt.field] : []));
1187
+ const partitionFields = new Set((instrument.partitions ?? []).flatMap((partition) => [
1188
+ partition.totalField,
1189
+ ...partition.pieceFields,
1190
+ ]));
1191
+ const aggregateFields = new Set((instrument.aggregateInvariants ?? []).map((invariant) => invariant.parentField));
1192
+ definition.updates.forEach((field, fieldIndex) => {
1193
+ const fieldPath = [...actionBase, "updates", fieldIndex];
1194
+ if (!Object.hasOwn(inputFields, field)) {
1195
+ add(fieldPath, `updated field ${field} is not declared by action input`, "UDL5008");
1196
+ }
1197
+ if (!Object.hasOwn(instrument.fields, field)) {
1198
+ add(fieldPath, `updated field ${field} is not an instrument field`, "UDL5008");
1199
+ }
1200
+ if (stepBoundFields.has(field)) {
1201
+ add(fieldPath, `updated field ${field} is bound by a kernel step`, "UDL5008");
1202
+ }
1203
+ if (setsAtFields.has(field)) {
1204
+ add(fieldPath, `updated field ${field} is owned by setsAt`, "UDL5008");
1205
+ }
1206
+ if (partitionFields.has(field)) {
1207
+ add(fieldPath, `updated field ${field} participates in a partition`, "UDL5008");
1208
+ }
1209
+ if (aggregateFields.has(field)) {
1210
+ add(fieldPath, `updated field ${field} is an aggregate cap`, "UDL5008");
1211
+ }
1212
+ });
1213
+ if (definition.moves.length > 0) {
1214
+ add([...actionBase, "updates"], "an action cannot update fields while moving money", "UDL5008");
1215
+ }
1216
+ }
1217
+ function validateCheckRequirements(instrument, definition, actionBase, add) {
1218
+ const checks = definition.requiresChecks ?? [];
1219
+ addDuplicateIssues(checks.map((check) => `${check.family}:${check.checkKind}`), [...actionBase, "requiresChecks"], "check requirement", add);
1220
+ checks.forEach((check, checkIndex) => {
1221
+ const checkBase = [...actionBase, "requiresChecks", checkIndex];
1222
+ if (!Object.hasOwn(instrument.fields, check.subjectField)) {
1223
+ add([...checkBase, "subjectField"], `check subjectField ${check.subjectField} is not declared`, "UDL5002");
1224
+ }
1225
+ addDuplicateIssues(check.statuses, [...checkBase, "statuses"], "accepted check status", add);
1226
+ if (check.maxAge && fixedIsoDurationMs(check.maxAge) === null) {
1227
+ add([...checkBase, "maxAge"], "check maxAge must be a fixed ISO-8601 duration", "UDL5002");
1228
+ }
1229
+ });
1230
+ }
1231
+ function validateRemainder(instrument, action, definition, actionBase, instruments, references, add) {
1232
+ const remainder = definition.remainder;
1233
+ if (!remainder)
1234
+ return;
1235
+ const remainderBase = [...actionBase, "remainder"];
1236
+ if (action === "create") {
1237
+ add(remainderBase, "create cannot declare remainder", "UDL4001");
1238
+ }
1239
+ if (definition.distribute || definition.signedSum) {
1240
+ add(remainderBase, "remainder cannot combine with distribute or signedSum", "UDL4001");
1241
+ }
1242
+ if (remainder.accumulateRef !== undefined &&
1243
+ remainder.accumulateRef === remainder.amountRef) {
1244
+ add([...remainderBase, "accumulateRef"], "remainder accumulateRef must differ from amountRef", "UDL4001");
1245
+ }
1246
+ const [totalRoot, totalKey] = remainder.totalPath.split(".");
1247
+ const totalDeclared = (totalRoot === "fields" &&
1248
+ totalKey !== undefined &&
1249
+ isMoneySchema(instrument.fields[totalKey] ?? {})) ||
1250
+ (totalRoot === "refs" &&
1251
+ totalKey !== undefined &&
1252
+ Object.values(instrument.actions).some((candidate) => candidate.remainder?.accumulateRef === totalKey));
1253
+ if (!totalDeclared) {
1254
+ add([...remainderBase, "totalPath"], `remainder total ${remainder.totalPath} is not declared money`, "UDL4001");
1255
+ }
1256
+ if (remainder.inputKey !== undefined &&
1257
+ !Object.hasOwn(recordValue(definition.input?.properties), remainder.inputKey)) {
1258
+ add([...remainderBase, "inputKey"], `remainder inputKey ${remainder.inputKey} is not declared by action input`, "UDL4001");
1259
+ }
1260
+ const transfers = definition.moves.filter((move) => move.operation === "internal_transfer.create" &&
1261
+ move.bind.amount?.from === "instance" &&
1262
+ move.bind.amount.path === `refs.${remainder.amountRef}`);
1263
+ if (transfers.length !== 1) {
1264
+ add(remainderBase, `remainder requires exactly one internal transfer whose amount is refs.${remainder.amountRef}`, "UDL4001");
1265
+ }
1266
+ (remainder.collected ?? []).forEach((collected, collectedIndex) => {
1267
+ const collectedBase = [
1268
+ ...remainderBase,
1269
+ "collected",
1270
+ collectedIndex,
1271
+ ];
1272
+ addDuplicateIssues(collected.statuses, [...collectedBase, "statuses"], "collected status", add);
1273
+ const child = instruments.get(collected.instrumentId);
1274
+ if (!child) {
1275
+ add([...collectedBase, "instrumentId"], `remainder references unknown instrument ${collected.instrumentId}`, "UDL4001");
1276
+ return;
1277
+ }
1278
+ const refSchema = child.fields[collected.refField];
1279
+ if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
1280
+ add([...collectedBase, "refField"], `${child.id}.${collected.refField} must reference ${instrument.id}`, "UDL4001");
1281
+ }
1282
+ const amountDeclared = collected.path === "refs"
1283
+ ? Object.values(child.actions).some((candidate) => candidate.remainder?.amountRef === collected.amountField ||
1284
+ candidate.remainder?.accumulateRef === collected.amountField)
1285
+ : isMoneySchema(child.fields[collected.amountField] ?? {});
1286
+ if (!amountDeclared) {
1287
+ add([...collectedBase, "amountField"], `${child.id}.${collected.amountField} is not declared money`, "UDL4001");
1288
+ }
1289
+ if (collected.path !== "refs" &&
1290
+ !(instrument.aggregateInvariants ?? []).some((invariant) => invariant.childInstrumentId === collected.instrumentId &&
1291
+ invariant.childRefField === collected.refField &&
1292
+ "childField" in invariant &&
1293
+ invariant.childField === collected.amountField)) {
1294
+ add(collectedBase, `remainder collection ${collected.instrumentId}.${collected.amountField} has no congruent aggregate invariant`, "UDL4001");
1295
+ }
1296
+ collected.statuses.forEach((status, statusIndex) => {
1297
+ if (!child.lifecycle.states.includes(status)) {
1298
+ add([...collectedBase, "statuses", statusIndex], `remainder status ${status} is not declared by ${child.id}`, "UDL4001");
1299
+ }
1300
+ });
1301
+ });
1302
+ }
1303
+ function validateActions(instrument, base, instruments, references, add) {
1304
+ validatePayoutsAndSettlement(instrument, base, instruments, references, add);
1305
+ for (const [action, definition] of Object.entries(instrument.actions)) {
1306
+ const actionBase = [...base, "actions", action];
1307
+ if (action === "create" && definition.input) {
1308
+ add([...actionBase, "input"], "create already consumes instrument fields and must not declare input", "UDL5008");
814
1309
  }
815
1310
  if (definition.input && definition.input.type !== "object") {
816
- add([...verbBase, "input", "type"], "verb input must declare an object-shaped JSON Schema");
1311
+ add([...actionBase, "input", "type"], "action input must declare an object-shaped JSON Schema", "UDL5008");
817
1312
  }
818
1313
  const inputFields = recordValue(definition.input?.properties);
819
1314
  for (const [refKey, inputKey] of Object.entries(definition.captureInput ?? {})) {
820
1315
  if (!Object.hasOwn(inputFields, inputKey)) {
821
- add([...verbBase, "captureInput", refKey], `captured receipt input references undeclared verb input field ${inputKey}`);
1316
+ add([...actionBase, "captureInput", refKey], `captured receipt input references undeclared action input field ${inputKey}`, "UDL5008");
822
1317
  }
823
1318
  }
824
- addDuplicateIssues(definition.moves.map((move) => move.key), [...verbBase, "moves"], "move key", add);
1319
+ validateDecidedAmount(instrument, action, actionBase, add);
1320
+ validateActionUpdates(instrument, action, definition, actionBase, add);
1321
+ validateCheckRequirements(instrument, definition, actionBase, add);
1322
+ validateRemainder(instrument, action, definition, actionBase, instruments, references, add);
1323
+ addDuplicateIssues(definition.moves.map((move) => move.key), [...actionBase, "moves"], "move key", add);
825
1324
  if (definition.earnable && definition.moves.length === 0) {
826
- add(verbBase, "earnable requires a money move");
1325
+ add(actionBase, "earnable requires a money move", "UDL5008");
827
1326
  }
828
- definition.steps.forEach((step, stepIndex) => validateStep(noun, definition, step, [...verbBase, "steps", stepIndex], add));
829
- definition.moves.forEach((move, moveIndex) => validateStep(noun, definition, move, [...verbBase, "moves", moveIndex], add));
1327
+ definition.steps.forEach((step, stepIndex) => validateStep(instrument, definition, step, [...actionBase, "steps", stepIndex], add));
1328
+ definition.moves.forEach((move, moveIndex) => validateStep(instrument, definition, move, [...actionBase, "moves", moveIndex], add));
830
1329
  if (definition.requiresRefs) {
831
- addDuplicateIssues(definition.requiresRefs.map((gate) => gate.field), [...verbBase, "requiresRefs"], "gate field", add);
1330
+ addDuplicateIssues(definition.requiresRefs.map((gate) => gate.field), [...actionBase, "requiresRefs"], "gate field", add);
832
1331
  definition.requiresRefs.forEach((gate, gateIndex) => {
833
- addDuplicateIssues(gate.statuses, [...verbBase, "requiresRefs", gateIndex, "statuses"], "gate status", add);
834
- const schema = noun.fields[gate.field];
1332
+ addDuplicateIssues(gate.statuses, [...actionBase, "requiresRefs", gateIndex, "statuses"], "gate status", add);
1333
+ const schema = instrument.fields[gate.field];
835
1334
  if (!schema) {
836
- add([...verbBase, "requiresRefs", gateIndex, "field"], `gate references unknown field ${gate.field}`);
1335
+ add([...actionBase, "requiresRefs", gateIndex, "field"], `gate references unknown field ${gate.field}`, "UDL5001");
837
1336
  return;
838
1337
  }
839
- const targets = [...nouns.values()].filter((target) => references.accepts(schema, target.idPrefix));
1338
+ const targets = [...instruments.values()].filter((target) => references.accepts(schema, target.idPrefix));
840
1339
  if (targets.length !== 1) {
841
- add([...verbBase, "requiresRefs", gateIndex, "field"], `gate field ${gate.field} must identify exactly one noun (found ${targets.length})`);
1340
+ add([...actionBase, "requiresRefs", gateIndex, "field"], `gate field ${gate.field} must identify exactly one instrument (found ${targets.length})`, "UDL5001");
842
1341
  return;
843
1342
  }
844
1343
  const target = targets[0];
845
1344
  gate.statuses.forEach((status, statusIndex) => {
846
1345
  if (!target.lifecycle.states.includes(status)) {
847
- add([...verbBase, "requiresRefs", gateIndex, "statuses", statusIndex], `gate status ${status} is not declared by ${target.id}`);
1346
+ add([
1347
+ ...actionBase,
1348
+ "requiresRefs",
1349
+ gateIndex,
1350
+ "statuses",
1351
+ statusIndex,
1352
+ ], `gate status ${status} is not declared by ${target.id}`, "UDL5008");
848
1353
  }
849
1354
  });
850
- validateGateShape(noun, verb, gate, target, [...verbBase, "requiresRefs", gateIndex], add);
1355
+ validateGateShape(instrument, action, gate, target, [...actionBase, "requiresRefs", gateIndex], add);
851
1356
  });
852
1357
  }
853
1358
  if (definition.requiresDrainedAccount) {
854
- const drainBase = [...verbBase, "requiresDrainedAccount"];
855
- if (verb === "create") {
856
- add([...drainBase], "requiresDrainedAccount cannot gate create: no account exists yet");
1359
+ const drainBase = [...actionBase, "requiresDrainedAccount"];
1360
+ if (action === "create") {
1361
+ add([...drainBase], "requiresDrainedAccount cannot gate create: no account exists yet", "UDL5008");
857
1362
  }
858
1363
  const path = definition.requiresDrainedAccount.path;
859
1364
  const declared = (path.startsWith("fields.") &&
860
- Object.hasOwn(noun.fields, path.slice("fields.".length))) ||
1365
+ Object.hasOwn(instrument.fields, path.slice("fields.".length))) ||
861
1366
  (path.startsWith("refs.") &&
862
- declaredRefKeys(noun).has(path.slice("refs.".length)));
1367
+ declaredRefKeys(instrument).has(path.slice("refs.".length)));
863
1368
  if (!declared) {
864
- add([...drainBase, "path"], `requiresDrainedAccount reads ${path}, which is not declared`);
1369
+ add([...drainBase, "path"], `requiresDrainedAccount reads ${path}, which is not declared`, "UDL5008");
865
1370
  }
866
1371
  }
867
1372
  if (definition.signedSum) {
868
- const sumBase = [...verbBase, "signedSum"];
869
- if (verb === "create") {
870
- add(sumBase, "signedSum cannot run on create: no parent row exists yet");
1373
+ const sumBase = [...actionBase, "signedSum"];
1374
+ if (action === "create") {
1375
+ add(sumBase, "signedSum cannot run on create: no parent row exists yet", "UDL5008");
871
1376
  }
872
1377
  const generatedRefs = [
873
1378
  {
@@ -881,38 +1386,38 @@ function validateVerbs(noun, base, nouns, references, add) {
881
1386
  ];
882
1387
  addDuplicateIssues(generatedRefs.map((ref) => ref.key), sumBase, "signed sum ref", add);
883
1388
  const existingRefs = new Set([
884
- ...Object.entries(noun.verbs).flatMap(([candidateVerb, candidate]) => [
1389
+ ...Object.entries(instrument.actions).flatMap(([candidateAction, candidate]) => [
885
1390
  ...Object.keys(candidate.captureInput ?? {}),
886
1391
  ...[...candidate.steps, ...candidate.moves].flatMap((step) => Object.keys(step.capture ?? {})),
887
- ...(candidateVerb !== verb && candidate.signedSum
1392
+ ...(candidateAction !== action && candidate.signedSum
888
1393
  ? [
889
1394
  candidate.signedSum.amountRef,
890
1395
  ...candidate.signedSum.sources.map((source) => source.subtotalRef),
891
1396
  ]
892
1397
  : []),
893
1398
  ]),
894
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
895
- ...(noun.subject ? ["subject"] : []),
1399
+ ...quoteRefKeys(instrument),
1400
+ ...(instrument.subject ? ["subject"] : []),
896
1401
  ]);
897
1402
  for (const ref of generatedRefs) {
898
1403
  if (existingRefs.has(ref.key)) {
899
- add(ref.path, `signed sum ref ${ref.key} collides with an existing noun ref key`);
1404
+ add(ref.path, `signed sum ref ${ref.key} collides with an existing instrument ref key`, "UDL5008");
900
1405
  }
901
1406
  }
902
1407
  if (!definition.signedSum.sources.some((source) => source.sign === "add")) {
903
- add([...sumBase, "sources"], "signedSum needs at least one add source");
1408
+ add([...sumBase, "sources"], "signedSum needs at least one add source", "UDL5008");
904
1409
  }
905
- const parentCurrencies = Object.entries(noun.fields).filter(([, schema]) => isCurrencySchema(schema));
1410
+ const parentCurrencies = Object.entries(instrument.fields).filter(([, schema]) => isCurrencySchema(schema));
906
1411
  if (parentCurrencies.length !== 1) {
907
- add(sumBase, `signedSum parent ${noun.id} needs exactly one currency field`);
1412
+ add(sumBase, `signedSum parent ${instrument.id} needs exactly one currency field`, "UDL5008");
908
1413
  }
909
- const sourceKeys = definition.signedSum.sources.map((source) => `${source.nounId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`);
1414
+ const sourceKeys = definition.signedSum.sources.map((source) => `${source.instrumentId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`);
910
1415
  addDuplicateIssues(sourceKeys, [...sumBase, "sources"], "signed sum source", add);
911
1416
  definition.signedSum.sources.forEach((source, sourceIndex) => {
912
1417
  for (let priorIndex = 0; priorIndex < sourceIndex; priorIndex += 1) {
913
1418
  const prior = definition.signedSum?.sources[priorIndex];
914
1419
  if (!prior ||
915
- prior.nounId !== source.nounId ||
1420
+ prior.instrumentId !== source.instrumentId ||
916
1421
  prior.refField !== source.refField ||
917
1422
  prior.amountField !== source.amountField ||
918
1423
  prior.sign !== source.sign) {
@@ -922,242 +1427,610 @@ function validateVerbs(noun, base, nouns, references, add) {
922
1427
  const overlap = source.statuses.filter((status) => priorStatuses.has(status));
923
1428
  if (overlap.length === 0)
924
1429
  continue;
925
- add([...sumBase, "sources", sourceIndex, "statuses"], `signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`);
1430
+ add([...sumBase, "sources", sourceIndex, "statuses"], `signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`, "UDL5008");
926
1431
  }
927
1432
  });
928
1433
  definition.signedSum.sources.forEach((source, sourceIndex) => {
929
1434
  const sourceBase = [...sumBase, "sources", sourceIndex];
930
- const child = nouns.get(source.nounId);
1435
+ const child = instruments.get(source.instrumentId);
931
1436
  if (!child) {
932
- add([...sourceBase, "nounId"], `signedSum references unknown noun ${source.nounId}`);
1437
+ add([...sourceBase, "instrumentId"], `signedSum references unknown instrument ${source.instrumentId}`, "UDL5008");
933
1438
  return;
934
1439
  }
935
1440
  const refSchema = child.fields[source.refField];
936
- if (!refSchema || !references.accepts(refSchema, noun.idPrefix)) {
937
- add([...sourceBase, "refField"], `${source.nounId}.${source.refField} must reference ${noun.id}`);
1441
+ if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
1442
+ add([...sourceBase, "refField"], `${source.instrumentId}.${source.refField} must reference ${instrument.id}`, "UDL5008");
938
1443
  }
939
1444
  const amountSchema = child.fields[source.amountField];
940
1445
  if (!amountSchema || !isMoneySchema(amountSchema)) {
941
- add([...sourceBase, "amountField"], `${source.nounId}.${source.amountField} must be a money field`);
1446
+ add([...sourceBase, "amountField"], `${source.instrumentId}.${source.amountField} must be a money field`, "UDL5008");
942
1447
  }
943
1448
  const childCurrencies = Object.entries(child.fields).filter(([, schema]) => isCurrencySchema(schema));
944
1449
  if (childCurrencies.length !== 1) {
945
- add([...sourceBase, "nounId"], `signedSum source ${source.nounId} needs exactly one currency field`);
1450
+ add([...sourceBase, "instrumentId"], `signedSum source ${source.instrumentId} needs exactly one currency field`, "UDL5008");
946
1451
  }
947
1452
  addDuplicateIssues(source.statuses, [...sourceBase, "statuses"], "signed sum status", add);
948
1453
  source.statuses.forEach((status, statusIndex) => {
949
1454
  if (!child.lifecycle.states.includes(status)) {
950
- add([...sourceBase, "statuses", statusIndex], `signedSum status ${status} is not declared by ${source.nounId}`);
1455
+ add([...sourceBase, "statuses", statusIndex], `signedSum status ${status} is not declared by ${source.instrumentId}`, "UDL5008");
951
1456
  }
952
1457
  });
953
1458
  });
954
1459
  const amountPath = `refs.${definition.signedSum.amountRef}`;
955
- const payoutMoves = Object.values(noun.verbs).flatMap((candidate) => candidate.moves.filter((move) => move.operation === "internal_transfer.create" &&
1460
+ const payoutMoves = Object.values(instrument.actions).flatMap((candidate) => candidate.moves.filter((move) => move.operation === "internal_transfer.create" &&
956
1461
  move.bind.amount?.from === "instance" &&
957
1462
  move.bind.amount.path === amountPath));
958
- const payouts = Object.values(noun.verbs).filter((candidate) => candidate.payout?.amount === amountPath);
1463
+ const payouts = Object.values(instrument.actions).filter((candidate) => candidate.payout?.amount === amountPath);
959
1464
  if (payoutMoves.length + payouts.length !== 1) {
960
- add(sumBase, `signedSum requires exactly one payout or noun transfer whose amount is ${amountPath}`);
1465
+ add(sumBase, `signedSum requires exactly one payout or instrument transfer whose amount is ${amountPath}`, "UDL5008");
961
1466
  }
962
1467
  }
963
1468
  if (definition.due) {
964
- if (definition.publicIntent) {
965
- add([...verbBase, "publicIntent"], "a system due verb cannot declare a public intent");
1469
+ if (definition.publicAction) {
1470
+ add([...actionBase, "publicAction"], "a system due action cannot declare a public action", "UDL3001");
966
1471
  }
967
- const transition = noun.lifecycle.transitions[verb];
1472
+ const transition = instrument.lifecycle.transitions[action];
968
1473
  if (definition.due.every) {
969
- // A recurring due verb is stationary: each occurrence fires in place
970
- // and the runtime keeps the instance's current status, so `to` is
971
- // only a witness naming one of the source states.
972
- if (!transition || !transition.from.includes(transition.to)) {
973
- add([...verbBase, "due"], "a recurring due verb must be stationary: its transition's to must name one of its from states");
1474
+ const serialLiability = definition.due.every.liability === "one_open";
1475
+ if (!transition ||
1476
+ (serialLiability
1477
+ ? transition.from.includes(transition.to)
1478
+ : !transition.from.includes(transition.to))) {
1479
+ add([...actionBase, "due"], serialLiability
1480
+ ? "a one-open recurring due action must leave its source states while the period liability is open"
1481
+ : "a recurring due action must be stationary: its transition's to must name one of its from states", "UDL5008");
974
1482
  }
975
1483
  }
976
1484
  else if (!transition || transition.from.includes(transition.to)) {
977
- add([...verbBase, "due"], "a due verb must leave every source state so the maintenance loop fires its anchor exactly once");
1485
+ add([...actionBase, "due"], "a due action must leave every source state so the maintenance loop fires its anchor exactly once", "UDL3001");
978
1486
  }
979
- const schema = noun.fields[definition.due.field];
1487
+ const schema = instrument.fields[definition.due.field];
980
1488
  if (!schema) {
981
- add([...verbBase, "due", "field"], `due condition references unknown field ${definition.due.field}`);
1489
+ add([...actionBase, "due", "field"], `due condition references unknown field ${definition.due.field}`, "UDL3001");
982
1490
  }
983
1491
  else if (!isDateTimeFormat(schema.format)) {
984
- add([...verbBase, "due", "field"], "due field must be a date-time field");
1492
+ add([...actionBase, "due", "field"], "due field must be a date-time field", "UDL5008");
985
1493
  }
986
1494
  if (definition.due.offset &&
987
1495
  fixedIsoDurationMs(definition.due.offset) === null) {
988
- add([...verbBase, "due", "offset"], "due.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds");
1496
+ add([...actionBase, "due", "offset"], "due.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
989
1497
  }
1498
+ validateDueRecurrence(instrument, action, actionBase, add);
990
1499
  }
991
1500
  if (definition.deadline) {
992
1501
  if (definition.due) {
993
- add([...verbBase, "deadline"], "a verb cannot declare both a due condition and a deadline");
1502
+ add([...actionBase, "deadline"], "a action cannot declare both a due condition and a deadline", "UDL3001");
994
1503
  }
995
- const schema = noun.fields[definition.deadline.field];
1504
+ const schema = instrument.fields[definition.deadline.field];
996
1505
  if (!schema) {
997
- add([...verbBase, "deadline", "field"], `deadline references unknown field ${definition.deadline.field}`);
1506
+ add([...actionBase, "deadline", "field"], `deadline references unknown field ${definition.deadline.field}`, "UDL3001");
998
1507
  }
999
1508
  else if (!isDateTimeFormat(schema.format)) {
1000
- add([...verbBase, "deadline", "field"], "deadline field must be a date-time field");
1509
+ add([...actionBase, "deadline", "field"], "deadline field must be a date-time field", "UDL5008");
1001
1510
  }
1002
1511
  if (definition.deadline.offset &&
1003
1512
  fixedIsoDurationMs(definition.deadline.offset) === null) {
1004
- add([...verbBase, "deadline", "offset"], "deadline.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds");
1513
+ add([...actionBase, "deadline", "offset"], "deadline.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
1005
1514
  }
1006
1515
  }
1007
1516
  if (definition.examples) {
1008
- addDuplicateIssues(definition.examples.map((example) => example.name), [...verbBase, "examples"], "example name", add);
1009
- const inputSchema = exampleInputSchema(noun, verb, definition);
1517
+ addDuplicateIssues(definition.examples.map((example) => example.name), [...actionBase, "examples"], "example name", add);
1518
+ const inputSchema = exampleInputSchema(instrument, action, definition);
1010
1519
  definition.examples.forEach((example, exampleIndex) => {
1011
- for (const error of validateUdlSchemaValue(inputSchema, example.input)
1012
- .errors) {
1013
- add([...verbBase, "examples", exampleIndex, "input"], `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`);
1520
+ const input = exampleInputForUdlValidation(instrument, example.input);
1521
+ for (const error of validateUdlSchemaValue(inputSchema, input).errors) {
1522
+ add([...actionBase, "examples", exampleIndex, "input"], `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`, "UDL5008");
1014
1523
  }
1015
1524
  });
1016
1525
  }
1017
1526
  }
1527
+ validateRecurringLiabilityOverlap(instrument, base, add);
1018
1528
  }
1019
- function validatePayoutsAndSettlement(noun, base, references, add) {
1020
- const payoutEntries = Object.entries(noun.verbs).filter(([, definition]) => definition.payout !== undefined);
1021
- const settlementEntries = Object.entries(noun.verbs).filter(([, definition]) => definition.requiresSettlement !== undefined);
1022
- if (payoutEntries.length > 0 && settlementEntries.length !== 1) {
1023
- add([...base, "verbs"], `payout-owning noun must declare exactly one settlement gate; found ${settlementEntries.length}`);
1529
+ function validateDueRecurrence(instrument, action, actionBase, add) {
1530
+ const definition = instrument.actions[action];
1531
+ const every = definition?.due?.every;
1532
+ if (!definition || !every)
1533
+ return;
1534
+ const recurrenceBase = [...actionBase, "due", "every"];
1535
+ if (typeof every.period === "string" &&
1536
+ fixedIsoDurationMs(every.period) === null) {
1537
+ add([...recurrenceBase, "period"], "recurrence period must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
1538
+ }
1539
+ const terminations = [
1540
+ every.countField === undefined ? undefined : "countField",
1541
+ every.untilField === undefined ? undefined : "untilField",
1542
+ every.untilAction === undefined ? undefined : "untilAction",
1543
+ ].filter((value) => value !== undefined);
1544
+ if (terminations.length === 0) {
1545
+ add(recurrenceBase, "recurrence must declare a termination using countField, untilField, or untilAction", "UDL3001");
1546
+ }
1547
+ else if (terminations.length > 1) {
1548
+ add(recurrenceBase, `recurrence termination is ambiguous: ${terminations.join(", ")}`, "UDL3001");
1549
+ }
1550
+ if ((every.untilField !== undefined || every.untilAction !== undefined) &&
1551
+ every.liability !== "one_open") {
1552
+ add([...recurrenceBase, "liability"], "open-ended recurrence must declare liability one_open", "UDL3001");
1024
1553
  }
1025
- const settlementRequirement = settlementEntries.length === 1
1026
- ? settlementEntries[0]?.[1].requiresSettlement
1554
+ if (every.liability === "one_open" && every.delinquency !== "parent_policy") {
1555
+ add([...recurrenceBase, "delinquency"], "one-open recurrence must reuse delinquency parent_policy", "UDL3001");
1556
+ }
1557
+ if ((every.untilField !== undefined || every.untilAction !== undefined) &&
1558
+ every.drainAction === undefined) {
1559
+ add([...recurrenceBase, "drainAction"], "open-ended recurrence must declare its drain action", "UDL3001");
1560
+ }
1561
+ if (every.countField) {
1562
+ const count = instrument.fields[every.countField];
1563
+ if (!count) {
1564
+ add([...recurrenceBase, "countField"], `recurrence count references unknown field ${every.countField}`, "UDL3001");
1565
+ }
1566
+ else if (count.type !== "integer") {
1567
+ add([...recurrenceBase, "countField"], "recurrence count field must be an integer field", "UDL3001");
1568
+ }
1569
+ else if (!instrument.required.includes(every.countField)) {
1570
+ add([...recurrenceBase, "countField"], "recurrence count field must be required", "UDL3001");
1571
+ }
1572
+ else if (instrument.update?.fields.includes(every.countField)) {
1573
+ add([...recurrenceBase, "countField"], "recurrence count field cannot be mutable", "UDL3001");
1574
+ }
1575
+ }
1576
+ if (every.untilField) {
1577
+ const until = instrument.fields[every.untilField];
1578
+ if (!until) {
1579
+ add([...recurrenceBase, "untilField"], `recurrence termination references unknown field ${every.untilField}`, "UDL3001");
1580
+ }
1581
+ else if (!isDateTimeFormat(until.format)) {
1582
+ add([...recurrenceBase, "untilField"], "recurrence termination field must be a date-time field", "UDL3001");
1583
+ }
1584
+ else if (!instrument.required.includes(every.untilField)) {
1585
+ add([...recurrenceBase, "untilField"], "recurrence termination field must be required", "UDL3001");
1586
+ }
1587
+ else if (instrument.update?.fields.includes(every.untilField)) {
1588
+ add([...recurrenceBase, "untilField"], "recurrence termination field cannot be mutable", "UDL3001");
1589
+ }
1590
+ }
1591
+ if (every.liability === "one_open" &&
1592
+ (definition.moves.length > 0 || definition.payout !== undefined)) {
1593
+ add([...actionBase, "moves"], "a recurring due action cannot move money; it may only open one period or invoke a declared system action", "UDL3001");
1594
+ }
1595
+ if (every.untilAction) {
1596
+ if (every.drainAction && every.drainAction !== every.untilAction) {
1597
+ add([...recurrenceBase, "drainAction"], "port recurrence termination must use untilAction as its drainAction", "UDL3001");
1598
+ }
1599
+ validateRecurrenceTerminationAction(instrument, action, every.untilAction, recurrenceBase, add);
1600
+ }
1601
+ else if (every.untilField) {
1602
+ const drainAction = every.drainAction;
1603
+ const drainDefinition = drainAction
1604
+ ? instrument.actions[drainAction]
1605
+ : undefined;
1606
+ if (drainAction &&
1607
+ (!drainDefinition ||
1608
+ drainDefinition.due?.every !== undefined ||
1609
+ drainDefinition.due?.field !== every.untilField ||
1610
+ !recurrenceActionExits(instrument, action, drainAction))) {
1611
+ add([...recurrenceBase, "drainAction"], `stored-date recurrence drain action ${drainAction} must be a one-shot due action on ${every.untilField} that exits every recurring state`, "UDL3001");
1612
+ }
1613
+ else if (drainAction &&
1614
+ drainDefinition &&
1615
+ instrumentOwnsReservation(instrument) &&
1616
+ !actionDrains(drainDefinition)) {
1617
+ add([...actionPath(recurrenceBase, drainAction), "moves"], `recurrence termination action ${drainAction} must drain its reserved money`, "UDL3001");
1618
+ }
1619
+ if (drainAction &&
1620
+ drainDefinition &&
1621
+ (drainDefinition.payout !== undefined ||
1622
+ drainDefinition.moves.some((move) => move.operation !== "internal_transfer.void"))) {
1623
+ add([...actionPath(recurrenceBase, drainAction), "moves"], "a stored-date recurrence termination may only void reserved money; no timeout creates, reserves, posts, or pays out money", "UDL3001");
1624
+ }
1625
+ }
1626
+ }
1627
+ function validateRecurrenceTerminationAction(instrument, recurringAction, terminationAction, recurrenceBase, add) {
1628
+ const definition = instrument.actions[terminationAction];
1629
+ const terminationPath = actionPath(recurrenceBase, terminationAction);
1630
+ if (!definition) {
1631
+ add([...recurrenceBase, "untilAction"], `recurrence termination references unknown action ${terminationAction}`, "UDL3001");
1632
+ return;
1633
+ }
1634
+ if (!definition.port) {
1635
+ add([...recurrenceBase, "untilAction"], `recurrence termination action ${terminationAction} must declare a port`, "UDL3001");
1636
+ }
1637
+ if (!recurrenceActionExits(instrument, recurringAction, terminationAction)) {
1638
+ add([...recurrenceBase, "untilAction"], `recurrence termination action ${terminationAction} must exit every recurring source state`, "UDL3001");
1639
+ }
1640
+ if (instrumentOwnsReservation(instrument) && !actionDrains(definition)) {
1641
+ add([...terminationPath, "moves"], `recurrence termination action ${terminationAction} must drain its reserved money`, "UDL3001");
1642
+ }
1643
+ }
1644
+ function actionPath(recurrenceBase, action) {
1645
+ return [...recurrenceBase.slice(0, -3), action];
1646
+ }
1647
+ function recurrenceActionExits(instrument, recurringAction, terminationAction) {
1648
+ const recurring = instrument.lifecycle.transitions[recurringAction];
1649
+ const termination = instrument.lifecycle.transitions[terminationAction];
1650
+ if (!recurring || !termination)
1651
+ return false;
1652
+ const recurringStates = new Set([...recurring.from, recurring.to]);
1653
+ return ([...recurringStates].every((state) => termination.from.includes(state)) &&
1654
+ !recurringStates.has(termination.to));
1655
+ }
1656
+ function actionDrains(definition) {
1657
+ return (definition.requiresDrainedAccount !== undefined ||
1658
+ definition.moves.some((move) => move.operation === "internal_transfer.void"));
1659
+ }
1660
+ function instrumentOwnsReservation(instrument) {
1661
+ return Object.values(instrument.actions).some((definition) => definition.moves.some((move) => move.operation === "internal_transfer.reserve"));
1662
+ }
1663
+ function validateRecurringLiabilityOverlap(instrument, base, add) {
1664
+ const recurring = Object.entries(instrument.actions).filter(([, definition]) => definition.due?.every?.liability === "one_open");
1665
+ for (const [index, [action, definition]] of recurring.entries()) {
1666
+ const transition = instrument.lifecycle.transitions[action];
1667
+ if (!transition || !definition.due)
1668
+ continue;
1669
+ for (let priorIndex = 0; priorIndex < index; priorIndex += 1) {
1670
+ const [priorAction, priorDefinition] = recurring[priorIndex];
1671
+ const priorTransition = instrument.lifecycle.transitions[priorAction];
1672
+ if (!priorTransition ||
1673
+ priorDefinition.due?.field !== definition.due.field) {
1674
+ continue;
1675
+ }
1676
+ const priorStates = new Set(priorTransition.from);
1677
+ const overlap = transition.from.filter((state) => priorStates.has(state));
1678
+ if (overlap.length === 0)
1679
+ continue;
1680
+ add([...base, "actions", action, "due", "every"], `recurring due actions ${priorAction} and ${action} overlap period liability in states ${overlap.join(", ")}`, "UDL3001");
1681
+ }
1682
+ }
1683
+ }
1684
+ function validateDecidedAmount(instrument, action, actionBase, add) {
1685
+ const definition = instrument.actions[action];
1686
+ const clause = definition?.decidedAmount;
1687
+ if (!definition || !clause)
1688
+ return;
1689
+ const clauseBase = [...actionBase, "decidedAmount"];
1690
+ const inputField = recordValue(definition.input?.properties)[clause.field];
1691
+ if (!inputField || !isMoneySchema(recordValue(inputField))) {
1692
+ add([...clauseBase, "field"], `decided amount field ${clause.field} must be a declared action input money field`, "UDL4001");
1693
+ }
1694
+ const inputRequired = definition.input?.required;
1695
+ if (!Array.isArray(inputRequired) || !inputRequired.includes(clause.field)) {
1696
+ add([...clauseBase, "field"], `decided amount field ${clause.field} must be required by the action input`, "UDL2002");
1697
+ }
1698
+ const boundSchema = instrument.fields[clause.boundField];
1699
+ if (!boundSchema || !isMoneySchema(boundSchema)) {
1700
+ add([...clauseBase, "boundField"], `decided amount bound ${clause.boundField} must be a declared instrument money field`, "UDL4001");
1701
+ }
1702
+ if (!instrument.required.includes(clause.boundField)) {
1703
+ add([...clauseBase, "boundField"], `decided amount bound ${clause.boundField} must be required`, "UDL2002");
1704
+ }
1705
+ if (instrument.update?.fields.includes(clause.boundField)) {
1706
+ add([...clauseBase, "boundField"], `decided amount bound ${clause.boundField} cannot be mutable`, "UDL5008");
1707
+ }
1708
+ const decidedMoves = definition.moves.filter((move) => move.operation === "internal_transfer.post" &&
1709
+ move.bind.amount?.from === "input" &&
1710
+ move.bind.amount.path === clause.field);
1711
+ if (decidedMoves.length !== 1) {
1712
+ add([...clauseBase, "field"], `decided amount field ${clause.field} must fund exactly one internal_transfer.post move`, "UDL4001");
1713
+ }
1714
+ const decidedMove = decidedMoves.length === 1 ? decidedMoves[0] : undefined;
1715
+ if (decidedMove) {
1716
+ const moveIndex = definition.moves.indexOf(decidedMove);
1717
+ const postMode = decidedMove.bind.postMode;
1718
+ if (postMode?.from !== "const" || postMode.value !== "partial_only") {
1719
+ add([...actionBase, "moves", moveIndex, "bind", "postMode"], "decided amount post must use partial_only so the remainder stays reserved", "UDL5008");
1720
+ }
1721
+ const currencyFields = Object.entries(instrument.fields).filter(([, schema]) => isCurrencySchema(schema));
1722
+ const currency = decidedMove.bind.currency;
1723
+ if (currencyFields.length !== 1 ||
1724
+ currency?.from !== "instance" ||
1725
+ currency.path !== `fields.${currencyFields[0]?.[0]}`) {
1726
+ add([...actionBase, "moves", moveIndex, "bind", "currency"], "decided amount post must bind the instrument currency", "UDL5008");
1727
+ }
1728
+ }
1729
+ const remainderMoves = definition.moves.filter((move) => move.key === "remainder");
1730
+ if (remainderMoves.length !== 1) {
1731
+ add([...actionBase, "moves"], `decided amount action must declare exactly one remainder move; found ${remainderMoves.length}`, "UDL5008");
1732
+ }
1733
+ const remainderMove = remainderMoves.length === 1 ? remainderMoves[0] : undefined;
1734
+ if (remainderMove && remainderMove.operation !== "internal_transfer.void") {
1735
+ add([
1736
+ ...actionBase,
1737
+ "moves",
1738
+ definition.moves.indexOf(remainderMove),
1739
+ "operation",
1740
+ ], "decided amount remainder move must drain the reservation with internal_transfer.void", "UDL4001");
1741
+ }
1742
+ const remainderDefinition = instrument.actions[clause.remainderAction];
1743
+ if (!remainderDefinition) {
1744
+ add([...clauseBase, "remainderAction"], `decided amount remainder action ${clause.remainderAction} is not declared`, "UDL4001");
1745
+ return;
1746
+ }
1747
+ if (clause.remainderAction === action) {
1748
+ add([...clauseBase, "remainderAction"], "decided amount remainder action must be a distinct action", "UDL4001");
1749
+ }
1750
+ const transition = instrument.lifecycle.transitions[action];
1751
+ const remainderTransition = instrument.lifecycle.transitions[clause.remainderAction];
1752
+ if (transition &&
1753
+ remainderTransition &&
1754
+ (transition.from.length !== remainderTransition.from.length ||
1755
+ transition.from.some((state) => !remainderTransition.from.includes(state)))) {
1756
+ add([...clauseBase, "remainderAction"], `decided amount remainder action ${clause.remainderAction} must start from the same lifecycle states as ${action}`, "UDL4001");
1757
+ }
1758
+ const namedDrains = remainderDefinition.moves.filter((move) => move.operation === "internal_transfer.void");
1759
+ if (namedDrains.length !== 1 || remainderDefinition.moves.length !== 1) {
1760
+ add([...actionBase.slice(0, -1), clause.remainderAction, "moves"], `decided amount remainder action ${clause.remainderAction} must declare exactly one reservation drain; found ${namedDrains.length}`, "UDL4001");
1761
+ }
1762
+ const namedDrain = namedDrains.length === 1 && remainderDefinition.moves.length === 1
1763
+ ? namedDrains[0]
1764
+ : undefined;
1765
+ if (!remainderMove || !namedDrain)
1766
+ return;
1767
+ const decidedTransfer = decidedMove?.bind.transferId;
1768
+ const remainderTransfer = remainderMove.bind.transferId;
1769
+ const namedTransfer = namedDrain.bind.transferId;
1770
+ if (!sameInstanceBinding(decidedTransfer, remainderTransfer) ||
1771
+ !sameInstanceBinding(remainderTransfer, namedTransfer)) {
1772
+ add([...clauseBase, "remainderAction"], `decided amount and remainder action ${clause.remainderAction} must drain the same reservation`, "UDL4001");
1773
+ }
1774
+ }
1775
+ function sameInstanceBinding(left, right) {
1776
+ return (left?.from === "instance" &&
1777
+ right?.from === "instance" &&
1778
+ left.path.startsWith("refs.") &&
1779
+ left.path === right.path);
1780
+ }
1781
+ function validatePayoutsAndSettlement(instrument, base, instruments, references, add) {
1782
+ const payoutEntries = Object.entries(instrument.actions).filter(([, definition]) => definition.payout !== undefined);
1783
+ const reconcilingEntries = Object.entries(instrument.actions).filter(([, definition]) => definition.reconcile !== undefined);
1784
+ if (payoutEntries.length > 0 && reconcilingEntries.length !== 1) {
1785
+ add([...base, "actions"], `payout-owning instrument must declare exactly one reconciling action; found ${reconcilingEntries.length}`, "UDL5005");
1786
+ }
1787
+ if (payoutEntries.length > 0) {
1788
+ // The bank's own line is the only evidence that a payout left the estate.
1789
+ // A provider confirmation is our row read back to us, and a credit is
1790
+ // money arriving, so neither closes a payout. The match law stays the
1791
+ // author's call.
1792
+ for (const [action, definition] of reconcilingEntries) {
1793
+ (definition.reconcile ?? []).forEach((reconcile, index) => {
1794
+ if (reconcile.evidence === "statement_line" &&
1795
+ reconcile.direction === "debit") {
1796
+ return;
1797
+ }
1798
+ add([...base, "actions", action, "reconcile", index, "evidence"], `payout-owning instrument ${instrument.id} action ${action} must reconcile against a debit statement_line; found ${reconcile.direction} ${reconcile.evidence}`, "UDL5005");
1799
+ });
1800
+ }
1801
+ }
1802
+ const soleReconciles = reconcilingEntries.length === 1
1803
+ ? reconcilingEntries[0]?.[1].reconcile
1027
1804
  : undefined;
1028
- if (settlementRequirement) {
1029
- for (const [verb, definition] of payoutEntries) {
1030
- if (definition.payout?.capture === settlementRequirement.payoutRef) {
1805
+ if (soleReconciles) {
1806
+ const expected = new Set(soleReconciles.map((reconcile) => reconcile.counterpartyRef));
1807
+ for (const [action, definition] of payoutEntries) {
1808
+ if (definition.payout && expected.has(definition.payout.capture)) {
1031
1809
  continue;
1032
1810
  }
1033
- add([...base, "verbs", verb, "payout", "capture"], `verb ${verb} payout capture ${definition.payout?.capture} is not covered by settlement payoutRef ${settlementRequirement.payoutRef}`);
1811
+ add([...base, "actions", action, "payout", "capture"], `action ${action} payout capture ${definition.payout?.capture} is expected by no reconcile`, "UDL5005");
1034
1812
  }
1035
1813
  }
1036
1814
  const reservedRefs = new Set([
1037
- ...Object.values(noun.verbs).flatMap((verb) => Object.keys(verb.captureInput ?? {})),
1038
- ...Object.values(noun.verbs).flatMap((verb) => [...verb.steps, ...verb.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
1039
- ...Object.values(noun.verbs).flatMap((verb) => verb.signedSum
1815
+ ...Object.values(instrument.actions).flatMap((action) => Object.keys(action.captureInput ?? {})),
1816
+ ...Object.values(instrument.actions).flatMap((action) => [...action.steps, ...action.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
1817
+ ...Object.values(instrument.actions).flatMap((action) => action.signedSum
1818
+ ? [
1819
+ action.signedSum.amountRef,
1820
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
1821
+ ]
1822
+ : []),
1823
+ ...Object.values(instrument.actions).flatMap((action) => action.distribute ? [action.distribute.amountRef] : []),
1824
+ ...Object.values(instrument.actions).flatMap((action) => action.remainder
1040
1825
  ? [
1041
- verb.signedSum.amountRef,
1042
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
1826
+ action.remainder.amountRef,
1827
+ ...(action.remainder.accumulateRef
1828
+ ? [action.remainder.accumulateRef]
1829
+ : []),
1043
1830
  ]
1044
1831
  : []),
1045
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1046
- ...(noun.subject ? ["subject"] : []),
1832
+ ...quoteRefKeys(instrument),
1833
+ ...(instrument.subject ? ["subject"] : []),
1047
1834
  ]);
1048
- const moneyWriters = moneyRefWriters(noun);
1835
+ const moneyWriters = moneyRefWriters(instrument);
1049
1836
  const payoutWriters = new Map();
1050
- for (const [verb, definition] of Object.entries(noun.verbs)) {
1837
+ for (const [action, definition] of Object.entries(instrument.actions)) {
1051
1838
  if (!definition.payout)
1052
1839
  continue;
1053
1840
  const payout = definition.payout;
1054
- const payoutBase = [...base, "verbs", verb, "payout"];
1055
- if (verb === "create") {
1056
- add(payoutBase, "create cannot declare a payout intent");
1841
+ const payoutBase = [...base, "actions", action, "payout"];
1842
+ if (action === "create") {
1843
+ add(payoutBase, "create cannot declare a payout intent", "UDL5005");
1057
1844
  }
1058
1845
  if (definition.steps.length > 0 || definition.moves.length > 0) {
1059
- add(payoutBase, `verb ${verb} payout intent cannot combine with kernel steps or moves`);
1846
+ add(payoutBase, `action ${action} payout intent cannot combine with kernel steps or moves`, "UDL5005");
1060
1847
  }
1061
1848
  const prior = payoutWriters.get(payout.capture) ?? [];
1062
1849
  if (reservedRefs.has(payout.capture) || prior.length > 0) {
1063
- add([...payoutBase, "capture"], `payout capture ${payout.capture} collides with an existing noun ref key`);
1850
+ add([...payoutBase, "capture"], `payout capture ${payout.capture} collides with an existing instrument ref key`, "UDL5005");
1064
1851
  }
1065
- payoutWriters.set(payout.capture, [...prior, verb]);
1852
+ payoutWriters.set(payout.capture, [...prior, action]);
1066
1853
  const amount = payout.amount;
1067
1854
  const [scope, key] = amount.split(".");
1068
1855
  const amountWriters = moneyWriters.get(key) ?? [];
1069
1856
  const amountDeclared = scope === "fields"
1070
- ? isMoneySchema(noun.fields[key] ?? {})
1857
+ ? isMoneySchema(instrument.fields[key] ?? {})
1071
1858
  : scope === "refs" && amountWriters.length > 0;
1072
1859
  if (!amountDeclared) {
1073
- add([...payoutBase, "amount"], `payout amount ${amount} must name a declared money field or money ref`);
1860
+ add([...payoutBase, "amount"], `payout amount ${amount} must name a declared money field or money ref`, "UDL5005");
1074
1861
  }
1075
1862
  else if (scope === "refs" &&
1076
- !amountWriters.includes(verb) &&
1863
+ !amountWriters.includes(action) &&
1077
1864
  !amountWriters.includes("create") &&
1078
- !verbGroupDominatesReader(noun, amountWriters, verb)) {
1079
- add([...payoutBase, "amount"], `payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`);
1865
+ !actionGroupDominatesReader(instrument, amountWriters, action)) {
1866
+ add([...payoutBase, "amount"], `payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`, "UDL5005");
1080
1867
  }
1081
- const currency = noun.fields[payout.currencyField];
1868
+ const currency = instrument.fields[payout.currencyField];
1082
1869
  if (!currency || !isCurrencySchema(currency)) {
1083
- add([...payoutBase, "currencyField"], `payout currencyField ${payout.currencyField} must name a currency field`);
1870
+ add([...payoutBase, "currencyField"], `payout currencyField ${payout.currencyField} must name a currency field`, "UDL5005");
1084
1871
  }
1085
- const source = noun.fields[payout.sourceAccountField];
1872
+ const source = instrument.fields[payout.sourceAccountField];
1086
1873
  if (!source || !references.accepts(source, "acct")) {
1087
- add([...payoutBase, "sourceAccountField"], `payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`);
1874
+ add([...payoutBase, "sourceAccountField"], `payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`, "UDL5005");
1088
1875
  }
1089
- const beneficiary = noun.fields[payout.beneficiaryField];
1876
+ const beneficiary = instrument.fields[payout.beneficiaryField];
1090
1877
  if (!beneficiary || !references.accepts(beneficiary, "ben")) {
1091
- add([...payoutBase, "beneficiaryField"], `payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`);
1878
+ add([...payoutBase, "beneficiaryField"], `payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`, "UDL5005");
1092
1879
  }
1093
- const beneficiaryPartyField = noun.parties?.beneficiary;
1880
+ const beneficiaryPartyField = instrument.parties?.beneficiary;
1094
1881
  if (!beneficiaryPartyField) {
1095
- add([...payoutBase, "beneficiaryPartyField"], "payout requires parties.beneficiary to bind its destination party");
1882
+ add([...payoutBase, "beneficiaryPartyField"], "payout requires parties.beneficiary to bind its destination party", "UDL5005");
1096
1883
  }
1097
1884
  else if (payout.beneficiaryPartyField !== beneficiaryPartyField) {
1098
- add([...payoutBase, "beneficiaryPartyField"], `payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`);
1885
+ add([...payoutBase, "beneficiaryPartyField"], `payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`, "UDL5005");
1099
1886
  }
1100
1887
  }
1101
- const settlementCaptures = new Set();
1102
- for (const [verb, definition] of Object.entries(noun.verbs)) {
1103
- if (!definition.requiresSettlement)
1888
+ validateReconciles(instrument, base, instruments, reservedRefs, payoutWriters, moneyWriters, references, add);
1889
+ }
1890
+ /**
1891
+ * The five reconcile laws. An expectation names money the instrument can
1892
+ * describe but cannot yet see, so every part of it has to be checkable before
1893
+ * an instance exists: the expected fields carry money, one source answers one
1894
+ * counterparty row, the window closes under a sweep that is actually declared,
1895
+ * the forgiveness is bounded by a dial, and the unmatched case has a capped
1896
+ * child to land in.
1897
+ */
1898
+ function validateReconciles(instrument, base, instruments, reservedRefs, payoutWriters, moneyWriters, references, add) {
1899
+ const captures = new Set();
1900
+ for (const [action, definition] of Object.entries(instrument.actions)) {
1901
+ if (!definition.reconcile)
1104
1902
  continue;
1105
- const requirement = definition.requiresSettlement;
1106
- const requirementBase = [
1107
- ...base,
1108
- "verbs",
1109
- verb,
1110
- "requiresSettlement",
1111
- ];
1112
- if (reservedRefs.has(requirement.capture) ||
1113
- payoutWriters.has(requirement.capture) ||
1114
- settlementCaptures.has(requirement.capture)) {
1115
- add([...requirementBase, "capture"], `settlement capture ${requirement.capture} collides with an existing noun ref key`);
1116
- }
1117
- settlementCaptures.add(requirement.capture);
1118
- const writers = payoutWriters.get(requirement.payoutRef) ?? [];
1119
- if (writers.length === 0) {
1120
- add([...requirementBase, "payoutRef"], `requiresSettlement payoutRef ${requirement.payoutRef} is not captured by a payout intent`);
1121
- }
1122
- else if (!verbGroupDominatesReader(noun, writers, verb)) {
1123
- add(requirementBase, `requiresSettlement can read ${requirement.payoutRef} before payout writers ${writers.join(" or ")}`);
1124
- }
1125
- if (!noun.lifecycle.transitions[verb]) {
1126
- add(requirementBase, "requiresSettlement needs a lifecycle transition and cannot run on create");
1903
+ const counterparties = new Set();
1904
+ definition.reconcile.forEach((reconcile, index) => {
1905
+ const path = [...base, "actions", action, "reconcile", index];
1906
+ // Law: the expectation is money this instrument can already describe,
1907
+ // read the same way a payout intent reads its amount.
1908
+ const [amountScope, amountKey] = reconcile.amount.split(".");
1909
+ const amountDeclared = amountScope === "fields"
1910
+ ? isMoneySchema(instrument.fields[amountKey] ?? {})
1911
+ : amountScope === "refs" &&
1912
+ (moneyWriters.get(amountKey) ?? []).length > 0;
1913
+ if (!amountDeclared) {
1914
+ add([...path, "amount"], `reconcile amount ${reconcile.amount} must name a declared money field or money ref`, "UDL5005");
1915
+ }
1916
+ const currency = instrument.fields[reconcile.currencyField];
1917
+ if (!currency) {
1918
+ add([...path, "currencyField"], `reconcile expects unknown field ${reconcile.currencyField}`, "UDL5005");
1919
+ }
1920
+ else if (currency.type !== "string" ||
1921
+ !instrument.required.includes(reconcile.currencyField)) {
1922
+ add([...path, "currencyField"], `${instrument.id}.${reconcile.currencyField} must be a required text field to carry the expected currency`, "UDL5005");
1923
+ }
1924
+ // Law: one evidence source answers one counterparty row. Two
1925
+ // expectations over the same row would match the same money twice.
1926
+ if (counterparties.has(reconcile.counterpartyRef)) {
1927
+ add([...path, "counterpartyRef"], `action ${action} expects ${reconcile.counterpartyRef} twice; one counterparty row answers one reconcile`, "UDL5005");
1928
+ }
1929
+ counterparties.add(reconcile.counterpartyRef);
1930
+ const writers = payoutWriters.get(reconcile.counterpartyRef) ?? [];
1931
+ if (reconcile.evidence === "statement_line") {
1932
+ if (writers.length === 0) {
1933
+ add([...path, "counterpartyRef"], `reconcile counterpartyRef ${reconcile.counterpartyRef} is not captured by a payout intent`, "UDL5005");
1934
+ }
1935
+ else if (!actionGroupDominatesReader(instrument, writers, action)) {
1936
+ add(path, `reconcile can read ${reconcile.counterpartyRef} before payout writers ${writers.join(" or ")}`, "UDL5005");
1937
+ }
1938
+ }
1939
+ // Law: the window is a fixed duration or a stored deadline, and the due
1940
+ // sweep that closes it is declared on the same action. A window nothing
1941
+ // fires is a wait with no end.
1942
+ const due = definition.due;
1943
+ if ("offset" in reconcile.within) {
1944
+ const offset = reconcile.within.offset;
1945
+ if (fixedIsoDurationMs(offset) === null) {
1946
+ add([...path, "within", "offset"], `reconcile window ${offset} is not a fixed ISO-8601 duration`, "UDL5005");
1947
+ }
1948
+ else if (!due || due.offset !== offset) {
1949
+ add([...path, "within", "offset"], `reconcile window ${offset} is closed by no due condition on ${action}`, "UDL5005");
1950
+ }
1951
+ }
1952
+ else {
1953
+ const deadline = instrument.fields[reconcile.within.field];
1954
+ if (!deadline || !isDateTimeFormat(deadline.format)) {
1955
+ add([...path, "within", "field"], `reconcile window ${reconcile.within.field} must be a stored date-time field`, "UDL5005");
1956
+ }
1957
+ else if (!due ||
1958
+ due.field !== reconcile.within.field ||
1959
+ due.offset !== undefined) {
1960
+ add([...path, "within", "field"], `reconcile window ${reconcile.within.field} is closed by no due condition on ${action}`, "UDL5005");
1961
+ }
1962
+ }
1963
+ // Law: tolerance never exceeds its dial. The authored number is the
1964
+ // forgiveness; the dial is the ceiling a tenant may not raise past.
1965
+ const match = reconcile.match;
1966
+ if (match.law === "tolerance") {
1967
+ const dial = instrument.dials?.find((candidate) => candidate.key === match.dial);
1968
+ if (!dial || dial.kind !== "reconcile_tolerance") {
1969
+ add([...path, "match", "dial"], `reconcile tolerance names no reconcile_tolerance dial ${match.dial}`, "UDL5005");
1970
+ }
1971
+ else if (match.minorUnits > dial.maxMinorUnits) {
1972
+ add([...path, "match", "minorUnits"], `reconcile tolerance ${match.minorUnits} exceeds dial ${dial.key} ceiling ${dial.maxMinorUnits}`, "UDL5005");
1973
+ }
1974
+ }
1975
+ // Law: matched or exception. The break lands in a child of this
1976
+ // instrument that points back at it, never in a status nobody reads.
1977
+ const child = instruments.get(reconcile.exception.childInstrumentId);
1978
+ if (!child) {
1979
+ add([...path, "exception", "childInstrumentId"], `reconcile raises unknown exception instrument ${reconcile.exception.childInstrumentId}`, "UDL5007");
1980
+ }
1981
+ else {
1982
+ const exception = reconcile.exception;
1983
+ for (const problem of reconcileExceptionChildProblems(instrument.idPrefix, child, exception, references)) {
1984
+ const field = problem === "UDL5009" || problem === "UDL5010"
1985
+ ? "amountField"
1986
+ : problem === "UDL5011" || problem === "UDL5012"
1987
+ ? "reasonField"
1988
+ : "refField";
1989
+ add([...path, "exception", field], reconcileExceptionProblemMessage(problem, instrument, child, exception), problem);
1990
+ }
1991
+ }
1992
+ if (reservedRefs.has(reconcile.capture) ||
1993
+ payoutWriters.has(reconcile.capture) ||
1994
+ captures.has(reconcile.capture)) {
1995
+ add([...path, "capture"], `reconcile capture ${reconcile.capture} collides with an existing instrument ref key`, "UDL5005");
1996
+ }
1997
+ captures.add(reconcile.capture);
1998
+ });
1999
+ if (!instrument.lifecycle.transitions[action]) {
2000
+ add([...base, "actions", action, "reconcile"], "a reconciling action needs a lifecycle transition and cannot run on create", "UDL5005");
1127
2001
  }
1128
2002
  const callerFacets = [
1129
2003
  definition.port ? "port" : undefined,
1130
- definition.publicIntent ? "publicIntent" : undefined,
2004
+ definition.publicAction ? "publicAction" : undefined,
1131
2005
  definition.input ? "input" : undefined,
1132
2006
  definition.captureInput ? "captureInput" : undefined,
1133
- definition.due ? "due" : undefined,
1134
2007
  definition.deadline ? "deadline" : undefined,
1135
2008
  ].filter((facet) => facet !== undefined);
1136
2009
  for (const facet of callerFacets) {
1137
- add([...base, "verbs", verb, facet], `requiresSettlement is system-only and cannot declare ${facet}`);
2010
+ add([...base, "actions", action, facet], `a reconciling action is system-only and cannot declare ${facet}`, "UDL5005");
1138
2011
  }
1139
2012
  if (definition.steps.length > 0) {
1140
- add([...base, "verbs", verb, "steps"], "requiresSettlement cannot add kernel steps");
2013
+ add([...base, "actions", action, "steps"], "a reconciling action cannot add kernel steps", "UDL5005");
1141
2014
  }
1142
2015
  if (definition.moves.length > 0) {
1143
- add([...base, "verbs", verb, "moves"], "requiresSettlement cannot move money");
2016
+ add([...base, "actions", action, "moves"], "a reconciling action cannot move money", "UDL5005");
1144
2017
  }
1145
2018
  }
1146
2019
  }
1147
- function moneyRefWriters(noun) {
2020
+ function moneyRefWriters(instrument) {
1148
2021
  const writers = new Map();
1149
- for (const [verbName, verb] of Object.entries(noun.verbs)) {
2022
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
1150
2023
  const refs = [
1151
- ...(verb.signedSum
2024
+ ...(action.signedSum
1152
2025
  ? [
1153
- verb.signedSum.amountRef,
1154
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
2026
+ action.signedSum.amountRef,
2027
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
1155
2028
  ]
1156
2029
  : []),
1157
- ...[...verb.steps, ...verb.moves].flatMap((step) => Object.entries(step.capture ?? {}).flatMap(([ref, output]) => output === "postedAmount" ? [ref] : [])),
2030
+ ...[...action.steps, ...action.moves].flatMap((step) => Object.entries(step.capture ?? {}).flatMap(([ref, output]) => output === "postedAmount" ? [ref] : [])),
1158
2031
  ];
1159
2032
  for (const ref of refs) {
1160
- writers.set(ref, [...(writers.get(ref) ?? []), verbName]);
2033
+ writers.set(ref, [...(writers.get(ref) ?? []), actionName]);
1161
2034
  }
1162
2035
  }
1163
2036
  return writers;
@@ -1169,81 +2042,81 @@ function moneyRefWriters(noun) {
1169
2042
  * exact mirror of the Hyperscale contract registry's rules, so a document that loads
1170
2043
  * open-grammar clean also loads registry clean.
1171
2044
  */
1172
- function validateGateShape(noun, verb, gate, target, base, add) {
1173
- if (gate.unique && verb !== "create") {
1174
- add([...base, "unique"], "unique is a create admission gate");
2045
+ function validateGateShape(instrument, action, gate, target, base, add) {
2046
+ if (gate.unique && action !== "create") {
2047
+ add([...base, "unique"], "unique is a create admission gate", "UDL5001");
1175
2048
  }
1176
- if (gate.bind && verb !== "create") {
1177
- add([...base, "bind"], "bind derives fields at create only");
2049
+ if (gate.bind && action !== "create") {
2050
+ add([...base, "bind"], "bind derives fields at create only", "UDL5001");
1178
2051
  }
1179
2052
  if (gate.bind && Object.keys(gate.bind).length === 0) {
1180
- add([...base, "bind"], "bind must not be empty");
2053
+ add([...base, "bind"], "bind must not be empty", "UDL5001");
1181
2054
  }
1182
2055
  if (gate.match && Object.keys(gate.match).length === 0) {
1183
- add([...base, "match"], "match must not be empty");
2056
+ add([...base, "match"], "match must not be empty", "UDL5001");
1184
2057
  }
1185
- if (gate.optional && noun.required.includes(gate.field)) {
1186
- add([...base, "optional"], `optional declares an opt-out on ${gate.field}, which required lists`);
2058
+ if (gate.optional && instrument.required.includes(gate.field)) {
2059
+ add([...base, "optional"], `optional declares an opt-out on ${gate.field}, which required lists`, "UDL5001");
1187
2060
  }
1188
2061
  for (const [key, path] of Object.entries(gate.bind ?? {})) {
1189
- if (gate.optional && noun.required.includes(key)) {
1190
- add([...base, "bind", key], `an optional reference can only bind optional fields; required lists ${key}`);
2062
+ if (gate.optional && instrument.required.includes(key)) {
2063
+ add([...base, "bind", key], `an optional reference can only bind optional fields; required lists ${key}`, "UDL5001");
1191
2064
  }
1192
- if (!Object.hasOwn(noun.fields, key)) {
1193
- add([...base, "bind", key], `bind targets unknown field ${key}`);
2065
+ if (!Object.hasOwn(instrument.fields, key)) {
2066
+ add([...base, "bind", key], `bind targets unknown field ${key}`, "UDL5001");
1194
2067
  }
1195
2068
  if (key === gate.field) {
1196
- add([...base, "bind", key], "bind cannot target the gate field itself");
2069
+ add([...base, "bind", key], "bind cannot target the gate field itself", "UDL5001");
1197
2070
  }
1198
- if (noun.update?.fields.includes(key)) {
1199
- add([...base, "bind", key], `bind targets ${key}, which update declares mutable`);
2071
+ if (instrument.update?.fields.includes(key)) {
2072
+ add([...base, "bind", key], `bind targets ${key}, which update declares mutable`, "UDL5001");
1200
2073
  }
1201
- if (path === "nounInstanceId") {
1202
- add([...base, "bind", key], "the gate field already carries the referenced id");
2074
+ if (path === "instrumentInstanceId") {
2075
+ add([...base, "bind", key], "the gate field already carries the referenced id", "UDL5001");
1203
2076
  }
1204
2077
  else if (!referencedPathDeclared(target, path)) {
1205
- add([...base, "bind", key], `bind reads ${path}, which ${target.id} does not declare`);
2078
+ add([...base, "bind", key], `bind reads ${path}, which ${target.id} does not declare`, "UDL5001");
1206
2079
  }
1207
2080
  }
1208
2081
  for (const [localPath, path] of Object.entries(gate.match ?? {})) {
1209
- const localOk = verb === "create"
2082
+ const localOk = action === "create"
1210
2083
  ? localPath.startsWith("fields.") &&
1211
- Object.hasOwn(noun.fields, localPath.slice("fields.".length))
1212
- : localPath === "nounInstanceId" ||
2084
+ Object.hasOwn(instrument.fields, localPath.slice("fields.".length))
2085
+ : localPath === "instrumentInstanceId" ||
1213
2086
  (localPath.startsWith("fields.") &&
1214
- Object.hasOwn(noun.fields, localPath.slice("fields.".length))) ||
2087
+ Object.hasOwn(instrument.fields, localPath.slice("fields.".length))) ||
1215
2088
  (localPath.startsWith("refs.") &&
1216
- declaredRefKeys(noun).has(localPath.slice("refs.".length)));
2089
+ declaredRefKeys(instrument).has(localPath.slice("refs.".length)));
1217
2090
  if (!localOk) {
1218
- add([...base, "match", localPath], verb === "create"
1219
- ? "a create match may only read the noun's own declared fields"
1220
- : `match reads unknown local path ${localPath}`);
2091
+ add([...base, "match", localPath], action === "create"
2092
+ ? "a create match may only read the instrument's own declared fields"
2093
+ : `match reads unknown local path ${localPath}`, "UDL5001");
1221
2094
  }
1222
2095
  if (!referencedPathDeclared(target, path)) {
1223
- add([...base, "match", localPath], `match reads ${path}, which ${target.id} does not declare`);
2096
+ add([...base, "match", localPath], `match reads ${path}, which ${target.id} does not declare`, "UDL5001");
1224
2097
  }
1225
2098
  }
1226
2099
  }
1227
- /** Every `refs.<key>` a noun's instances can legitimately carry. */
1228
- function declaredRefKeys(noun) {
2100
+ /** Every `refs.<key>` a instrument's instances can legitimately carry. */
2101
+ function declaredRefKeys(instrument) {
1229
2102
  return new Set([
1230
- ...Object.values(noun.verbs).flatMap((verb) => verb.payout ? [verb.payout.capture] : []),
1231
- ...Object.values(noun.verbs).flatMap((verb) => verb.requiresSettlement ? [verb.requiresSettlement.capture] : []),
1232
- ...Object.values(noun.verbs).flatMap((verb) => Object.keys(verb.captureInput ?? {})),
1233
- ...Object.values(noun.verbs).flatMap((verb) => [...verb.steps, ...verb.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
1234
- ...Object.values(noun.verbs).flatMap((verb) => verb.signedSum
2103
+ ...Object.values(instrument.actions).flatMap((action) => action.payout ? [action.payout.capture] : []),
2104
+ ...Object.values(instrument.actions).flatMap((action) => (action.reconcile ?? []).map((reconcile) => reconcile.capture)),
2105
+ ...Object.values(instrument.actions).flatMap((action) => Object.keys(action.captureInput ?? {})),
2106
+ ...Object.values(instrument.actions).flatMap((action) => [...action.steps, ...action.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
2107
+ ...Object.values(instrument.actions).flatMap((action) => action.signedSum
1235
2108
  ? [
1236
- verb.signedSum.amountRef,
1237
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
2109
+ action.signedSum.amountRef,
2110
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
1238
2111
  ]
1239
2112
  : []),
1240
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1241
- ...(noun.subject ? ["subject"] : []),
2113
+ ...quoteRefKeys(instrument),
2114
+ ...(instrument.subject ? ["subject"] : []),
1242
2115
  ]);
1243
2116
  }
1244
2117
  /** Whether a bind/match referenced path names declared target structure. */
1245
2118
  function referencedPathDeclared(target, path) {
1246
- if (path === "nounInstanceId")
2119
+ if (path === "instrumentInstanceId")
1247
2120
  return true;
1248
2121
  if (path.startsWith("fields.")) {
1249
2122
  return Object.hasOwn(target.fields, path.slice("fields.".length));
@@ -1253,35 +2126,35 @@ function referencedPathDeclared(target, path) {
1253
2126
  }
1254
2127
  return false;
1255
2128
  }
1256
- function validateStep(noun, verb, step, base, add) {
2129
+ function validateStep(instrument, action, step, base, add) {
1257
2130
  if (Object.keys(step.bind).length === 0) {
1258
- add([...base, "bind"], "a kernel step must bind at least one value");
2131
+ add([...base, "bind"], "a kernel step must bind at least one value", "UDL5008");
1259
2132
  }
1260
2133
  if (step.capture && Object.keys(step.capture).length === 0) {
1261
- add([...base, "capture"], "capture must not be empty");
2134
+ add([...base, "capture"], "capture must not be empty", "UDL5008");
1262
2135
  }
1263
- const inputFields = recordValue(verb.input?.properties);
2136
+ const inputFields = recordValue(action.input?.properties);
1264
2137
  for (const [field, binding] of Object.entries(step.bind)) {
1265
2138
  if (binding.from === "const")
1266
2139
  continue;
1267
2140
  if (binding.from === "input") {
1268
2141
  const root = binding.path.split(".")[0];
1269
2142
  if (!Object.hasOwn(inputFields, root)) {
1270
- add([...base, "bind", field, "path"], `input binding references undeclared verb input field ${root}`);
2143
+ add([...base, "bind", field, "path"], `input binding references undeclared action input field ${root}`, "UDL5008");
1271
2144
  }
1272
2145
  continue;
1273
2146
  }
1274
2147
  if (binding.path.startsWith("fields.")) {
1275
2148
  const root = binding.path.split(".")[1];
1276
- if (!Object.hasOwn(noun.fields, root)) {
1277
- add([...base, "bind", field, "path"], `instance binding references unknown noun field ${root}`);
2149
+ if (!Object.hasOwn(instrument.fields, root)) {
2150
+ add([...base, "bind", field, "path"], `instance binding references unknown instrument field ${root}`, "UDL5008");
1278
2151
  }
1279
2152
  continue;
1280
2153
  }
1281
- if (binding.path !== "nounInstanceId" &&
1282
- binding.path !== "workspaceId" &&
2154
+ if (binding.path !== "instrumentInstanceId" &&
2155
+ binding.path !== "productId" &&
1283
2156
  !binding.path.startsWith("refs.")) {
1284
- add([...base, "bind", field, "path"], `instance binding path ${binding.path} must read nounInstanceId, workspaceId, fields.*, or refs.*`);
2157
+ add([...base, "bind", field, "path"], `instance binding path ${binding.path} must read instrumentInstanceId, productId, fields.*, or refs.*`, "UDL5008");
1285
2158
  }
1286
2159
  }
1287
2160
  if (step.operation === "account.freeze" ||
@@ -1291,11 +2164,11 @@ function validateStep(noun, verb, step, base, add) {
1291
2164
  // and a monetary leg on a freeze step is a contradiction in terms.
1292
2165
  const accountId = step.bind.accountId;
1293
2166
  if (!accountId || accountId.from !== "instance") {
1294
- add([...base, "bind", "accountId"], `${step.operation} must bind accountId from an instance path`);
2167
+ add([...base, "bind", "accountId"], `${step.operation} must bind accountId from an instance path`, "UDL5008");
1295
2168
  }
1296
2169
  for (const monetary of ["amount", "currency"]) {
1297
2170
  if (Object.hasOwn(step.bind, monetary)) {
1298
- add([...base, "bind", monetary], `${step.operation} must not bind ${monetary}`);
2171
+ add([...base, "bind", monetary], `${step.operation} must not bind ${monetary}`, "UDL5008");
1299
2172
  }
1300
2173
  }
1301
2174
  }
@@ -1306,90 +2179,168 @@ function validateStep(noun, verb, step, base, add) {
1306
2179
  if (source?.from === "instance" &&
1307
2180
  destination?.from === "instance" &&
1308
2181
  source.path === destination.path) {
1309
- add([...base, "bind", "destinationAccountId"], "transfer source and destination must be different accounts");
2182
+ add([...base, "bind", "destinationAccountId"], "transfer source and destination must be different accounts", "UDL4001");
1310
2183
  }
1311
2184
  }
1312
2185
  }
1313
- function validateUnwind(noun, base, references, add) {
1314
- const unwind = noun.unwind;
1315
- if (!unwind)
1316
- return;
1317
- if (unwind.quote === unwind.confirm) {
1318
- add([...base, "unwind", "confirm"], "unwind quote and confirm verbs must differ");
1319
- }
1320
- for (const [fieldName, field] of [
1321
- ["refundableField", unwind.refundableField],
1322
- ["refundDestinationField", unwind.refundDestinationField],
1323
- ]) {
1324
- if (!Object.hasOwn(noun.fields, field)) {
1325
- add([...base, "unwind", fieldName], `unwind references unknown field ${field}`);
2186
+ /**
2187
+ * Every ref a quoting action seeds: the charge and the net the author named,
2188
+ * plus the expiry and fingerprint the machinery derives from the net ref.
2189
+ */
2190
+ function quoteRefKeys(instrument) {
2191
+ return Object.values(instrument.actions).flatMap((action) => action.quote ? quoteSeededRefKeys(action.quote) : []);
2192
+ }
2193
+ function validateQuoteCommit(instrument, base, references, add) {
2194
+ const quotingActions = Object.entries(instrument.actions).filter(([, action]) => action.quote);
2195
+ for (const [actionName, action] of quotingActions) {
2196
+ const quote = action.quote;
2197
+ if (!quote)
2198
+ continue;
2199
+ const quoteBase = [...base, "actions", actionName, "quote"];
2200
+ if (action.earnable) {
2201
+ add([...base, "actions", actionName, "earnable"], `quoting action ${actionName} prices a refund and cannot be earnable`, "UDL5006");
2202
+ }
2203
+ for (const [slot, field] of [
2204
+ ["baseField", quote.baseField],
2205
+ ["netDestinationField", quote.netDestinationField],
2206
+ ...quote.fixes.map((fixed, index) => [`fixes.${index}`, fixed]),
2207
+ ]) {
2208
+ if (Object.hasOwn(instrument.fields, field))
2209
+ continue;
2210
+ add([...quoteBase, slot], `quote references unknown field ${field}`, "UDL5006");
2211
+ }
2212
+ const priced = instrument.fields[quote.baseField];
2213
+ if (priced && !isMoneySchema(priced)) {
2214
+ add([...quoteBase, "baseField"], "baseField must be a money field", "UDL5006");
2215
+ }
2216
+ const destination = instrument.fields[quote.netDestinationField];
2217
+ if (destination && !references.accepts(destination, "acct")) {
2218
+ add([...quoteBase, "netDestinationField"], "netDestinationField must be an account-id field", "UDL5006");
2219
+ }
2220
+ for (const [slot, label, field] of [
2221
+ ["baseField", "base field", quote.baseField],
2222
+ [
2223
+ "netDestinationField",
2224
+ "net destination field",
2225
+ quote.netDestinationField,
2226
+ ],
2227
+ ]) {
2228
+ if (quote.fixes.includes(field))
2229
+ continue;
2230
+ add([...quoteBase, slot], `quoting action ${actionName} must freeze its ${label} ${field}`, "UDL5006");
1326
2231
  }
1327
- }
1328
- const refundable = noun.fields[unwind.refundableField];
1329
- if (refundable && !isMoneySchema(refundable)) {
1330
- add([...base, "unwind", "refundableField"], "refundableField must be a money field");
1331
- }
1332
- const destination = noun.fields[unwind.refundDestinationField];
1333
- if (destination && !references.accepts(destination, "acct")) {
1334
- add([...base, "unwind", "refundDestinationField"], "refundDestinationField must be an account-id field");
1335
- }
1336
- if (unwind.beforeField) {
1337
- const before = noun.fields[unwind.beforeField];
1338
- if (!before) {
1339
- add([...base, "unwind", "beforeField"], `unwind references unknown field ${unwind.beforeField}`);
2232
+ for (const field of action.updates ?? []) {
2233
+ if (!quote.fixes.includes(field))
2234
+ continue;
2235
+ add([...base, "actions", actionName, "updates"], `quoting action ${actionName} freezes ${field} and writes it in the same action`, "UDL5006");
1340
2236
  }
1341
- else if (!isDateTimeFormat(before.format)) {
1342
- add([...base, "unwind", "beforeField"], "beforeField must be a date-time field");
2237
+ if (quote.anchorField !== undefined) {
2238
+ const anchor = instrument.fields[quote.anchorField];
2239
+ if (!anchor) {
2240
+ add([...quoteBase, "anchorField"], `quote references unknown field ${quote.anchorField}`, "UDL5006");
2241
+ }
2242
+ else if (!isDateTimeFormat(anchor.format)) {
2243
+ add([...quoteBase, "anchorField"], "anchorField must be a date-time field", "UDL5006");
2244
+ }
2245
+ else if (!instrument.required.includes(quote.anchorField)) {
2246
+ add([...quoteBase, "anchorField"], "anchorField must be required", "UDL5006");
2247
+ }
2248
+ }
2249
+ if ("offset" in quote.expires) {
2250
+ if (fixedIsoDurationMs(quote.expires.offset) === null) {
2251
+ add([...quoteBase, "expires", "offset"], "the offer's life must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL5006");
2252
+ }
2253
+ }
2254
+ else {
2255
+ const deadline = instrument.fields[quote.expires.field];
2256
+ if (!deadline) {
2257
+ add([...quoteBase, "expires", "field"], `quote references unknown field ${quote.expires.field}`, "UDL5006");
2258
+ }
2259
+ else if (!isDateTimeFormat(deadline.format)) {
2260
+ add([...quoteBase, "expires", "field"], "the offer's deadline field must be a date-time field", "UDL5006");
2261
+ }
2262
+ else if (!instrument.required.includes(quote.expires.field)) {
2263
+ add([...quoteBase, "expires", "field"], "the offer's deadline field must be required", "UDL5006");
2264
+ }
1343
2265
  }
1344
- else if (!noun.required.includes(unwind.beforeField)) {
1345
- add([...base, "unwind", "beforeField"], "beforeField must be required");
2266
+ addDuplicateIssues(quote.fixes, [...quoteBase, "fixes"], "frozen field", add);
2267
+ const offsets = quote.charges.flatMap((tier) => tier.withinOffset ? [tier.withinOffset] : []);
2268
+ addDuplicateIssues(offsets, [...quoteBase, "charges"], "charge offset", add);
2269
+ if (quote.charges.filter((tier) => tier.withinOffset === undefined).length > 1) {
2270
+ add([...quoteBase, "charges"], "a quote may declare at most one floor tier", "UDL5006");
2271
+ }
2272
+ quote.charges.forEach((tier, index) => {
2273
+ if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
2274
+ add([...quoteBase, "charges", index, "withinOffset"], "withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL5006");
2275
+ }
2276
+ });
2277
+ const committing = Object.entries(instrument.actions).filter(([, candidate]) => candidate.commit === actionName);
2278
+ if (committing.length !== 1) {
2279
+ add([...quoteBase], `quoting action ${actionName} must be committed by exactly one action, not ${committing.length}`, "UDL5006");
1346
2280
  }
1347
2281
  }
1348
- for (const [verbName, verb] of [
1349
- ["quote", unwind.quote],
1350
- ["confirm", unwind.confirm],
1351
- ]) {
1352
- const definition = noun.verbs[verb];
1353
- if (!definition) {
1354
- add([...base, "unwind", verbName], `unwind references unknown verb ${verb}`);
2282
+ const seededBy = new Map();
2283
+ for (const [actionName, action] of quotingActions) {
2284
+ const quote = action.quote;
2285
+ if (!quote)
1355
2286
  continue;
1356
- }
1357
- if (definition.earnable) {
1358
- add([...base, "verbs", verb, "earnable"], `unwind ${verbName} verb ${verb} is refund-shaped and cannot be earnable`);
2287
+ const quoteBase = [...base, "actions", actionName, "quote"];
2288
+ for (const [slot, label, key] of [
2289
+ ["chargeRef", "charge", quote.chargeRef],
2290
+ ["netRef", "net", quote.netRef],
2291
+ ["netRef", "expiry stamp", quoteExpiresAtRefKey(quote)],
2292
+ ["netRef", "frozen fingerprint", quoteFrozenRefKey(quote)],
2293
+ ]) {
2294
+ const owner = seededBy.get(key);
2295
+ if (owner === undefined) {
2296
+ seededBy.set(key, `${actionName} ${label}`);
2297
+ continue;
2298
+ }
2299
+ add([...quoteBase, slot], `quote ref ${key} is seeded twice: ${owner} and ${actionName} ${label}`, "UDL5006");
1359
2300
  }
1360
2301
  }
1361
- const confirm = noun.verbs[unwind.confirm];
1362
- if (confirm) {
1363
- const transfers = confirm.moves.filter((move) => move.operation === "internal_transfer.create");
1364
- const refund = transfers[0];
1365
- const amount = refund?.bind.amount;
1366
- const sourceBinding = refund?.bind.sourceAccountId;
1367
- const destinationBinding = refund?.bind.destinationAccountId;
2302
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
2303
+ const quotingName = action.commit;
2304
+ if (quotingName === undefined)
2305
+ continue;
2306
+ const commitBase = [...base, "actions", actionName, "commit"];
2307
+ if (quotingName === actionName) {
2308
+ add(commitBase, "an action cannot commit its own quote", "UDL5006");
2309
+ continue;
2310
+ }
2311
+ const quoting = instrument.actions[quotingName];
2312
+ if (!quoting) {
2313
+ add(commitBase, `commit references unknown action ${quotingName}`, "UDL5006");
2314
+ continue;
2315
+ }
2316
+ const quote = quoting.quote;
2317
+ if (!quote) {
2318
+ add(commitBase, `action ${quotingName} declares no quote to commit`, "UDL5006");
2319
+ continue;
2320
+ }
2321
+ if (action.earnable) {
2322
+ add([...base, "actions", actionName, "earnable"], `commit action ${actionName} spends a quoted refund and cannot be earnable`, "UDL5006");
2323
+ }
2324
+ const transfers = action.moves.filter((move) => move.operation === "internal_transfer.create");
2325
+ const net = transfers[0];
2326
+ const amount = net?.bind.amount;
2327
+ const sourceBinding = net?.bind.sourceAccountId;
2328
+ const destinationBinding = net?.bind.destinationAccountId;
1368
2329
  if (transfers.length !== 1 ||
1369
2330
  amount?.from !== "instance" ||
1370
- amount.path !== "refs.unwindRefund" ||
2331
+ amount.path !== `refs.${quote.netRef}` ||
1371
2332
  sourceBinding?.from !== "instance" ||
1372
2333
  destinationBinding?.from !== "instance" ||
1373
- destinationBinding.path !== `fields.${unwind.refundDestinationField}`) {
1374
- add([...base, "verbs", unwind.confirm, "moves"], `unwind confirm verb ${unwind.confirm} must contain exactly one internal transfer whose source comes from the noun instance, amount is refs.unwindRefund, and destination is fields.${unwind.refundDestinationField}`);
2334
+ destinationBinding.path !== `fields.${quote.netDestinationField}`) {
2335
+ add([...base, "actions", actionName, "moves"], `commit action ${actionName} must contain exactly one internal transfer whose source comes from the instrument instance, amount is refs.${quote.netRef}, and destination is fields.${quote.netDestinationField}`, "UDL5006");
1375
2336
  }
1376
2337
  }
1377
- const offsets = unwind.penalty.flatMap((tier) => tier.withinOffset ? [tier.withinOffset] : []);
1378
- addDuplicateIssues(offsets, [...base, "unwind", "penalty"], "penalty offset", add);
1379
- if (unwind.penalty.filter((tier) => tier.withinOffset === undefined).length > 1) {
1380
- add([...base, "unwind", "penalty"], "unwind may declare at most one floor tier");
1381
- }
1382
- unwind.penalty.forEach((tier, index) => {
1383
- if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
1384
- add([...base, "unwind", "penalty", index, "withinOffset"], "withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds");
1385
- }
1386
- });
1387
2338
  }
1388
- function validateAggregates(noun, base, nouns, references, add) {
1389
- const aggregates = noun.aggregateInvariants;
2339
+ function validateAggregates(instrument, base, instruments, references, add) {
2340
+ const aggregates = instrument.aggregateInvariants;
1390
2341
  if (!aggregates)
1391
2342
  return;
1392
- addDuplicateIssues(aggregates.map((aggregate) => `${aggregate.childNounId}:${aggregate.childRefField}:${aggregateMeasureKey(aggregate)}:${aggregate.parentField}`), [...base, "aggregateInvariants"], "aggregate invariant", add);
2343
+ addDuplicateIssues(aggregates.map((aggregate) => `${aggregate.childInstrumentId}:${aggregate.childRefField}:${aggregateMeasureKey(aggregate)}:${aggregate.parentField}`), [...base, "aggregateInvariants"], "aggregate invariant", add);
1393
2344
  aggregates.forEach((aggregate, aggregateIndex) => {
1394
2345
  const aggregateBase = [
1395
2346
  ...base,
@@ -1397,72 +2348,72 @@ function validateAggregates(noun, base, nouns, references, add) {
1397
2348
  aggregateIndex,
1398
2349
  ];
1399
2350
  const sum = "childField" in aggregate ? aggregate : undefined;
1400
- const parentField = noun.fields[aggregate.parentField];
2351
+ const parentField = instrument.fields[aggregate.parentField];
1401
2352
  if (!parentField) {
1402
- add([...aggregateBase, "parentField"], `aggregate references unknown parent field ${aggregate.parentField}`);
2353
+ add([...aggregateBase, "parentField"], `aggregate references unknown parent field ${aggregate.parentField}`, "UDL5004");
1403
2354
  }
1404
2355
  else if (sum && !isMoneySchema(parentField)) {
1405
- add([...aggregateBase, "parentField"], `${noun.id}.${aggregate.parentField} must be a money field`);
2356
+ add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} must be a money field`, "UDL5004");
1406
2357
  }
1407
2358
  else if (!sum &&
1408
2359
  (parentField.type !== "integer" ||
1409
- !noun.required.includes(aggregate.parentField))) {
1410
- add([...aggregateBase, "parentField"], `${noun.id}.${aggregate.parentField} must be a required integer field to cap a count`);
2360
+ !instrument.required.includes(aggregate.parentField))) {
2361
+ add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} must be a required integer field to cap a count`, "UDL5004");
1411
2362
  }
1412
- else if (noun.update?.fields.includes(aggregate.parentField)) {
1413
- add([...aggregateBase, "parentField"], `${noun.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`);
2363
+ else if (instrument.update?.fields.includes(aggregate.parentField)) {
2364
+ add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`, "UDL5004");
1414
2365
  }
1415
- const child = nouns.get(aggregate.childNounId);
2366
+ const child = instruments.get(aggregate.childInstrumentId);
1416
2367
  if (!child) {
1417
- add([...aggregateBase, "childNounId"], `aggregate references unknown child noun ${aggregate.childNounId}`);
2368
+ add([...aggregateBase, "childInstrumentId"], `aggregate references unknown child instrument ${aggregate.childInstrumentId}`, "UDL5004");
1418
2369
  return;
1419
2370
  }
1420
2371
  if (sum) {
1421
2372
  const childField = child.fields[sum.childField];
1422
2373
  if (!childField) {
1423
- add([...aggregateBase, "childField"], `aggregate references unknown child field ${sum.childField}`);
2374
+ add([...aggregateBase, "childField"], `aggregate references unknown child field ${sum.childField}`, "UDL5004");
1424
2375
  }
1425
2376
  else if (!isMoneySchema(childField)) {
1426
- add([...aggregateBase, "childField"], `${child.id}.${sum.childField} must be a money field`);
2377
+ add([...aggregateBase, "childField"], `${child.id}.${sum.childField} must be a money field`, "UDL5004");
1427
2378
  }
1428
2379
  else if (child.update?.fields.includes(sum.childField)) {
1429
- add([...aggregateBase, "childField"], `${child.id}.${sum.childField} cannot be updateable while it contributes to an aggregate`);
2380
+ add([...aggregateBase, "childField"], `${child.id}.${sum.childField} cannot be updateable while it contributes to an aggregate`, "UDL5004");
1430
2381
  }
1431
2382
  }
1432
2383
  const window = "count" in aggregate ? aggregate.window : undefined;
1433
2384
  if (window) {
1434
2385
  const windowField = child.fields[window.field];
1435
2386
  if (!windowField) {
1436
- add([...aggregateBase, "window", "field"], `aggregate window references unknown child field ${window.field}`);
2387
+ add([...aggregateBase, "window", "field"], `aggregate window references unknown child field ${window.field}`, "UDL5004");
1437
2388
  }
1438
2389
  else if (!isDateTimeFormat(windowField.format) ||
1439
2390
  !child.required.includes(window.field)) {
1440
- add([...aggregateBase, "window", "field"], `${child.id}.${window.field} must be a required date-time field to window a count`);
2391
+ add([...aggregateBase, "window", "field"], `${child.id}.${window.field} must be a required date-time field to window a count`, "UDL5004");
1441
2392
  }
1442
2393
  else if (child.update?.fields.includes(window.field)) {
1443
- add([...aggregateBase, "window", "field"], `${child.id}.${window.field} cannot be updateable while it windows an aggregate`);
2394
+ add([...aggregateBase, "window", "field"], `${child.id}.${window.field} cannot be updateable while it windows an aggregate`, "UDL5004");
1444
2395
  }
1445
2396
  }
1446
2397
  const refField = child.fields[aggregate.childRefField];
1447
2398
  if (!refField) {
1448
- add([...aggregateBase, "childRefField"], `aggregate references unknown child ref field ${aggregate.childRefField}`);
2399
+ add([...aggregateBase, "childRefField"], `aggregate references unknown child ref field ${aggregate.childRefField}`, "UDL5004");
1449
2400
  }
1450
- else if (!references.accepts(refField, noun.idPrefix)) {
1451
- add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} must reference ${noun.id}`);
2401
+ else if (!references.accepts(refField, instrument.idPrefix)) {
2402
+ add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} must reference ${instrument.id}`, "UDL5004");
1452
2403
  }
1453
2404
  else if (child.update?.fields.includes(aggregate.childRefField)) {
1454
- add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} cannot be updateable while it links an aggregate`);
2405
+ add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} cannot be updateable while it links an aggregate`, "UDL5004");
1455
2406
  }
1456
2407
  addDuplicateIssues(aggregate.childStatuses, [...aggregateBase, "childStatuses"], "child status", add);
1457
2408
  aggregate.childStatuses.forEach((status, statusIndex) => {
1458
2409
  if (!child.lifecycle.states.includes(status)) {
1459
- add([...aggregateBase, "childStatuses", statusIndex], `aggregate consumes unknown ${child.id} status ${status}`);
2410
+ add([...aggregateBase, "childStatuses", statusIndex], `aggregate consumes unknown ${child.id} status ${status}`, "UDL5004");
1460
2411
  }
1461
2412
  });
1462
2413
  });
1463
2414
  }
1464
- function exampleInputSchema(noun, verb, definition) {
1465
- if (verb !== "create") {
2415
+ function exampleInputSchema(instrument, action, definition) {
2416
+ if (action !== "create") {
1466
2417
  return (definition.input ?? {
1467
2418
  additionalProperties: false,
1468
2419
  properties: {},
@@ -1473,11 +2424,13 @@ function exampleInputSchema(noun, verb, definition) {
1473
2424
  // the runtime writes them from referenced instances or lifecycle events.
1474
2425
  const boundKeys = new Set([
1475
2426
  ...(definition.requiresRefs ?? []).flatMap((gate) => Object.keys(gate.bind ?? {})),
1476
- ...Object.values(noun.verbs).flatMap((candidate) => candidate.setsAt ? [candidate.setsAt.field] : []),
2427
+ ...Object.values(instrument.actions).flatMap((candidate) => candidate.setsAt ? [candidate.setsAt.field] : []),
2428
+ ...(instrument.derivedAmounts ?? []).map((amount) => amount.field),
2429
+ ...(instrument.feeRules ?? []).flatMap((fee) => fee.rule.kind === "exact" ? [] : [fee.amountField]),
1477
2430
  ]);
1478
- const properties = Object.fromEntries(Object.entries(noun.fields).filter(([key]) => !boundKeys.has(key)));
1479
- const required = noun.required.filter((key) => !boundKeys.has(key));
1480
- if (noun.subject) {
2431
+ const properties = Object.fromEntries(Object.entries(instrument.fields).filter(([key]) => !boundKeys.has(key)));
2432
+ const required = instrument.required.filter((key) => !boundKeys.has(key));
2433
+ if (instrument.subject) {
1481
2434
  properties.subject = { minLength: 1, type: "string" };
1482
2435
  required.push("subject");
1483
2436
  }
@@ -1488,6 +2441,16 @@ function exampleInputSchema(noun, verb, definition) {
1488
2441
  type: "object",
1489
2442
  };
1490
2443
  }
2444
+ function exampleInputForUdlValidation(instrument, input) {
2445
+ const camelId = instrument.id.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase());
2446
+ const envelopeKeys = new Set([
2447
+ "actorAccountId",
2448
+ "tenantId",
2449
+ "productId",
2450
+ `${camelId}Id`,
2451
+ ]);
2452
+ return Object.fromEntries(Object.entries(input).filter(([key]) => !envelopeKeys.has(key)));
2453
+ }
1491
2454
  /** The measure half of an aggregate's identity: summed field, or count(+window). */
1492
2455
  function aggregateMeasureKey(aggregate) {
1493
2456
  return "childField" in aggregate
@@ -1501,6 +2464,49 @@ function isMoneySchema(schema) {
1501
2464
  function isCurrencySchema(schema) {
1502
2465
  return schema.pattern === currencyPattern;
1503
2466
  }
2467
+ /** Whether a reconcile exception child owns a reference back to its parent. */
2468
+ export function reconcileExceptionChildProblems(parentIdPrefix, child, exception, references) {
2469
+ const problems = [];
2470
+ const refSchema = child.fields[exception.refField];
2471
+ if (refSchema === undefined || !references.accepts(refSchema, parentIdPrefix))
2472
+ problems.push("UDL5007");
2473
+ const amountSchema = child.fields[exception.amountField];
2474
+ if (amountSchema === undefined ||
2475
+ !child.required.includes(exception.amountField))
2476
+ problems.push("UDL5009");
2477
+ else if (!isMoneySchema(amountSchema))
2478
+ problems.push("UDL5010");
2479
+ const reasonSchema = child.fields[exception.reasonField];
2480
+ if (reasonSchema === undefined ||
2481
+ !child.required.includes(exception.reasonField))
2482
+ problems.push("UDL5011");
2483
+ else if (reasonSchema.type !== "string" ||
2484
+ isMoneySchema(reasonSchema) ||
2485
+ "pattern" in reasonSchema ||
2486
+ "format" in reasonSchema ||
2487
+ "enum" in reasonSchema)
2488
+ problems.push("UDL5012");
2489
+ return problems;
2490
+ }
2491
+ export function isReconcileExceptionChild(parentIdPrefix, child, exception, references) {
2492
+ return (reconcileExceptionChildProblems(parentIdPrefix, child, exception, references).length === 0);
2493
+ }
2494
+ function reconcileExceptionProblemMessage(code, parent, child, exception) {
2495
+ switch (code) {
2496
+ case "UDL5007":
2497
+ return `${child.id}.${exception.refField} must reference ${parent.id} to carry its breaks`;
2498
+ case "UDL5009":
2499
+ return `${child.id}.${exception.amountField} must be a required child money field`;
2500
+ case "UDL5010":
2501
+ return `${child.id}.${exception.amountField} must be a money field`;
2502
+ case "UDL5011":
2503
+ return `${child.id}.${exception.reasonField} must be a required child text field`;
2504
+ case "UDL5012":
2505
+ return `${child.id}.${exception.reasonField} must be a text field`;
2506
+ default:
2507
+ return "reconcile exception child is invalid";
2508
+ }
2509
+ }
1504
2510
  /**
1505
2511
  * One sample id per prefix, built here and nowhere else. The probe below asks
1506
2512
  * a schema two questions and the answers are only comparable while both
@@ -1612,7 +2618,7 @@ function addDuplicateIssues(values, path, label, add) {
1612
2618
  if (previous === undefined)
1613
2619
  firstIndex.set(value, index);
1614
2620
  else
1615
- add([...path, index], `duplicate ${label} ${value}; first declared at index ${previous}`);
2621
+ add([...path, index], `duplicate ${label} ${value}; first declared at index ${previous}`, "UDL2001");
1616
2622
  });
1617
2623
  }
1618
2624
  function jsonPath(path) {