@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
package/src/validation.ts CHANGED
@@ -7,31 +7,33 @@ import {
7
7
  import { z } from "zod";
8
8
 
9
9
  import {
10
+ udlClauseVocabulary,
11
+ quoteExpiresAtRefKey,
12
+ quoteFrozenRefKey,
13
+ quoteSeededRefKeys,
10
14
  udlDocumentSchema,
11
15
  type UdlAggregate,
12
16
  type UdlDocument,
13
17
  type UdlGate,
14
18
  type UdlMove,
15
- type UdlNoun,
19
+ type UdlInstrument,
16
20
  type UdlStep,
17
- type UdlVerb,
21
+ type UdlAction,
18
22
  } from "./schema.js";
19
23
  import { fixedIsoDurationMs } from "./duration.js";
20
- import { analyzeNounFinance, financeAdmissionProblem } from "./finance.js";
24
+ import {
25
+ analyzeInstrumentFinance,
26
+ financeAdmissionProblem,
27
+ } from "./finance.js";
21
28
  import { UDL_LIMITS } from "./limits.js";
29
+ import { deriveUdlActionEffects, udlEffectKinds } from "./effects.js";
30
+ import { issue, type UdlIssue, type UdlIssueCode } from "./diagnostics.js";
22
31
 
23
- export type UdlIssueCode =
24
- | "invalid_json"
25
- | "invalid_semantics"
26
- | "invalid_shape"
27
- | "invalid_utf8"
28
- | "resource_limit";
29
-
30
- export interface UdlIssue {
31
- readonly code: UdlIssueCode;
32
- readonly message: string;
33
- readonly path: string;
34
- }
32
+ export type {
33
+ UdlIssue,
34
+ UdlIssueCategory,
35
+ UdlIssueCode,
36
+ } from "./diagnostics.js";
35
37
 
36
38
  export type UdlValidationResult =
37
39
  | { readonly ok: true; readonly value: UdlDocument }
@@ -56,14 +58,16 @@ export function validateUdl(value: unknown): UdlValidationResult {
56
58
  const resourceIssue = structuralBudgetIssue(value);
57
59
  if (resourceIssue) return { issues: [resourceIssue], ok: false };
58
60
 
61
+ const reconcileShapeIssues = reconcileExceptionRequiredFieldIssues(value);
62
+ if (reconcileShapeIssues.length > 0)
63
+ return { issues: reconcileShapeIssues, ok: false };
64
+
59
65
  const parsed = udlDocumentSchema.safeParse(value);
60
66
  if (!parsed.success) {
61
67
  return {
62
- issues: parsed.error.issues.map((issue) => ({
63
- code: "invalid_shape" as const,
64
- message: issue.message,
65
- path: jsonPath(issue.path),
66
- })),
68
+ issues: parsed.error.issues.map((entry) =>
69
+ issue(shapeIssueCode(entry.path), jsonPath(entry.path), entry.message),
70
+ ),
67
71
  ok: false,
68
72
  };
69
73
  }
@@ -73,11 +77,11 @@ export function validateUdl(value: unknown): UdlValidationResult {
73
77
  if (references.exhausted) {
74
78
  return {
75
79
  issues: [
76
- {
77
- code: "resource_limit",
78
- message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns, reference gates, or payout intents`,
79
- path: "$.nouns",
80
- },
80
+ issue(
81
+ "UDL1004",
82
+ "$.instruments",
83
+ `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer instruments, reference gates, or payout intents`,
84
+ ),
81
85
  ],
82
86
  ok: false,
83
87
  };
@@ -87,6 +91,46 @@ export function validateUdl(value: unknown): UdlValidationResult {
87
91
  : { issues, ok: false };
88
92
  }
89
93
 
94
+ function shapeIssueCode(path: readonly PropertyKey[]): UdlIssueCode {
95
+ if (path.includes("requiresExposure")) return "UDL5003";
96
+ if (path.includes("requiresAggregate")) return "UDL5004";
97
+ return "UDL1003";
98
+ }
99
+
100
+ function reconcileExceptionRequiredFieldIssues(value: unknown): UdlIssue[] {
101
+ const issues: UdlIssue[] = [];
102
+ const instruments = recordValue(value).instruments;
103
+ if (!Array.isArray(instruments)) return issues;
104
+ instruments.forEach((instrument, instrumentIndex) => {
105
+ const actions = recordValue(instrument).actions;
106
+ if (!actions || typeof actions !== "object" || Array.isArray(actions))
107
+ return;
108
+ for (const [action, definition] of Object.entries(actions)) {
109
+ const reconciles = recordValue(definition).reconcile;
110
+ if (!Array.isArray(reconciles)) continue;
111
+ reconciles.forEach((reconcile, reconcileIndex) => {
112
+ const exception = recordValue(recordValue(reconcile).exception);
113
+ const base = jsonPath([
114
+ "instruments",
115
+ instrumentIndex,
116
+ "actions",
117
+ action,
118
+ "reconcile",
119
+ reconcileIndex,
120
+ "exception",
121
+ ]);
122
+ if (!("amountField" in exception)) {
123
+ issues.push(issue("UDL5009", `${base}.amountField`));
124
+ }
125
+ if (!("reasonField" in exception)) {
126
+ issues.push(issue("UDL5011", `${base}.reasonField`));
127
+ }
128
+ });
129
+ }
130
+ });
131
+ return issues;
132
+ }
133
+
90
134
  export function assertValidUdl(value: unknown): UdlDocument {
91
135
  const result = validateUdl(value);
92
136
  if (!result.ok) throw new UdlError(result.issues);
@@ -139,18 +183,12 @@ export function validateUdlJsonSchema(schema: unknown): readonly UdlIssue[] {
139
183
  const admissionIssue = structuralBudgetIssue(schema);
140
184
  if (admissionIssue) return [admissionIssue];
141
185
  if (!isRecord(schema)) {
142
- return [
143
- {
144
- code: "invalid_shape",
145
- message: "JSON Schema must be an object",
146
- path: "$",
147
- },
148
- ];
186
+ return [issue("UDL1003", "$", "JSON Schema must be an object")];
149
187
  }
150
188
 
151
189
  const issues: UdlIssue[] = [];
152
190
  validateJsonSchema(schema, [], (path, message) => {
153
- issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
191
+ issues.push(issue("UDL6001", jsonPath(path), message));
154
192
  });
155
193
  return issues;
156
194
  }
@@ -165,7 +203,9 @@ function admissionValidationResult(
165
203
  error: issue.message,
166
204
  instanceLocation: issue.path,
167
205
  keyword:
168
- issue.code === "resource_limit" ? "resource_limit" : invalidKeyword,
206
+ issue.category === "resource_limit"
207
+ ? "resource_limit"
208
+ : invalidKeyword,
169
209
  keywordLocation: "$",
170
210
  },
171
211
  ],
@@ -173,7 +213,11 @@ function admissionValidationResult(
173
213
  };
174
214
  }
175
215
 
176
- type AddIssue = (path: readonly PropertyKey[], message: string) => void;
216
+ type AddIssue = (
217
+ path: readonly PropertyKey[],
218
+ message: string,
219
+ code: UdlIssueCode,
220
+ ) => void;
177
221
 
178
222
  const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
179
223
  const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
@@ -219,13 +263,14 @@ function semanticIssues(
219
263
  references: ReferenceShapeBudget,
220
264
  ): UdlIssue[] {
221
265
  const issues: UdlIssue[] = [];
222
- const add: AddIssue = (path, message) => {
223
- issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
266
+ const add: AddIssue = (path, message, code) => {
267
+ issues.push(issue(code, jsonPath(path), message));
224
268
  };
225
269
 
226
- document.nouns.forEach((noun, nounIndex) => {
227
- const problem = financeAdmissionProblem(noun);
228
- if (problem) add(["nouns", nounIndex, "lifecycle"], problem);
270
+ document.instruments.forEach((instrument, instrumentIndex) => {
271
+ const problem = financeAdmissionProblem(instrument);
272
+ if (problem)
273
+ add(["instruments", instrumentIndex, "lifecycle"], problem, "UDL1004");
229
274
  });
230
275
  if (issues.length > 0) return issues;
231
276
 
@@ -239,34 +284,84 @@ function semanticIssues(
239
284
  add,
240
285
  );
241
286
  addDuplicateIssues(
242
- document.nouns.map((noun) => noun.id),
243
- ["nouns"],
244
- "noun id",
287
+ document.instruments.map((instrument) => instrument.id),
288
+ ["instruments"],
289
+ "instrument id",
245
290
  add,
246
291
  );
247
292
  addDuplicateIssues(
248
- document.nouns.map((noun) => noun.idPrefix),
249
- ["nouns"],
250
- "noun idPrefix",
293
+ document.instruments.map((instrument) => instrument.idPrefix),
294
+ ["instruments"],
295
+ "instrument idPrefix",
251
296
  add,
252
297
  );
253
298
 
299
+ validateCompositionDials(document, add);
300
+
254
301
  const subjects = new Map(
255
302
  document.subjects.map((subject) => [subject.kind, subject] as const),
256
303
  );
257
- const nouns = new Map(document.nouns.map((noun) => [noun.id, noun] as const));
304
+ const instruments = new Map(
305
+ document.instruments.map(
306
+ (instrument) => [instrument.id, instrument] as const,
307
+ ),
308
+ );
258
309
 
259
310
  for (const [subjectIndex, subject] of document.subjects.entries()) {
260
311
  if (subject.schema.type !== "object") {
261
312
  add(
262
313
  ["subjects", subjectIndex, "schema", "type"],
263
314
  "a subject attribute schema must declare type object",
315
+ "UDL2002",
264
316
  );
265
317
  }
266
318
  }
267
319
 
268
- for (const [nounIndex, noun] of document.nouns.entries()) {
269
- validateNoun(noun, nounIndex, nouns, subjects, references, add);
320
+ for (const [instrumentIndex, instrument] of document.instruments.entries()) {
321
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
322
+ if (!action.effects) continue;
323
+ const expected = deriveUdlActionEffects(action, udlClauseVocabulary);
324
+ for (const kind of udlEffectKinds) {
325
+ const actualRows = action.effects[kind] ?? [];
326
+ const expectedRows = expected[kind] ?? [];
327
+ if (
328
+ actualRows.length === expectedRows.length &&
329
+ actualRows.every((row, index) => {
330
+ const expectedRow = expectedRows[index];
331
+ return (
332
+ expectedRow !== undefined &&
333
+ row.signature === expectedRow.signature &&
334
+ row.source === expectedRow.source &&
335
+ ("channel" in row ? row.channel : undefined) ===
336
+ expectedRow.channel &&
337
+ ("role" in row ? row.role : undefined) === expectedRow.role
338
+ );
339
+ })
340
+ ) {
341
+ continue;
342
+ }
343
+ add(
344
+ [
345
+ "instruments",
346
+ instrumentIndex,
347
+ "actions",
348
+ actionName,
349
+ "effects",
350
+ kind,
351
+ ],
352
+ `derived ${kind} effects do not match the action clauses`,
353
+ "UDL2005",
354
+ );
355
+ }
356
+ }
357
+ validateInstrument(
358
+ instrument,
359
+ instrumentIndex,
360
+ instruments,
361
+ subjects,
362
+ references,
363
+ add,
364
+ );
270
365
  }
271
366
  return issues;
272
367
  }
@@ -277,14 +372,15 @@ function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
277
372
  let totalStringLength = 0;
278
373
  const pending: {
279
374
  readonly ancestors: ReadonlySet<object>;
375
+ readonly countNode: boolean;
280
376
  readonly depth: number;
281
377
  readonly path: readonly PropertyKey[];
282
378
  readonly value: unknown;
283
- }[] = [{ ancestors: new Set(), depth: 1, path: [], value }];
379
+ }[] = [{ ancestors: new Set(), countNode: true, depth: 1, path: [], value }];
284
380
 
285
381
  while (pending.length > 0) {
286
382
  const entry = pending.pop() as (typeof pending)[number];
287
- nodes += 1;
383
+ if (entry.countNode) nodes += 1;
288
384
  if (nodes > UDL_LIMITS.maxNodes) {
289
385
  return resourceIssue(
290
386
  entry.path,
@@ -327,16 +423,20 @@ function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
327
423
  const childAncestors = new Set(entry.ancestors);
328
424
  childAncestors.add(entry.value);
329
425
  if (Array.isArray(entry.value)) {
330
- if (entry.value.length > UDL_LIMITS.maxNodes - discovered) {
426
+ if (
427
+ entry.countNode &&
428
+ entry.value.length > UDL_LIMITS.maxNodes - discovered
429
+ ) {
331
430
  return resourceIssue(
332
431
  entry.path,
333
432
  `UDL contains more than ${UDL_LIMITS.maxNodes} values`,
334
433
  );
335
434
  }
336
- discovered += entry.value.length;
435
+ if (entry.countNode) discovered += entry.value.length;
337
436
  for (let index = entry.value.length - 1; index >= 0; index -= 1) {
338
437
  pending.push({
339
438
  ancestors: childAncestors,
439
+ countNode: entry.countNode,
340
440
  depth: entry.depth + 1,
341
441
  path: [...entry.path, index],
342
442
  value: entry.value[index],
@@ -351,7 +451,7 @@ function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
351
451
  let childCount = 0;
352
452
  for (const key in entry.value) {
353
453
  if (!Object.hasOwn(entry.value, key)) continue;
354
- childCount += 1;
454
+ if (countsAsAuthoredNode(entry, key)) childCount += 1;
355
455
  if (childCount > UDL_LIMITS.maxNodes - discovered) {
356
456
  return resourceIssue(
357
457
  entry.path,
@@ -377,6 +477,7 @@ function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
377
477
  if (!Object.hasOwn(entry.value, key)) continue;
378
478
  pending.push({
379
479
  ancestors: childAncestors,
480
+ countNode: countsAsAuthoredNode(entry, key),
380
481
  depth: entry.depth + 1,
381
482
  path: [...entry.path, key],
382
483
  value: (entry.value as Record<string, unknown>)[key],
@@ -386,19 +487,30 @@ function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
386
487
  return undefined;
387
488
  }
388
489
 
490
+ function countsAsAuthoredNode(
491
+ entry: { readonly countNode: boolean; readonly path: readonly PropertyKey[] },
492
+ key: string,
493
+ ): boolean {
494
+ return !(
495
+ !entry.countNode ||
496
+ (key === "effects" &&
497
+ entry.path.length === 4 &&
498
+ entry.path[0] === "instruments" &&
499
+ typeof entry.path[1] === "number" &&
500
+ entry.path[2] === "actions" &&
501
+ typeof entry.path[3] === "string")
502
+ );
503
+ }
504
+
389
505
  function resourceIssue(
390
506
  path: readonly PropertyKey[],
391
507
  message: string,
392
508
  ): UdlIssue {
393
- return { code: "resource_limit", message, path: jsonPath(path) };
509
+ return issue("UDL1004", jsonPath(path), message);
394
510
  }
395
511
 
396
512
  function jsonValueIssue(path: readonly PropertyKey[]): UdlIssue {
397
- return {
398
- code: "invalid_shape",
399
- message: "UDL must contain only JSON values",
400
- path: jsonPath(path),
401
- };
513
+ return issue("UDL1003", jsonPath(path), "UDL must contain only JSON values");
402
514
  }
403
515
 
404
516
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -406,23 +518,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
406
518
  }
407
519
 
408
520
  function validateDocumentSchemas(document: UdlDocument, add: AddIssue): void {
521
+ const addSchemaIssue: AddIssue = (path, message) =>
522
+ add(path, message, "UDL6001");
409
523
  document.subjects.forEach((subject, subjectIndex) =>
410
524
  validateJsonSchema(
411
525
  subject.schema,
412
526
  ["subjects", subjectIndex, "schema"],
413
- add,
527
+ addSchemaIssue,
414
528
  ),
415
529
  );
416
- document.nouns.forEach((noun, nounIndex) => {
417
- for (const [field, schema] of Object.entries(noun.fields)) {
418
- validateJsonSchema(schema, ["nouns", nounIndex, "fields", field], add);
530
+ document.instruments.forEach((instrument, instrumentIndex) => {
531
+ for (const [field, schema] of Object.entries(instrument.fields)) {
532
+ validateJsonSchema(
533
+ schema,
534
+ ["instruments", instrumentIndex, "fields", field],
535
+ addSchemaIssue,
536
+ );
419
537
  }
420
- for (const [verb, definition] of Object.entries(noun.verbs)) {
538
+ for (const [action, definition] of Object.entries(instrument.actions)) {
421
539
  if (definition.input) {
422
540
  validateJsonSchema(
423
541
  definition.input,
424
- ["nouns", nounIndex, "verbs", verb, "input"],
425
- add,
542
+ ["instruments", instrumentIndex, "actions", action, "input"],
543
+ addSchemaIssue,
426
544
  );
427
545
  }
428
546
  }
@@ -439,6 +557,7 @@ function validateJsonSchema(
439
557
  add(
440
558
  [...path, keyword],
441
559
  `JSON Schema keyword ${keyword} is not in the UDL schema subset`,
560
+ "UDL6001",
442
561
  );
443
562
  }
444
563
  }
@@ -447,18 +566,27 @@ function validateJsonSchema(
447
566
  add(
448
567
  [...path, "type"],
449
568
  `JSON Schema type must be one of ${[...jsonSchemaTypes].join(", ")}`,
569
+ "UDL6001",
450
570
  );
451
571
  }
452
572
  for (const keyword of ["description", "title"] as const) {
453
573
  if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "string") {
454
- add([...path, keyword], `JSON Schema ${keyword} must be a string`);
574
+ add(
575
+ [...path, keyword],
576
+ `JSON Schema ${keyword} must be a string`,
577
+ "UDL6001",
578
+ );
455
579
  }
456
580
  }
457
581
  if (
458
582
  Object.hasOwn(schema, "enum") &&
459
583
  (!Array.isArray(schema.enum) || schema.enum.length === 0)
460
584
  ) {
461
- add([...path, "enum"], "JSON Schema enum must be a non-empty array");
585
+ add(
586
+ [...path, "enum"],
587
+ "JSON Schema enum must be a non-empty array",
588
+ "UDL6001",
589
+ );
462
590
  }
463
591
  if (
464
592
  Object.hasOwn(schema, "properties") &&
@@ -467,6 +595,7 @@ function validateJsonSchema(
467
595
  add(
468
596
  [...path, "properties"],
469
597
  "JSON Schema properties require an object schema and object value",
598
+ "UDL6001",
470
599
  );
471
600
  }
472
601
  if (Object.hasOwn(schema, "required")) {
@@ -474,6 +603,7 @@ function validateJsonSchema(
474
603
  add(
475
604
  [...path, "required"],
476
605
  "JSON Schema required is only valid on an object schema",
606
+ "UDL6001",
477
607
  );
478
608
  } else if (
479
609
  !Array.isArray(schema.required) ||
@@ -482,6 +612,7 @@ function validateJsonSchema(
482
612
  add(
483
613
  [...path, "required"],
484
614
  "JSON Schema required must be an array of property names",
615
+ "UDL6001",
485
616
  );
486
617
  } else {
487
618
  const properties = recordValue(schema.properties);
@@ -490,6 +621,7 @@ function validateJsonSchema(
490
621
  add(
491
622
  [...path, "required"],
492
623
  `JSON Schema required references undeclared property ${field}`,
624
+ "UDL6001",
493
625
  );
494
626
  }
495
627
  }
@@ -502,6 +634,7 @@ function validateJsonSchema(
502
634
  add(
503
635
  [...path, "items"],
504
636
  "JSON Schema items require an array schema and object value",
637
+ "UDL6001",
505
638
  );
506
639
  }
507
640
  if (
@@ -513,6 +646,7 @@ function validateJsonSchema(
513
646
  add(
514
647
  [...path, "additionalProperties"],
515
648
  "JSON Schema additionalProperties requires an object schema and a boolean or schema value",
649
+ "UDL6001",
516
650
  );
517
651
  }
518
652
  for (const keyword of [
@@ -534,6 +668,7 @@ function validateJsonSchema(
534
668
  add(
535
669
  [...path, keyword],
536
670
  `JSON Schema ${keyword} must be a non-negative integer on the matching schema type`,
671
+ "UDL6001",
537
672
  );
538
673
  }
539
674
  }
@@ -548,6 +683,7 @@ function validateJsonSchema(
548
683
  add(
549
684
  [...path, keyword],
550
685
  `JSON Schema ${keyword} must be finite on an integer schema`,
686
+ "UDL6001",
551
687
  );
552
688
  }
553
689
  }
@@ -556,21 +692,33 @@ function validateJsonSchema(
556
692
  typeof schema.maxLength === "number" &&
557
693
  schema.minLength > schema.maxLength
558
694
  ) {
559
- add([...path, "maxLength"], "JSON Schema maxLength is below minLength");
695
+ add(
696
+ [...path, "maxLength"],
697
+ "JSON Schema maxLength is below minLength",
698
+ "UDL6001",
699
+ );
560
700
  }
561
701
  if (
562
702
  typeof schema.minItems === "number" &&
563
703
  typeof schema.maxItems === "number" &&
564
704
  schema.minItems > schema.maxItems
565
705
  ) {
566
- add([...path, "maxItems"], "JSON Schema maxItems is below minItems");
706
+ add(
707
+ [...path, "maxItems"],
708
+ "JSON Schema maxItems is below minItems",
709
+ "UDL6001",
710
+ );
567
711
  }
568
712
  if (
569
713
  typeof schema.minimum === "number" &&
570
714
  typeof schema.maximum === "number" &&
571
715
  schema.minimum > schema.maximum
572
716
  ) {
573
- add([...path, "maximum"], "JSON Schema maximum is below minimum");
717
+ add(
718
+ [...path, "maximum"],
719
+ "JSON Schema maximum is below minimum",
720
+ "UDL6001",
721
+ );
574
722
  }
575
723
  if (Object.hasOwn(schema, "x-hyperscale-reference-filter")) {
576
724
  const filter = schema["x-hyperscale-reference-filter"];
@@ -585,6 +733,7 @@ function validateJsonSchema(
585
733
  add(
586
734
  [...path, "x-hyperscale-reference-filter"],
587
735
  "x-hyperscale-reference-filter requires only string column and string-array values",
736
+ "UDL6001",
588
737
  );
589
738
  }
590
739
  }
@@ -596,14 +745,19 @@ function validateJsonSchema(
596
745
  add(
597
746
  [...path, "x-hyperscale-fee-collection-port"],
598
747
  "x-hyperscale-fee-collection-port must be true on a string schema",
748
+ "UDL6001",
599
749
  );
600
750
  }
601
751
  if (Object.hasOwn(schema, "pattern")) {
602
752
  if (schema.type !== "string" || typeof schema.pattern !== "string") {
603
- add([...path, "pattern"], "JSON Schema pattern must be a string");
753
+ add(
754
+ [...path, "pattern"],
755
+ "JSON Schema pattern must be a string",
756
+ "UDL6001",
757
+ );
604
758
  } else {
605
759
  const problem = regexProblem(schema.pattern);
606
- if (problem) add([...path, "pattern"], problem);
760
+ if (problem) add([...path, "pattern"], problem, "UDL6001");
607
761
  }
608
762
  }
609
763
  if (
@@ -615,6 +769,7 @@ function validateJsonSchema(
615
769
  add(
616
770
  [...path, "format"],
617
771
  `JSON Schema format must be one of ${[...jsonSchemaFormats].join(", ")}`,
772
+ "UDL6001",
618
773
  );
619
774
  }
620
775
 
@@ -626,6 +781,7 @@ function validateJsonSchema(
626
781
  add(
627
782
  [...path, "properties", property],
628
783
  "JSON Schema property must be a schema object",
784
+ "UDL6001",
629
785
  );
630
786
  }
631
787
  }
@@ -793,77 +949,118 @@ function isEscapedRegexToken(pattern: string, index: number): boolean {
793
949
  return slashes % 2 === 1;
794
950
  }
795
951
 
796
- function validateNoun(
797
- noun: UdlNoun,
798
- nounIndex: number,
799
- nouns: ReadonlyMap<string, UdlNoun>,
952
+ function validateInstrument(
953
+ instrument: UdlInstrument,
954
+ instrumentIndex: number,
955
+ instruments: ReadonlyMap<string, UdlInstrument>,
800
956
  subjects: ReadonlyMap<string, unknown>,
801
957
  references: ReferenceShapeBudget,
802
958
  add: AddIssue,
803
959
  ): void {
804
- const base = ["nouns", nounIndex] as const;
805
- const states = new Set(noun.lifecycle.states);
960
+ const base = ["instruments", instrumentIndex] as const;
961
+ const states = new Set(instrument.lifecycle.states);
962
+ const orderedActions = new Set(instrument.actionOrder);
963
+
964
+ addDuplicateIssues(
965
+ instrument.actionOrder,
966
+ [...base, "actionOrder"],
967
+ "action id",
968
+ add,
969
+ );
970
+ instrument.actionOrder.forEach((action, actionIndex) => {
971
+ if (!Object.hasOwn(instrument.actions, action)) {
972
+ add(
973
+ [...base, "actionOrder", actionIndex],
974
+ `action order references unknown action ${action}`,
975
+ "UDL3001",
976
+ );
977
+ }
978
+ });
979
+ for (const action of Object.keys(instrument.actions)) {
980
+ if (!orderedActions.has(action)) {
981
+ add(
982
+ [...base, "actions", action],
983
+ `action ${action} is missing from actionOrder`,
984
+ "UDL3001",
985
+ );
986
+ }
987
+ }
806
988
 
807
989
  addDuplicateIssues(
808
- noun.lifecycle.states,
990
+ instrument.lifecycle.states,
809
991
  [...base, "lifecycle", "states"],
810
992
  "lifecycle state",
811
993
  add,
812
994
  );
813
- if (!states.has(noun.lifecycle.initial)) {
995
+ if (!states.has(instrument.lifecycle.initial)) {
814
996
  add(
815
997
  [...base, "lifecycle", "initial"],
816
- `initial state ${noun.lifecycle.initial} is not declared in lifecycle.states`,
998
+ `initial state ${instrument.lifecycle.initial} is not declared in lifecycle.states`,
999
+ "UDL3001",
817
1000
  );
818
1001
  }
819
- if (!Object.hasOwn(noun.verbs, "create")) {
820
- add([...base, "verbs"], "every noun must declare the create verb");
1002
+ if (!Object.hasOwn(instrument.actions, "create")) {
1003
+ add(
1004
+ [...base, "actions"],
1005
+ "every instrument must declare the create action",
1006
+ "UDL3001",
1007
+ );
821
1008
  }
822
- if (Object.hasOwn(noun.lifecycle.transitions, "create")) {
1009
+ if (Object.hasOwn(instrument.lifecycle.transitions, "create")) {
823
1010
  add(
824
1011
  [...base, "lifecycle", "transitions", "create"],
825
1012
  "create lands on lifecycle.initial and must not declare a transition",
1013
+ "UDL3001",
826
1014
  );
827
1015
  }
828
1016
 
829
- for (const [verb, transition] of Object.entries(noun.lifecycle.transitions)) {
830
- if (!Object.hasOwn(noun.verbs, verb)) {
1017
+ for (const [action, transition] of Object.entries(
1018
+ instrument.lifecycle.transitions,
1019
+ )) {
1020
+ if (!Object.hasOwn(instrument.actions, action)) {
831
1021
  add(
832
- [...base, "lifecycle", "transitions", verb],
833
- `transition ${verb} has no matching verb`,
1022
+ [...base, "lifecycle", "transitions", action],
1023
+ `transition ${action} has no matching action`,
1024
+ "UDL3001",
834
1025
  );
835
1026
  }
836
1027
  addDuplicateIssues(
837
1028
  transition.from,
838
- [...base, "lifecycle", "transitions", verb, "from"],
1029
+ [...base, "lifecycle", "transitions", action, "from"],
839
1030
  "source state",
840
1031
  add,
841
1032
  );
842
1033
  transition.from.forEach((from, fromIndex) => {
843
1034
  if (!states.has(from)) {
844
1035
  add(
845
- [...base, "lifecycle", "transitions", verb, "from", fromIndex],
1036
+ [...base, "lifecycle", "transitions", action, "from", fromIndex],
846
1037
  `source state ${from} is not declared in lifecycle.states`,
1038
+ "UDL3001",
847
1039
  );
848
1040
  }
849
1041
  });
850
1042
  if (!states.has(transition.to)) {
851
1043
  add(
852
- [...base, "lifecycle", "transitions", verb, "to"],
1044
+ [...base, "lifecycle", "transitions", action, "to"],
853
1045
  `target state ${transition.to} is not declared in lifecycle.states`,
1046
+ "UDL3001",
854
1047
  );
855
1048
  }
856
1049
  }
857
1050
 
858
- for (const verb of Object.keys(noun.verbs)) {
859
- if (verb !== "create" && !Object.hasOwn(noun.lifecycle.transitions, verb)) {
1051
+ for (const action of Object.keys(instrument.actions)) {
1052
+ if (
1053
+ action !== "create" &&
1054
+ !Object.hasOwn(instrument.lifecycle.transitions, action)
1055
+ ) {
860
1056
  add(
861
- [...base, "verbs", verb],
862
- `verb ${verb} must declare a lifecycle transition`,
1057
+ [...base, "actions", action],
1058
+ `action ${action} must declare a lifecycle transition`,
1059
+ "UDL3001",
863
1060
  );
864
1061
  }
865
1062
  }
866
- validateReachability(noun, base, add);
1063
+ validateReachability(instrument, base, add);
867
1064
 
868
1065
  const reservedFields = new Set([
869
1066
  "createdAt",
@@ -872,115 +1069,385 @@ function validateNoun(
872
1069
  "refs",
873
1070
  "status",
874
1071
  ]);
875
- for (const field of Object.keys(noun.fields)) {
1072
+ for (const field of Object.keys(instrument.fields)) {
876
1073
  if (reservedFields.has(field)) {
877
1074
  add(
878
1075
  [...base, "fields", field],
879
1076
  `${field} is an envelope field and cannot be authored`,
1077
+ "UDL2002",
880
1078
  );
881
1079
  }
882
1080
  }
883
1081
  addDuplicateIssues(
884
- noun.required,
1082
+ instrument.required,
885
1083
  [...base, "required"],
886
1084
  "required field",
887
1085
  add,
888
1086
  );
889
- noun.required.forEach((field, fieldIndex) => {
890
- if (!Object.hasOwn(noun.fields, field)) {
1087
+ instrument.required.forEach((field, fieldIndex) => {
1088
+ if (!Object.hasOwn(instrument.fields, field)) {
891
1089
  add(
892
1090
  [...base, "required", fieldIndex],
893
1091
  `required references unknown field ${field}`,
1092
+ "UDL2002",
894
1093
  );
895
1094
  }
896
1095
  });
897
1096
 
898
- if (noun.subject) {
1097
+ if (instrument.subject) {
899
1098
  addDuplicateIssues(
900
- noun.subject.kinds,
1099
+ instrument.subject.kinds,
901
1100
  [...base, "subject", "kinds"],
902
1101
  "subject kind",
903
1102
  add,
904
1103
  );
905
- noun.subject.kinds.forEach((kind, kindIndex) => {
1104
+ instrument.subject.kinds.forEach((kind, kindIndex) => {
906
1105
  if (!subjects.has(kind)) {
907
1106
  add(
908
1107
  [...base, "subject", "kinds", kindIndex],
909
- `noun subject references unknown kind ${kind}`,
1108
+ `instrument subject references unknown kind ${kind}`,
1109
+ "UDL2002",
910
1110
  );
911
1111
  }
912
1112
  });
913
1113
  }
914
1114
 
915
- validateParties(noun, base, references, add);
916
- validateUpdate(noun, base, states, add);
917
- validateSetsAt(noun, base, add);
918
- validateVerbs(noun, base, nouns, references, add);
919
- validateUnwind(noun, base, references, add);
920
- for (const issue of analyzeNounFinance(noun)) {
921
- add([...base, ...issue.path], issue.message);
1115
+ validateParties(instrument, base, references, add);
1116
+ validateUpdate(instrument, base, states, add);
1117
+ validateDials(instrument, base, add);
1118
+ validateCallerParkedStates(instrument, base, states, add);
1119
+ validateFeeRules(instrument, base, references, add);
1120
+ validateSetsAt(instrument, base, add);
1121
+ validateActions(instrument, base, instruments, references, add);
1122
+ validateQuoteCommit(instrument, base, references, add);
1123
+ for (const issue of analyzeInstrumentFinance(instrument)) {
1124
+ add([...base, ...issue.path], issue.message, issue.code);
1125
+ }
1126
+ validateAggregates(instrument, base, instruments, references, add);
1127
+ }
1128
+
1129
+ function validateFeeRules(
1130
+ instrument: UdlInstrument,
1131
+ base: readonly PropertyKey[],
1132
+ references: ReferenceShapeBudget,
1133
+ add: AddIssue,
1134
+ ): void {
1135
+ const feeRules = instrument.feeRules ?? [];
1136
+ if (feeRules.length === 0) return;
1137
+ if (Object.keys(instrument.fields).length > 24) {
1138
+ add(
1139
+ [...base, "fields"],
1140
+ "fee-bearing instruments must not declare more than 24 fields",
1141
+ "UDL4001",
1142
+ );
922
1143
  }
923
- validateAggregates(noun, base, nouns, references, add);
1144
+ addDuplicateIssues(
1145
+ feeRules.map((fee) => fee.amountField),
1146
+ [...base, "feeRules"],
1147
+ "fee amount field",
1148
+ add,
1149
+ );
1150
+
1151
+ const currencyFields = Object.entries(instrument.fields)
1152
+ .filter(([, schema]) => isCurrencySchema(schema))
1153
+ .map(([field]) => field);
1154
+ const instrumentCurrency =
1155
+ currencyFields.length === 1 ? currencyFields[0] : undefined;
1156
+ const mutableFields = new Set(instrument.update?.fields ?? []);
1157
+ const partitions = instrument.partitions ?? [];
1158
+
1159
+ feeRules.forEach((fee, feeIndex) => {
1160
+ const feeBase = [...base, "feeRules", feeIndex] as const;
1161
+ const amountSchema = instrument.fields[fee.amountField];
1162
+ const baseSchema = instrument.fields[fee.baseField];
1163
+ if (!amountSchema || !isMoneySchema(amountSchema)) {
1164
+ add(
1165
+ [...feeBase, "amountField"],
1166
+ `fee amount ${fee.amountField} must be a declared money field`,
1167
+ "UDL4001",
1168
+ );
1169
+ }
1170
+ if (!baseSchema || !isMoneySchema(baseSchema)) {
1171
+ add(
1172
+ [...feeBase, "baseField"],
1173
+ `fee base ${fee.baseField} must be a declared money field`,
1174
+ "UDL4001",
1175
+ );
1176
+ }
1177
+ if (!instrument.required.includes(fee.baseField)) {
1178
+ add(
1179
+ [...feeBase, "baseField"],
1180
+ `fee base ${fee.baseField} must be required`,
1181
+ "UDL4001",
1182
+ );
1183
+ }
1184
+ if (mutableFields.has(fee.baseField)) {
1185
+ add(
1186
+ [...feeBase, "baseField"],
1187
+ `fee base ${fee.baseField} cannot be mutable`,
1188
+ "UDL4001",
1189
+ );
1190
+ }
1191
+ const bearerSchema = instrument.fields[fee.bearerField];
1192
+ if (!bearerSchema || !references.accepts(bearerSchema, "acct")) {
1193
+ add(
1194
+ [...feeBase, "bearerField"],
1195
+ `fee bearer ${fee.bearerField} must be a declared account-id field`,
1196
+ "UDL4001",
1197
+ );
1198
+ } else if (!instrument.required.includes(fee.bearerField)) {
1199
+ add(
1200
+ [...feeBase, "bearerField"],
1201
+ `fee bearer ${fee.bearerField} must be required`,
1202
+ "UDL4001",
1203
+ );
1204
+ }
1205
+
1206
+ const validateExact = (
1207
+ rule: { readonly field: string; readonly currencyField: string },
1208
+ rulePath: readonly PropertyKey[],
1209
+ direct: boolean,
1210
+ ): void => {
1211
+ if (direct && rule.field !== fee.amountField) {
1212
+ add(
1213
+ [...rulePath, "field"],
1214
+ `exact fee field ${rule.field} must equal amountField ${fee.amountField}`,
1215
+ "UDL4001",
1216
+ );
1217
+ }
1218
+ if (!direct && rule.field === fee.amountField) {
1219
+ add(
1220
+ [...rulePath, "field"],
1221
+ `tiered fee output ${fee.amountField} must differ from exact source ${rule.field}`,
1222
+ "UDL4001",
1223
+ );
1224
+ }
1225
+ const exactSchema = instrument.fields[rule.field];
1226
+ if (!exactSchema || !isMoneySchema(exactSchema)) {
1227
+ add(
1228
+ [...rulePath, "field"],
1229
+ `exact fee field ${rule.field} must be a declared money field`,
1230
+ "UDL4001",
1231
+ );
1232
+ }
1233
+ if (!instrument.required.includes(rule.field)) {
1234
+ add(
1235
+ [...rulePath, "field"],
1236
+ `exact fee field ${rule.field} must be required`,
1237
+ "UDL4001",
1238
+ );
1239
+ }
1240
+ if (mutableFields.has(rule.field)) {
1241
+ add(
1242
+ [...rulePath, "field"],
1243
+ `exact fee field ${rule.field} cannot be mutable`,
1244
+ "UDL4001",
1245
+ );
1246
+ }
1247
+ if (
1248
+ instrumentCurrency === undefined ||
1249
+ rule.currencyField !== instrumentCurrency
1250
+ ) {
1251
+ add(
1252
+ [...rulePath, "currencyField"],
1253
+ `exact fee currency ${rule.currencyField} must equal the fee base currency field`,
1254
+ "UDL4001",
1255
+ );
1256
+ }
1257
+ };
1258
+
1259
+ if (fee.rule.kind === "exact") {
1260
+ validateExact(fee.rule, [...feeBase, "rule"], true);
1261
+ } else {
1262
+ if (instrument.required.includes(fee.amountField)) {
1263
+ add(
1264
+ [...feeBase, "amountField"],
1265
+ `computed fee amount ${fee.amountField} must not be required at create`,
1266
+ "UDL4001",
1267
+ );
1268
+ }
1269
+ if (fee.rule.kind === "tiered") {
1270
+ const tiers = fee.rule.tiers;
1271
+ const openEnded = tiers.filter(
1272
+ (tier) => tier.toExclusive === undefined,
1273
+ ).length;
1274
+ if (openEnded !== 1) {
1275
+ add(
1276
+ [...feeBase, "rule", "tiers"],
1277
+ `tiered fee must declare exactly one open-ended tier; found ${openEnded}`,
1278
+ "UDL4001",
1279
+ );
1280
+ }
1281
+ tiers.forEach((tier, tierIndex) => {
1282
+ const tierPath = [...feeBase, "rule", "tiers", tierIndex] as const;
1283
+ const from = BigInt(tier.fromInclusive);
1284
+ if (tierIndex === 0 && from !== 0n) {
1285
+ add(
1286
+ [...tierPath, "fromInclusive"],
1287
+ "tiered fee coverage must start at 0",
1288
+ "UDL4001",
1289
+ );
1290
+ }
1291
+ if (tier.toExclusive !== undefined) {
1292
+ const to = BigInt(tier.toExclusive);
1293
+ if (to <= from) {
1294
+ add(
1295
+ [...tierPath, "toExclusive"],
1296
+ "tier upper bound must be greater than its lower bound",
1297
+ "UDL4001",
1298
+ );
1299
+ }
1300
+ } else if (tierIndex !== tiers.length - 1) {
1301
+ add(
1302
+ [...tierPath, "toExclusive"],
1303
+ "only the final fee tier may be open-ended",
1304
+ "UDL4001",
1305
+ );
1306
+ }
1307
+ if (tierIndex > 0) {
1308
+ const previous = tiers[tierIndex - 1];
1309
+ if (previous?.toExclusive === undefined) {
1310
+ add(
1311
+ [...tierPath, "fromInclusive"],
1312
+ "a fee tier cannot follow an open-ended tier",
1313
+ "UDL4001",
1314
+ );
1315
+ } else {
1316
+ const previousEnd = BigInt(previous.toExclusive);
1317
+ if (from > previousEnd) {
1318
+ add(
1319
+ [...tierPath, "fromInclusive"],
1320
+ `fee tiers have a gap before ${tier.fromInclusive}`,
1321
+ "UDL4001",
1322
+ );
1323
+ } else if (from < previousEnd) {
1324
+ add(
1325
+ [...tierPath, "fromInclusive"],
1326
+ `fee tiers overlap at ${tier.fromInclusive}`,
1327
+ "UDL4001",
1328
+ );
1329
+ }
1330
+ }
1331
+ }
1332
+ if (tier.rule.kind === "exact") {
1333
+ validateExact(tier.rule, [...tierPath, "rule"], false);
1334
+ }
1335
+ });
1336
+ }
1337
+ }
1338
+
1339
+ const basePartitions = partitions.filter(
1340
+ (partition) => partition.totalField === fee.baseField,
1341
+ );
1342
+ const direct = basePartitions.some((partition) =>
1343
+ partition.pieceFields.includes(fee.amountField),
1344
+ );
1345
+ const nested = partitions.filter(
1346
+ (partition) =>
1347
+ partition.totalField === fee.amountField &&
1348
+ basePartitions.some((basePartition) =>
1349
+ partition.pieceFields.every((piece) =>
1350
+ basePartition.pieceFields.includes(piece),
1351
+ ),
1352
+ ),
1353
+ );
1354
+ const carvedInsideBase = direct || nested.length === 1;
1355
+ if (fee.position === "carved" && !carvedInsideBase) {
1356
+ add(
1357
+ [...feeBase, "position"],
1358
+ `carved fee ${fee.amountField} must form part of a partition of ${fee.baseField}`,
1359
+ "UDL4001",
1360
+ );
1361
+ }
1362
+ if (fee.position === "on_top" && carvedInsideBase) {
1363
+ add(
1364
+ [...feeBase, "position"],
1365
+ `on-top fee ${fee.amountField} must stay outside the partition of ${fee.baseField}`,
1366
+ "UDL4001",
1367
+ );
1368
+ }
1369
+ });
924
1370
  }
925
1371
 
926
1372
  function validateSetsAt(
927
- noun: UdlNoun,
1373
+ instrument: UdlInstrument,
928
1374
  base: readonly PropertyKey[],
929
1375
  add: AddIssue,
930
1376
  ): void {
931
1377
  const writers = new Map<string, string[]>();
932
1378
  const markerFields = new Set<string>();
933
- for (const [verbName, verb] of Object.entries(noun.verbs)) {
934
- if (!verb.setsAt) continue;
935
- const path = [...base, "verbs", verbName, "setsAt"] as const;
936
- const { field, marker, offset } = verb.setsAt;
1379
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
1380
+ if (!action.setsAt) continue;
1381
+ const path = [...base, "actions", actionName, "setsAt"] as const;
1382
+ const { field, marker, offset } = action.setsAt;
937
1383
  if (marker) markerFields.add(field);
938
- if (verbName === "create") {
1384
+ if (actionName === "create") {
939
1385
  add(
940
1386
  path,
941
1387
  "setsAt requires a lifecycle transition and cannot run on create",
1388
+ "UDL3001",
942
1389
  );
943
1390
  }
944
1391
  const fieldWriters = writers.get(field) ?? [];
945
1392
  if (fieldWriters.length > 0) {
946
- const alternatives = [...fieldWriters, verbName];
1393
+ const alternatives = [...fieldWriters, actionName];
947
1394
  const targets = new Set(
948
- alternatives.map((writer) => noun.lifecycle.transitions[writer]?.to),
1395
+ alternatives.map(
1396
+ (writer) => instrument.lifecycle.transitions[writer]?.to,
1397
+ ),
949
1398
  );
950
1399
  const reentrant = alternatives.some((writer) => {
951
- const transition = noun.lifecycle.transitions[writer];
1400
+ const transition = instrument.lifecycle.transitions[writer];
952
1401
  return transition?.from.includes(transition.to) === true;
953
1402
  });
954
1403
  if (targets.size !== 1 || targets.has(undefined) || reentrant) {
955
1404
  add(
956
1405
  [...path, "field"],
957
1406
  `${field} has multiple writers without one shared one-way destination`,
1407
+ "UDL3001",
958
1408
  );
959
1409
  }
960
1410
  }
961
- writers.set(field, [...fieldWriters, verbName]);
1411
+ writers.set(field, [...fieldWriters, actionName]);
962
1412
 
963
- const schema = noun.fields[field];
1413
+ const schema = instrument.fields[field];
964
1414
  if (!schema) {
965
- add([...path, "field"], `setsAt references unknown field ${field}`);
1415
+ add(
1416
+ [...path, "field"],
1417
+ `setsAt references unknown field ${field}`,
1418
+ "UDL3001",
1419
+ );
966
1420
  } else if (schema.type !== "string" || !isDateTimeFormat(schema.format)) {
967
- add([...path, "field"], "setsAt must target a date-time field");
1421
+ add(
1422
+ [...path, "field"],
1423
+ "setsAt must target a date-time field",
1424
+ "UDL3001",
1425
+ );
968
1426
  }
969
- if (noun.required.includes(field)) {
970
- add([...path, "field"], "setsAt target must be optional at create");
1427
+ if (instrument.required.includes(field)) {
1428
+ add(
1429
+ [...path, "field"],
1430
+ "setsAt target must be optional at create",
1431
+ "UDL3001",
1432
+ );
971
1433
  }
972
- if (noun.update?.fields.includes(field)) {
973
- add([...path, "field"], "setsAt target cannot also be mutable");
1434
+ if (instrument.update?.fields.includes(field)) {
1435
+ add(
1436
+ [...path, "field"],
1437
+ "setsAt target cannot also be mutable",
1438
+ "UDL3001",
1439
+ );
974
1440
  }
975
1441
  const offsetMs = fixedIsoDurationMs(offset);
976
1442
  if (offsetMs === null || offsetMs <= 0) {
977
1443
  add(
978
1444
  [...path, "offset"],
979
1445
  "setsAt.offset must be a positive fixed ISO-8601 duration",
1446
+ "UDL3001",
980
1447
  );
981
1448
  }
982
1449
 
983
- const readers = Object.entries(noun.verbs).filter(
1450
+ const readers = Object.entries(instrument.actions).filter(
984
1451
  ([, candidate]) =>
985
1452
  candidate.due?.field === field || candidate.deadline?.field === field,
986
1453
  );
@@ -989,6 +1456,7 @@ function validateSetsAt(
989
1456
  add(
990
1457
  path,
991
1458
  `setsAt marker field ${field} cannot drive a due condition or deadline`,
1459
+ "UDL3001",
992
1460
  );
993
1461
  }
994
1462
  continue;
@@ -997,45 +1465,47 @@ function validateSetsAt(
997
1465
  add(
998
1466
  path,
999
1467
  `setsAt field ${field} must anchor at least one due condition or deadline`,
1468
+ "UDL3001",
1000
1469
  );
1001
1470
  continue;
1002
1471
  }
1003
1472
  }
1004
1473
  for (const [field, fieldWriters] of writers) {
1005
1474
  if (markerFields.has(field)) continue;
1006
- const readers = Object.entries(noun.verbs).filter(
1475
+ const readers = Object.entries(instrument.actions).filter(
1007
1476
  ([, candidate]) =>
1008
1477
  candidate.due?.field === field || candidate.deadline?.field === field,
1009
1478
  );
1010
1479
  for (const [readerName] of readers) {
1011
- if (!verbGroupDominatesReader(noun, fieldWriters, readerName)) {
1480
+ if (!actionGroupDominatesReader(instrument, fieldWriters, readerName)) {
1012
1481
  add(
1013
- [...base, "verbs", readerName],
1014
- `verb ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`,
1482
+ [...base, "actions", readerName],
1483
+ `action ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`,
1484
+ "UDL3001",
1015
1485
  );
1016
1486
  }
1017
1487
  }
1018
1488
  }
1019
1489
  }
1020
1490
 
1021
- function verbGroupDominatesReader(
1022
- noun: UdlNoun,
1491
+ function actionGroupDominatesReader(
1492
+ instrument: UdlInstrument,
1023
1493
  writers: readonly string[],
1024
1494
  reader: string,
1025
1495
  ): boolean {
1026
1496
  if (writers.length === 0) return false;
1027
- const readerTransition = noun.lifecycle.transitions[reader];
1497
+ const readerTransition = instrument.lifecycle.transitions[reader];
1028
1498
  if (!readerTransition) return false;
1029
1499
  const removed = new Set(writers);
1030
- const reachable = new Set([noun.lifecycle.initial]);
1031
- const pending = [noun.lifecycle.initial];
1500
+ const reachable = new Set([instrument.lifecycle.initial]);
1501
+ const pending = [instrument.lifecycle.initial];
1032
1502
  while (pending.length > 0) {
1033
1503
  const state = pending.shift() as string;
1034
- for (const [verb, transition] of Object.entries(
1035
- noun.lifecycle.transitions,
1504
+ for (const [action, transition] of Object.entries(
1505
+ instrument.lifecycle.transitions,
1036
1506
  )) {
1037
1507
  if (
1038
- removed.has(verb) ||
1508
+ removed.has(action) ||
1039
1509
  !transition.from.includes(state) ||
1040
1510
  reachable.has(transition.to)
1041
1511
  ) {
@@ -1049,15 +1519,15 @@ function verbGroupDominatesReader(
1049
1519
  }
1050
1520
 
1051
1521
  function validateReachability(
1052
- noun: UdlNoun,
1522
+ instrument: UdlInstrument,
1053
1523
  base: readonly PropertyKey[],
1054
1524
  add: AddIssue,
1055
1525
  ): void {
1056
- const reachable = new Set([noun.lifecycle.initial]);
1526
+ const reachable = new Set([instrument.lifecycle.initial]);
1057
1527
  let changed = true;
1058
1528
  while (changed) {
1059
1529
  changed = false;
1060
- for (const transition of Object.values(noun.lifecycle.transitions)) {
1530
+ for (const transition of Object.values(instrument.lifecycle.transitions)) {
1061
1531
  if (
1062
1532
  transition.from.some((state) => reachable.has(state)) &&
1063
1533
  !reachable.has(transition.to)
@@ -1067,34 +1537,40 @@ function validateReachability(
1067
1537
  }
1068
1538
  }
1069
1539
  }
1070
- noun.lifecycle.states.forEach((state, stateIndex) => {
1540
+ instrument.lifecycle.states.forEach((state, stateIndex) => {
1071
1541
  if (!reachable.has(state)) {
1072
1542
  add(
1073
1543
  [...base, "lifecycle", "states", stateIndex],
1074
- `lifecycle state ${state} is unreachable from ${noun.lifecycle.initial}`,
1544
+ `lifecycle state ${state} is unreachable from ${instrument.lifecycle.initial}`,
1545
+ "UDL3001",
1075
1546
  );
1076
1547
  }
1077
1548
  });
1078
1549
  }
1079
1550
 
1080
1551
  function validateParties(
1081
- noun: UdlNoun,
1552
+ instrument: UdlInstrument,
1082
1553
  base: readonly PropertyKey[],
1083
1554
  references: ReferenceShapeBudget,
1084
1555
  add: AddIssue,
1085
1556
  ): void {
1086
- if (!noun.parties) return;
1087
- const entries = Object.entries(noun.parties).filter(
1557
+ if (!instrument.parties) return;
1558
+ const entries = Object.entries(instrument.parties).filter(
1088
1559
  (entry): entry is [string, string] => typeof entry[1] === "string",
1089
1560
  );
1090
1561
  if (entries.length === 0) {
1091
- add([...base, "parties"], "parties must contain at least one declaration");
1562
+ add(
1563
+ [...base, "parties"],
1564
+ "parties must contain at least one declaration",
1565
+ "UDL2002",
1566
+ );
1092
1567
  return;
1093
1568
  }
1094
- if (noun.distinctParties && entries.length < 2) {
1569
+ if (instrument.distinctParties && entries.length < 2) {
1095
1570
  add(
1096
1571
  [...base, "distinctParties"],
1097
1572
  "distinctParties requires at least two declared party roles",
1573
+ "UDL2002",
1098
1574
  );
1099
1575
  }
1100
1576
  addDuplicateIssues(
@@ -1104,200 +1580,762 @@ function validateParties(
1104
1580
  add,
1105
1581
  );
1106
1582
  for (const [role, field] of entries) {
1107
- const schema = noun.fields[field];
1583
+ const schema = instrument.fields[field];
1108
1584
  if (!schema) {
1109
1585
  add(
1110
1586
  [...base, "parties", role],
1111
1587
  `party role ${role} references unknown field ${field}`,
1588
+ "UDL2002",
1112
1589
  );
1113
1590
  } else if (!references.accepts(schema, "acct")) {
1114
1591
  add(
1115
1592
  [...base, "parties", role],
1116
1593
  `party role ${role} must reference an account-id field`,
1594
+ "UDL2002",
1117
1595
  );
1118
1596
  }
1119
1597
  }
1120
1598
  }
1121
1599
 
1122
1600
  function validateUpdate(
1123
- noun: UdlNoun,
1601
+ instrument: UdlInstrument,
1124
1602
  base: readonly PropertyKey[],
1125
1603
  states: ReadonlySet<string>,
1126
1604
  add: AddIssue,
1127
1605
  ): void {
1128
- if (!noun.update) return;
1606
+ if (!instrument.update) return;
1129
1607
  addDuplicateIssues(
1130
- noun.update.fields,
1608
+ instrument.update.fields,
1131
1609
  [...base, "update", "fields"],
1132
1610
  "field",
1133
1611
  add,
1134
1612
  );
1135
1613
  addDuplicateIssues(
1136
- noun.update.states,
1614
+ instrument.update.states,
1137
1615
  [...base, "update", "states"],
1138
1616
  "state",
1139
1617
  add,
1140
1618
  );
1141
- noun.update.fields.forEach((field, index) => {
1142
- if (!Object.hasOwn(noun.fields, field)) {
1619
+ instrument.update.fields.forEach((field, index) => {
1620
+ if (!Object.hasOwn(instrument.fields, field)) {
1143
1621
  add(
1144
1622
  [...base, "update", "fields", index],
1145
1623
  `update references unknown field ${field}`,
1624
+ "UDL2002",
1146
1625
  );
1147
1626
  }
1148
1627
  });
1149
- noun.update.states.forEach((state, index) => {
1628
+ instrument.update.states.forEach((state, index) => {
1150
1629
  if (!states.has(state)) {
1151
1630
  add(
1152
1631
  [...base, "update", "states", index],
1153
1632
  `update references unknown state ${state}`,
1633
+ "UDL2002",
1154
1634
  );
1155
1635
  }
1156
1636
  });
1637
+ if (instrument.update.examples) {
1638
+ addDuplicateIssues(
1639
+ instrument.update.examples.map((example) => example.name),
1640
+ [...base, "update", "examples"],
1641
+ "example name",
1642
+ add,
1643
+ );
1644
+ const schema: Schema = {
1645
+ additionalProperties: false,
1646
+ properties: Object.fromEntries(
1647
+ instrument.update.fields.flatMap((field) => {
1648
+ const fieldSchema = instrument.fields[field];
1649
+ return fieldSchema ? [[field, fieldSchema]] : [];
1650
+ }),
1651
+ ),
1652
+ type: "object",
1653
+ };
1654
+ instrument.update.examples.forEach((example, exampleIndex) => {
1655
+ const input = exampleInputForUdlValidation(instrument, example.input);
1656
+ for (const error of validateUdlSchemaValue(schema, input).errors) {
1657
+ add(
1658
+ [...base, "update", "examples", exampleIndex, "input"],
1659
+ `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`,
1660
+ "UDL2002",
1661
+ );
1662
+ }
1663
+ });
1664
+ }
1157
1665
  }
1158
1666
 
1159
- function validateVerbs(
1160
- noun: UdlNoun,
1667
+ function validateCallerParkedStates(
1668
+ instrument: UdlInstrument,
1161
1669
  base: readonly PropertyKey[],
1162
- nouns: ReadonlyMap<string, UdlNoun>,
1163
- references: ReferenceShapeBudget,
1670
+ states: ReadonlySet<string>,
1164
1671
  add: AddIssue,
1165
1672
  ): void {
1166
- validatePayoutsAndSettlement(noun, base, references, add);
1167
- for (const [verb, definition] of Object.entries(noun.verbs)) {
1168
- const verbBase = [...base, "verbs", verb] as const;
1169
- if (verb === "create" && definition.input) {
1673
+ const parked = instrument.callerParkedStates;
1674
+ if (parked === undefined) return;
1675
+ const exitsByState = new Map(
1676
+ instrument.lifecycle.states.map((state) => [state, [] as string[]]),
1677
+ );
1678
+ for (const [action, transition] of Object.entries(
1679
+ instrument.lifecycle.transitions,
1680
+ )) {
1681
+ const stationary =
1682
+ instrument.actions[action]?.due?.every !== undefined &&
1683
+ transition.from.includes(transition.to);
1684
+ if (stationary) continue;
1685
+ for (const state of transition.from) {
1686
+ if (state !== transition.to) exitsByState.get(state)?.push(action);
1687
+ }
1688
+ }
1689
+ const isUnconditionallyTimeDriven = (action: string): boolean => {
1690
+ const due = instrument.actions[action]?.due;
1691
+ if (!due || !isDateTimeFormat(instrument.fields[due.field]?.format)) {
1692
+ return false;
1693
+ }
1694
+ if (instrument.required.includes(due.field)) return true;
1695
+ const writers = Object.entries(instrument.actions).flatMap(
1696
+ ([writer, definition]) =>
1697
+ definition.setsAt?.field === due.field ? [writer] : [],
1698
+ );
1699
+ return actionGroupDominatesReader(instrument, writers, action);
1700
+ };
1701
+ for (const state of Object.keys(parked)) {
1702
+ const stateBase = [...base, "callerParkedStates", state] as const;
1703
+ if (!states.has(state)) {
1170
1704
  add(
1171
- [...verbBase, "input"],
1172
- "create already consumes noun fields and must not declare input",
1705
+ stateBase,
1706
+ `callerParkedStates references unknown state ${state}`,
1707
+ "UDL3001",
1173
1708
  );
1709
+ continue;
1174
1710
  }
1175
- if (definition.input && definition.input.type !== "object") {
1711
+ const exits = exitsByState.get(state) ?? [];
1712
+ if (exits.length === 0) {
1713
+ add(
1714
+ stateBase,
1715
+ `terminal state ${state} cannot be caller-parked`,
1716
+ "UDL3001",
1717
+ );
1718
+ } else if (exits.some(isUnconditionallyTimeDriven)) {
1176
1719
  add(
1177
- [...verbBase, "input", "type"],
1178
- "verb input must declare an object-shaped JSON Schema",
1720
+ stateBase,
1721
+ `caller-parked state ${state} has a time-driven exit`,
1722
+ "UDL3001",
1179
1723
  );
1180
1724
  }
1181
- const inputFields = recordValue(definition.input?.properties);
1182
- for (const [refKey, inputKey] of Object.entries(
1183
- definition.captureInput ?? {},
1184
- )) {
1185
- if (!Object.hasOwn(inputFields, inputKey)) {
1186
- add(
1187
- [...verbBase, "captureInput", refKey],
1188
- `captured receipt input references undeclared verb input field ${inputKey}`,
1189
- );
1190
- }
1725
+ }
1726
+ for (const [state, exits] of exitsByState) {
1727
+ if (
1728
+ exits.length > 0 &&
1729
+ !exits.some(isUnconditionallyTimeDriven) &&
1730
+ !Object.hasOwn(parked, state)
1731
+ ) {
1732
+ add(
1733
+ [...base, "callerParkedStates"],
1734
+ `state ${state} has only caller-driven exits and needs a caller-parked reason`,
1735
+ "UDL3001",
1736
+ );
1191
1737
  }
1738
+ }
1739
+ }
1192
1740
 
1193
- addDuplicateIssues(
1194
- definition.moves.map((move) => move.key),
1195
- [...verbBase, "moves"],
1196
- "move key",
1197
- add,
1198
- );
1199
- if (definition.earnable && definition.moves.length === 0) {
1200
- add(verbBase, "earnable requires a money move");
1741
+ /**
1742
+ * Composition dials sit on the document, not on an instrument: a confirmation
1743
+ * threshold is a property of the whole product. Keys are unique across the
1744
+ * document the same way instrument dial keys are unique within an instrument.
1745
+ */
1746
+ function validateCompositionDials(document: UdlDocument, add: AddIssue): void {
1747
+ const dials = document.dials ?? [];
1748
+ addDuplicateIssues(
1749
+ dials.map((dial) => dial.key),
1750
+ ["dials"],
1751
+ "composition dial key",
1752
+ add,
1753
+ );
1754
+ const kinds = new Map<string, number>();
1755
+ dials.forEach((dial, dialIndex) => {
1756
+ const prior = kinds.get(dial.kind);
1757
+ if (prior !== undefined) {
1758
+ add(
1759
+ ["dials", dialIndex, "kind"],
1760
+ `composition dial kind ${dial.kind} duplicates dials[${prior}]`,
1761
+ "UDL2001",
1762
+ );
1763
+ return;
1201
1764
  }
1202
- definition.steps.forEach((step, stepIndex) =>
1203
- validateStep(
1204
- noun,
1205
- definition,
1206
- step,
1207
- [...verbBase, "steps", stepIndex],
1208
- add,
1209
- ),
1210
- );
1211
- definition.moves.forEach((move, moveIndex) =>
1212
- validateStep(
1213
- noun,
1214
- definition,
1215
- move,
1216
- [...verbBase, "moves", moveIndex],
1217
- add,
1218
- ),
1219
- );
1765
+ kinds.set(dial.kind, dialIndex);
1766
+ });
1767
+ }
1220
1768
 
1221
- if (definition.requiresRefs) {
1222
- addDuplicateIssues(
1223
- definition.requiresRefs.map((gate) => gate.field),
1224
- [...verbBase, "requiresRefs"],
1225
- "gate field",
1226
- add,
1769
+ function validateDials(
1770
+ instrument: UdlInstrument,
1771
+ base: readonly PropertyKey[],
1772
+ add: AddIssue,
1773
+ ): void {
1774
+ const dials = instrument.dials ?? [];
1775
+ addDuplicateIssues(
1776
+ dials.map((dial) => dial.key),
1777
+ [...base, "dials"],
1778
+ "dial key",
1779
+ add,
1780
+ );
1781
+ const targets = new Map<string, number>();
1782
+ dials.forEach((dial, dialIndex) => {
1783
+ const dialBase = [...base, "dials", dialIndex] as const;
1784
+ const target =
1785
+ dial.kind === "window"
1786
+ ? `window:${dial.field}`
1787
+ : dial.kind === "decision_deadline_ms"
1788
+ ? `decision:${dial.action}`
1789
+ : dial.kind === "reconcile_tolerance"
1790
+ ? `reconcile_tolerance:${dial.key}`
1791
+ : "unwind_penalty";
1792
+ const prior = targets.get(target);
1793
+ if (prior !== undefined) {
1794
+ add(
1795
+ [...dialBase, "key"],
1796
+ `dial target ${target} duplicates dials[${prior}]`,
1797
+ "UDL5008",
1227
1798
  );
1228
- definition.requiresRefs.forEach((gate, gateIndex) => {
1229
- addDuplicateIssues(
1799
+ } else {
1800
+ targets.set(target, dialIndex);
1801
+ }
1802
+
1803
+ if (dial.kind === "window") {
1804
+ const anchorsAction = Object.values(instrument.actions).some(
1805
+ (definition) =>
1806
+ definition.due?.field === dial.field ||
1807
+ definition.deadline?.field === dial.field,
1808
+ );
1809
+ if (!anchorsAction) {
1810
+ add(
1811
+ [...dialBase, "field"],
1812
+ `window dial field ${dial.field} anchors no action deadline or due condition`,
1813
+ "UDL5008",
1814
+ );
1815
+ }
1816
+ const min =
1817
+ dial.minOffset === undefined
1818
+ ? undefined
1819
+ : fixedIsoDurationMs(dial.minOffset);
1820
+ const max =
1821
+ dial.maxOffset === undefined
1822
+ ? undefined
1823
+ : fixedIsoDurationMs(dial.maxOffset);
1824
+ if (dial.minOffset !== undefined && min === null) {
1825
+ add(
1826
+ [...dialBase, "minOffset"],
1827
+ "dial minOffset must be a fixed ISO-8601 duration",
1828
+ "UDL5008",
1829
+ );
1830
+ }
1831
+ if (dial.maxOffset !== undefined && max === null) {
1832
+ add(
1833
+ [...dialBase, "maxOffset"],
1834
+ "dial maxOffset must be a fixed ISO-8601 duration",
1835
+ "UDL5008",
1836
+ );
1837
+ }
1838
+ if (
1839
+ min !== undefined &&
1840
+ min !== null &&
1841
+ max !== undefined &&
1842
+ max !== null &&
1843
+ min > max
1844
+ ) {
1845
+ add(
1846
+ [...dialBase, "maxOffset"],
1847
+ "dial minOffset exceeds maxOffset",
1848
+ "UDL5008",
1849
+ );
1850
+ }
1851
+ return;
1852
+ }
1853
+ if (dial.kind === "decision_deadline_ms") {
1854
+ if (!instrument.actions[dial.action]?.decision) {
1855
+ add(
1856
+ [...dialBase, "action"],
1857
+ `decision deadline dial action ${dial.action} declares no decision`,
1858
+ "UDL5008",
1859
+ );
1860
+ }
1861
+ if (
1862
+ dial.minMs !== undefined &&
1863
+ dial.maxMs !== undefined &&
1864
+ dial.minMs > dial.maxMs
1865
+ ) {
1866
+ add([...dialBase, "maxMs"], "dial minMs exceeds maxMs", "UDL5008");
1867
+ }
1868
+ return;
1869
+ }
1870
+ if (dial.kind === "reconcile_tolerance") {
1871
+ const forgives = Object.values(instrument.actions).some((action) =>
1872
+ (action.reconcile ?? []).some(
1873
+ (reconcile) =>
1874
+ reconcile.match.law === "tolerance" &&
1875
+ reconcile.match.dial === dial.key,
1876
+ ),
1877
+ );
1878
+ if (!forgives) {
1879
+ add(
1880
+ dialBase,
1881
+ `reconcile tolerance dial ${dial.key} bounds no reconcile`,
1882
+ "UDL5008",
1883
+ );
1884
+ }
1885
+ return;
1886
+ }
1887
+ if (!Object.values(instrument.actions).some((action) => action.quote)) {
1888
+ add(dialBase, "unwind penalty dial requires a quoting action", "UDL5008");
1889
+ }
1890
+ });
1891
+ }
1892
+
1893
+ function validateActionUpdates(
1894
+ instrument: UdlInstrument,
1895
+ action: string,
1896
+ definition: UdlAction,
1897
+ actionBase: readonly PropertyKey[],
1898
+ add: AddIssue,
1899
+ ): void {
1900
+ if (!definition.updates) return;
1901
+ if (action === "create") {
1902
+ add([...actionBase, "updates"], "create cannot declare updates", "UDL5008");
1903
+ }
1904
+ addDuplicateIssues(
1905
+ definition.updates,
1906
+ [...actionBase, "updates"],
1907
+ "updated field",
1908
+ add,
1909
+ );
1910
+ const inputFields = recordValue(definition.input?.properties);
1911
+ const stepBoundFields = new Set(
1912
+ Object.values(instrument.actions).flatMap((candidate) =>
1913
+ [...candidate.steps, ...candidate.moves].flatMap((step) =>
1914
+ Object.values(step.bind).flatMap((binding) =>
1915
+ binding.from === "instance" && binding.path.startsWith("fields.")
1916
+ ? [binding.path.slice("fields.".length)]
1917
+ : [],
1918
+ ),
1919
+ ),
1920
+ ),
1921
+ );
1922
+ const setsAtFields = new Set(
1923
+ Object.values(instrument.actions).flatMap((candidate) =>
1924
+ candidate.setsAt ? [candidate.setsAt.field] : [],
1925
+ ),
1926
+ );
1927
+ const partitionFields = new Set(
1928
+ (instrument.partitions ?? []).flatMap((partition) => [
1929
+ partition.totalField,
1930
+ ...partition.pieceFields,
1931
+ ]),
1932
+ );
1933
+ const aggregateFields = new Set(
1934
+ (instrument.aggregateInvariants ?? []).map(
1935
+ (invariant) => invariant.parentField,
1936
+ ),
1937
+ );
1938
+ definition.updates.forEach((field, fieldIndex) => {
1939
+ const fieldPath = [...actionBase, "updates", fieldIndex] as const;
1940
+ if (!Object.hasOwn(inputFields, field)) {
1941
+ add(
1942
+ fieldPath,
1943
+ `updated field ${field} is not declared by action input`,
1944
+ "UDL5008",
1945
+ );
1946
+ }
1947
+ if (!Object.hasOwn(instrument.fields, field)) {
1948
+ add(
1949
+ fieldPath,
1950
+ `updated field ${field} is not an instrument field`,
1951
+ "UDL5008",
1952
+ );
1953
+ }
1954
+ if (stepBoundFields.has(field)) {
1955
+ add(
1956
+ fieldPath,
1957
+ `updated field ${field} is bound by a kernel step`,
1958
+ "UDL5008",
1959
+ );
1960
+ }
1961
+ if (setsAtFields.has(field)) {
1962
+ add(fieldPath, `updated field ${field} is owned by setsAt`, "UDL5008");
1963
+ }
1964
+ if (partitionFields.has(field)) {
1965
+ add(
1966
+ fieldPath,
1967
+ `updated field ${field} participates in a partition`,
1968
+ "UDL5008",
1969
+ );
1970
+ }
1971
+ if (aggregateFields.has(field)) {
1972
+ add(fieldPath, `updated field ${field} is an aggregate cap`, "UDL5008");
1973
+ }
1974
+ });
1975
+ if (definition.moves.length > 0) {
1976
+ add(
1977
+ [...actionBase, "updates"],
1978
+ "an action cannot update fields while moving money",
1979
+ "UDL5008",
1980
+ );
1981
+ }
1982
+ }
1983
+
1984
+ function validateCheckRequirements(
1985
+ instrument: UdlInstrument,
1986
+ definition: UdlAction,
1987
+ actionBase: readonly PropertyKey[],
1988
+ add: AddIssue,
1989
+ ): void {
1990
+ const checks = definition.requiresChecks ?? [];
1991
+ addDuplicateIssues(
1992
+ checks.map((check) => `${check.family}:${check.checkKind}`),
1993
+ [...actionBase, "requiresChecks"],
1994
+ "check requirement",
1995
+ add,
1996
+ );
1997
+ checks.forEach((check, checkIndex) => {
1998
+ const checkBase = [...actionBase, "requiresChecks", checkIndex] as const;
1999
+ if (!Object.hasOwn(instrument.fields, check.subjectField)) {
2000
+ add(
2001
+ [...checkBase, "subjectField"],
2002
+ `check subjectField ${check.subjectField} is not declared`,
2003
+ "UDL5002",
2004
+ );
2005
+ }
2006
+ addDuplicateIssues(
2007
+ check.statuses,
2008
+ [...checkBase, "statuses"],
2009
+ "accepted check status",
2010
+ add,
2011
+ );
2012
+ if (check.maxAge && fixedIsoDurationMs(check.maxAge) === null) {
2013
+ add(
2014
+ [...checkBase, "maxAge"],
2015
+ "check maxAge must be a fixed ISO-8601 duration",
2016
+ "UDL5002",
2017
+ );
2018
+ }
2019
+ });
2020
+ }
2021
+
2022
+ function validateRemainder(
2023
+ instrument: UdlInstrument,
2024
+ action: string,
2025
+ definition: UdlAction,
2026
+ actionBase: readonly PropertyKey[],
2027
+ instruments: ReadonlyMap<string, UdlInstrument>,
2028
+ references: ReferenceShapeBudget,
2029
+ add: AddIssue,
2030
+ ): void {
2031
+ const remainder = definition.remainder;
2032
+ if (!remainder) return;
2033
+ const remainderBase = [...actionBase, "remainder"] as const;
2034
+ if (action === "create") {
2035
+ add(remainderBase, "create cannot declare remainder", "UDL4001");
2036
+ }
2037
+ if (definition.distribute || definition.signedSum) {
2038
+ add(
2039
+ remainderBase,
2040
+ "remainder cannot combine with distribute or signedSum",
2041
+ "UDL4001",
2042
+ );
2043
+ }
2044
+ if (
2045
+ remainder.accumulateRef !== undefined &&
2046
+ remainder.accumulateRef === remainder.amountRef
2047
+ ) {
2048
+ add(
2049
+ [...remainderBase, "accumulateRef"],
2050
+ "remainder accumulateRef must differ from amountRef",
2051
+ "UDL4001",
2052
+ );
2053
+ }
2054
+ const [totalRoot, totalKey] = remainder.totalPath.split(".");
2055
+ const totalDeclared =
2056
+ (totalRoot === "fields" &&
2057
+ totalKey !== undefined &&
2058
+ isMoneySchema(instrument.fields[totalKey] ?? {})) ||
2059
+ (totalRoot === "refs" &&
2060
+ totalKey !== undefined &&
2061
+ Object.values(instrument.actions).some(
2062
+ (candidate) => candidate.remainder?.accumulateRef === totalKey,
2063
+ ));
2064
+ if (!totalDeclared) {
2065
+ add(
2066
+ [...remainderBase, "totalPath"],
2067
+ `remainder total ${remainder.totalPath} is not declared money`,
2068
+ "UDL4001",
2069
+ );
2070
+ }
2071
+ if (
2072
+ remainder.inputKey !== undefined &&
2073
+ !Object.hasOwn(
2074
+ recordValue(definition.input?.properties),
2075
+ remainder.inputKey,
2076
+ )
2077
+ ) {
2078
+ add(
2079
+ [...remainderBase, "inputKey"],
2080
+ `remainder inputKey ${remainder.inputKey} is not declared by action input`,
2081
+ "UDL4001",
2082
+ );
2083
+ }
2084
+ const transfers = definition.moves.filter(
2085
+ (move) =>
2086
+ move.operation === "internal_transfer.create" &&
2087
+ move.bind.amount?.from === "instance" &&
2088
+ move.bind.amount.path === `refs.${remainder.amountRef}`,
2089
+ );
2090
+ if (transfers.length !== 1) {
2091
+ add(
2092
+ remainderBase,
2093
+ `remainder requires exactly one internal transfer whose amount is refs.${remainder.amountRef}`,
2094
+ "UDL4001",
2095
+ );
2096
+ }
2097
+ (remainder.collected ?? []).forEach((collected, collectedIndex) => {
2098
+ const collectedBase = [
2099
+ ...remainderBase,
2100
+ "collected",
2101
+ collectedIndex,
2102
+ ] as const;
2103
+ addDuplicateIssues(
2104
+ collected.statuses,
2105
+ [...collectedBase, "statuses"],
2106
+ "collected status",
2107
+ add,
2108
+ );
2109
+ const child = instruments.get(collected.instrumentId);
2110
+ if (!child) {
2111
+ add(
2112
+ [...collectedBase, "instrumentId"],
2113
+ `remainder references unknown instrument ${collected.instrumentId}`,
2114
+ "UDL4001",
2115
+ );
2116
+ return;
2117
+ }
2118
+ const refSchema = child.fields[collected.refField];
2119
+ if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
2120
+ add(
2121
+ [...collectedBase, "refField"],
2122
+ `${child.id}.${collected.refField} must reference ${instrument.id}`,
2123
+ "UDL4001",
2124
+ );
2125
+ }
2126
+ const amountDeclared =
2127
+ collected.path === "refs"
2128
+ ? Object.values(child.actions).some(
2129
+ (candidate) =>
2130
+ candidate.remainder?.amountRef === collected.amountField ||
2131
+ candidate.remainder?.accumulateRef === collected.amountField,
2132
+ )
2133
+ : isMoneySchema(child.fields[collected.amountField] ?? {});
2134
+ if (!amountDeclared) {
2135
+ add(
2136
+ [...collectedBase, "amountField"],
2137
+ `${child.id}.${collected.amountField} is not declared money`,
2138
+ "UDL4001",
2139
+ );
2140
+ }
2141
+ if (
2142
+ collected.path !== "refs" &&
2143
+ !(instrument.aggregateInvariants ?? []).some(
2144
+ (invariant) =>
2145
+ invariant.childInstrumentId === collected.instrumentId &&
2146
+ invariant.childRefField === collected.refField &&
2147
+ "childField" in invariant &&
2148
+ invariant.childField === collected.amountField,
2149
+ )
2150
+ ) {
2151
+ add(
2152
+ collectedBase,
2153
+ `remainder collection ${collected.instrumentId}.${collected.amountField} has no congruent aggregate invariant`,
2154
+ "UDL4001",
2155
+ );
2156
+ }
2157
+ collected.statuses.forEach((status, statusIndex) => {
2158
+ if (!child.lifecycle.states.includes(status)) {
2159
+ add(
2160
+ [...collectedBase, "statuses", statusIndex],
2161
+ `remainder status ${status} is not declared by ${child.id}`,
2162
+ "UDL4001",
2163
+ );
2164
+ }
2165
+ });
2166
+ });
2167
+ }
2168
+
2169
+ function validateActions(
2170
+ instrument: UdlInstrument,
2171
+ base: readonly PropertyKey[],
2172
+ instruments: ReadonlyMap<string, UdlInstrument>,
2173
+ references: ReferenceShapeBudget,
2174
+ add: AddIssue,
2175
+ ): void {
2176
+ validatePayoutsAndSettlement(instrument, base, instruments, references, add);
2177
+ for (const [action, definition] of Object.entries(instrument.actions)) {
2178
+ const actionBase = [...base, "actions", action] as const;
2179
+ if (action === "create" && definition.input) {
2180
+ add(
2181
+ [...actionBase, "input"],
2182
+ "create already consumes instrument fields and must not declare input",
2183
+ "UDL5008",
2184
+ );
2185
+ }
2186
+ if (definition.input && definition.input.type !== "object") {
2187
+ add(
2188
+ [...actionBase, "input", "type"],
2189
+ "action input must declare an object-shaped JSON Schema",
2190
+ "UDL5008",
2191
+ );
2192
+ }
2193
+ const inputFields = recordValue(definition.input?.properties);
2194
+ for (const [refKey, inputKey] of Object.entries(
2195
+ definition.captureInput ?? {},
2196
+ )) {
2197
+ if (!Object.hasOwn(inputFields, inputKey)) {
2198
+ add(
2199
+ [...actionBase, "captureInput", refKey],
2200
+ `captured receipt input references undeclared action input field ${inputKey}`,
2201
+ "UDL5008",
2202
+ );
2203
+ }
2204
+ }
2205
+
2206
+ validateDecidedAmount(instrument, action, actionBase, add);
2207
+ validateActionUpdates(instrument, action, definition, actionBase, add);
2208
+ validateCheckRequirements(instrument, definition, actionBase, add);
2209
+ validateRemainder(
2210
+ instrument,
2211
+ action,
2212
+ definition,
2213
+ actionBase,
2214
+ instruments,
2215
+ references,
2216
+ add,
2217
+ );
2218
+
2219
+ addDuplicateIssues(
2220
+ definition.moves.map((move) => move.key),
2221
+ [...actionBase, "moves"],
2222
+ "move key",
2223
+ add,
2224
+ );
2225
+ if (definition.earnable && definition.moves.length === 0) {
2226
+ add(actionBase, "earnable requires a money move", "UDL5008");
2227
+ }
2228
+ definition.steps.forEach((step, stepIndex) =>
2229
+ validateStep(
2230
+ instrument,
2231
+ definition,
2232
+ step,
2233
+ [...actionBase, "steps", stepIndex],
2234
+ add,
2235
+ ),
2236
+ );
2237
+ definition.moves.forEach((move, moveIndex) =>
2238
+ validateStep(
2239
+ instrument,
2240
+ definition,
2241
+ move,
2242
+ [...actionBase, "moves", moveIndex],
2243
+ add,
2244
+ ),
2245
+ );
2246
+
2247
+ if (definition.requiresRefs) {
2248
+ addDuplicateIssues(
2249
+ definition.requiresRefs.map((gate) => gate.field),
2250
+ [...actionBase, "requiresRefs"],
2251
+ "gate field",
2252
+ add,
2253
+ );
2254
+ definition.requiresRefs.forEach((gate, gateIndex) => {
2255
+ addDuplicateIssues(
1230
2256
  gate.statuses,
1231
- [...verbBase, "requiresRefs", gateIndex, "statuses"],
2257
+ [...actionBase, "requiresRefs", gateIndex, "statuses"],
1232
2258
  "gate status",
1233
2259
  add,
1234
2260
  );
1235
- const schema = noun.fields[gate.field];
2261
+ const schema = instrument.fields[gate.field];
1236
2262
  if (!schema) {
1237
2263
  add(
1238
- [...verbBase, "requiresRefs", gateIndex, "field"],
2264
+ [...actionBase, "requiresRefs", gateIndex, "field"],
1239
2265
  `gate references unknown field ${gate.field}`,
2266
+ "UDL5001",
1240
2267
  );
1241
2268
  return;
1242
2269
  }
1243
- const targets = [...nouns.values()].filter((target) =>
2270
+ const targets = [...instruments.values()].filter((target) =>
1244
2271
  references.accepts(schema, target.idPrefix),
1245
2272
  );
1246
2273
  if (targets.length !== 1) {
1247
2274
  add(
1248
- [...verbBase, "requiresRefs", gateIndex, "field"],
1249
- `gate field ${gate.field} must identify exactly one noun (found ${targets.length})`,
2275
+ [...actionBase, "requiresRefs", gateIndex, "field"],
2276
+ `gate field ${gate.field} must identify exactly one instrument (found ${targets.length})`,
2277
+ "UDL5001",
1250
2278
  );
1251
2279
  return;
1252
2280
  }
1253
- const target = targets[0] as UdlNoun;
2281
+ const target = targets[0] as UdlInstrument;
1254
2282
  gate.statuses.forEach((status, statusIndex) => {
1255
2283
  if (!target.lifecycle.states.includes(status)) {
1256
2284
  add(
1257
- [...verbBase, "requiresRefs", gateIndex, "statuses", statusIndex],
2285
+ [
2286
+ ...actionBase,
2287
+ "requiresRefs",
2288
+ gateIndex,
2289
+ "statuses",
2290
+ statusIndex,
2291
+ ],
1258
2292
  `gate status ${status} is not declared by ${target.id}`,
2293
+ "UDL5008",
1259
2294
  );
1260
2295
  }
1261
2296
  });
1262
2297
  validateGateShape(
1263
- noun,
1264
- verb,
2298
+ instrument,
2299
+ action,
1265
2300
  gate,
1266
2301
  target,
1267
- [...verbBase, "requiresRefs", gateIndex],
2302
+ [...actionBase, "requiresRefs", gateIndex],
1268
2303
  add,
1269
2304
  );
1270
2305
  });
1271
2306
  }
1272
2307
 
1273
2308
  if (definition.requiresDrainedAccount) {
1274
- const drainBase = [...verbBase, "requiresDrainedAccount"] as const;
1275
- if (verb === "create") {
2309
+ const drainBase = [...actionBase, "requiresDrainedAccount"] as const;
2310
+ if (action === "create") {
1276
2311
  add(
1277
2312
  [...drainBase],
1278
2313
  "requiresDrainedAccount cannot gate create: no account exists yet",
2314
+ "UDL5008",
1279
2315
  );
1280
2316
  }
1281
2317
  const path = definition.requiresDrainedAccount.path;
1282
2318
  const declared =
1283
2319
  (path.startsWith("fields.") &&
1284
- Object.hasOwn(noun.fields, path.slice("fields.".length))) ||
2320
+ Object.hasOwn(instrument.fields, path.slice("fields.".length))) ||
1285
2321
  (path.startsWith("refs.") &&
1286
- declaredRefKeys(noun).has(path.slice("refs.".length)));
2322
+ declaredRefKeys(instrument).has(path.slice("refs.".length)));
1287
2323
  if (!declared) {
1288
2324
  add(
1289
2325
  [...drainBase, "path"],
1290
2326
  `requiresDrainedAccount reads ${path}, which is not declared`,
2327
+ "UDL5008",
1291
2328
  );
1292
2329
  }
1293
2330
  }
1294
2331
 
1295
2332
  if (definition.signedSum) {
1296
- const sumBase = [...verbBase, "signedSum"] as const;
1297
- if (verb === "create") {
2333
+ const sumBase = [...actionBase, "signedSum"] as const;
2334
+ if (action === "create") {
1298
2335
  add(
1299
2336
  sumBase,
1300
2337
  "signedSum cannot run on create: no parent row exists yet",
2338
+ "UDL5008",
1301
2339
  );
1302
2340
  }
1303
2341
  const generatedRefs = [
@@ -1317,48 +2355,56 @@ function validateVerbs(
1317
2355
  add,
1318
2356
  );
1319
2357
  const existingRefs = new Set([
1320
- ...Object.entries(noun.verbs).flatMap(([candidateVerb, candidate]) => [
1321
- ...Object.keys(candidate.captureInput ?? {}),
1322
- ...[...candidate.steps, ...candidate.moves].flatMap((step) =>
1323
- Object.keys(step.capture ?? {}),
1324
- ),
1325
- ...(candidateVerb !== verb && candidate.signedSum
1326
- ? [
1327
- candidate.signedSum.amountRef,
1328
- ...candidate.signedSum.sources.map(
1329
- (source) => source.subtotalRef,
1330
- ),
1331
- ]
1332
- : []),
1333
- ]),
1334
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1335
- ...(noun.subject ? ["subject"] : []),
2358
+ ...Object.entries(instrument.actions).flatMap(
2359
+ ([candidateAction, candidate]) => [
2360
+ ...Object.keys(candidate.captureInput ?? {}),
2361
+ ...[...candidate.steps, ...candidate.moves].flatMap((step) =>
2362
+ Object.keys(step.capture ?? {}),
2363
+ ),
2364
+ ...(candidateAction !== action && candidate.signedSum
2365
+ ? [
2366
+ candidate.signedSum.amountRef,
2367
+ ...candidate.signedSum.sources.map(
2368
+ (source) => source.subtotalRef,
2369
+ ),
2370
+ ]
2371
+ : []),
2372
+ ],
2373
+ ),
2374
+ ...quoteRefKeys(instrument),
2375
+ ...(instrument.subject ? ["subject"] : []),
1336
2376
  ]);
1337
2377
  for (const ref of generatedRefs) {
1338
2378
  if (existingRefs.has(ref.key)) {
1339
2379
  add(
1340
2380
  ref.path,
1341
- `signed sum ref ${ref.key} collides with an existing noun ref key`,
2381
+ `signed sum ref ${ref.key} collides with an existing instrument ref key`,
2382
+ "UDL5008",
1342
2383
  );
1343
2384
  }
1344
2385
  }
1345
2386
  if (
1346
2387
  !definition.signedSum.sources.some((source) => source.sign === "add")
1347
2388
  ) {
1348
- add([...sumBase, "sources"], "signedSum needs at least one add source");
2389
+ add(
2390
+ [...sumBase, "sources"],
2391
+ "signedSum needs at least one add source",
2392
+ "UDL5008",
2393
+ );
1349
2394
  }
1350
- const parentCurrencies = Object.entries(noun.fields).filter(
2395
+ const parentCurrencies = Object.entries(instrument.fields).filter(
1351
2396
  ([, schema]) => isCurrencySchema(schema),
1352
2397
  );
1353
2398
  if (parentCurrencies.length !== 1) {
1354
2399
  add(
1355
2400
  sumBase,
1356
- `signedSum parent ${noun.id} needs exactly one currency field`,
2401
+ `signedSum parent ${instrument.id} needs exactly one currency field`,
2402
+ "UDL5008",
1357
2403
  );
1358
2404
  }
1359
2405
  const sourceKeys = definition.signedSum.sources.map(
1360
2406
  (source) =>
1361
- `${source.nounId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`,
2407
+ `${source.instrumentId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`,
1362
2408
  );
1363
2409
  addDuplicateIssues(
1364
2410
  sourceKeys,
@@ -1371,7 +2417,7 @@ function validateVerbs(
1371
2417
  const prior = definition.signedSum?.sources[priorIndex];
1372
2418
  if (
1373
2419
  !prior ||
1374
- prior.nounId !== source.nounId ||
2420
+ prior.instrumentId !== source.instrumentId ||
1375
2421
  prior.refField !== source.refField ||
1376
2422
  prior.amountField !== source.amountField ||
1377
2423
  prior.sign !== source.sign
@@ -1386,31 +2432,35 @@ function validateVerbs(
1386
2432
  add(
1387
2433
  [...sumBase, "sources", sourceIndex, "statuses"],
1388
2434
  `signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`,
2435
+ "UDL5008",
1389
2436
  );
1390
2437
  }
1391
2438
  });
1392
2439
  definition.signedSum.sources.forEach((source, sourceIndex) => {
1393
2440
  const sourceBase = [...sumBase, "sources", sourceIndex] as const;
1394
- const child = nouns.get(source.nounId);
2441
+ const child = instruments.get(source.instrumentId);
1395
2442
  if (!child) {
1396
2443
  add(
1397
- [...sourceBase, "nounId"],
1398
- `signedSum references unknown noun ${source.nounId}`,
2444
+ [...sourceBase, "instrumentId"],
2445
+ `signedSum references unknown instrument ${source.instrumentId}`,
2446
+ "UDL5008",
1399
2447
  );
1400
2448
  return;
1401
2449
  }
1402
2450
  const refSchema = child.fields[source.refField];
1403
- if (!refSchema || !references.accepts(refSchema, noun.idPrefix)) {
2451
+ if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
1404
2452
  add(
1405
2453
  [...sourceBase, "refField"],
1406
- `${source.nounId}.${source.refField} must reference ${noun.id}`,
2454
+ `${source.instrumentId}.${source.refField} must reference ${instrument.id}`,
2455
+ "UDL5008",
1407
2456
  );
1408
2457
  }
1409
2458
  const amountSchema = child.fields[source.amountField];
1410
2459
  if (!amountSchema || !isMoneySchema(amountSchema)) {
1411
2460
  add(
1412
2461
  [...sourceBase, "amountField"],
1413
- `${source.nounId}.${source.amountField} must be a money field`,
2462
+ `${source.instrumentId}.${source.amountField} must be a money field`,
2463
+ "UDL5008",
1414
2464
  );
1415
2465
  }
1416
2466
  const childCurrencies = Object.entries(child.fields).filter(
@@ -1418,8 +2468,9 @@ function validateVerbs(
1418
2468
  );
1419
2469
  if (childCurrencies.length !== 1) {
1420
2470
  add(
1421
- [...sourceBase, "nounId"],
1422
- `signedSum source ${source.nounId} needs exactly one currency field`,
2471
+ [...sourceBase, "instrumentId"],
2472
+ `signedSum source ${source.instrumentId} needs exactly one currency field`,
2473
+ "UDL5008",
1423
2474
  );
1424
2475
  }
1425
2476
  addDuplicateIssues(
@@ -1432,65 +2483,78 @@ function validateVerbs(
1432
2483
  if (!child.lifecycle.states.includes(status)) {
1433
2484
  add(
1434
2485
  [...sourceBase, "statuses", statusIndex],
1435
- `signedSum status ${status} is not declared by ${source.nounId}`,
2486
+ `signedSum status ${status} is not declared by ${source.instrumentId}`,
2487
+ "UDL5008",
1436
2488
  );
1437
2489
  }
1438
2490
  });
1439
2491
  });
1440
2492
  const amountPath = `refs.${definition.signedSum.amountRef}`;
1441
- const payoutMoves = Object.values(noun.verbs).flatMap((candidate) =>
1442
- candidate.moves.filter(
1443
- (move) =>
1444
- move.operation === "internal_transfer.create" &&
1445
- move.bind.amount?.from === "instance" &&
1446
- move.bind.amount.path === amountPath,
1447
- ),
2493
+ const payoutMoves = Object.values(instrument.actions).flatMap(
2494
+ (candidate) =>
2495
+ candidate.moves.filter(
2496
+ (move) =>
2497
+ move.operation === "internal_transfer.create" &&
2498
+ move.bind.amount?.from === "instance" &&
2499
+ move.bind.amount.path === amountPath,
2500
+ ),
1448
2501
  );
1449
- const payouts = Object.values(noun.verbs).filter(
2502
+ const payouts = Object.values(instrument.actions).filter(
1450
2503
  (candidate) => candidate.payout?.amount === amountPath,
1451
2504
  );
1452
2505
  if (payoutMoves.length + payouts.length !== 1) {
1453
2506
  add(
1454
2507
  sumBase,
1455
- `signedSum requires exactly one payout or noun transfer whose amount is ${amountPath}`,
2508
+ `signedSum requires exactly one payout or instrument transfer whose amount is ${amountPath}`,
2509
+ "UDL5008",
1456
2510
  );
1457
2511
  }
1458
2512
  }
1459
2513
 
1460
2514
  if (definition.due) {
1461
- if (definition.publicIntent) {
2515
+ if (definition.publicAction) {
1462
2516
  add(
1463
- [...verbBase, "publicIntent"],
1464
- "a system due verb cannot declare a public intent",
2517
+ [...actionBase, "publicAction"],
2518
+ "a system due action cannot declare a public action",
2519
+ "UDL3001",
1465
2520
  );
1466
2521
  }
1467
- const transition = noun.lifecycle.transitions[verb];
2522
+ const transition = instrument.lifecycle.transitions[action];
1468
2523
  if (definition.due.every) {
1469
- // A recurring due verb is stationary: each occurrence fires in place
1470
- // and the runtime keeps the instance's current status, so `to` is
1471
- // only a witness naming one of the source states.
1472
- if (!transition || !transition.from.includes(transition.to)) {
2524
+ const serialLiability = definition.due.every.liability === "one_open";
2525
+ if (
2526
+ !transition ||
2527
+ (serialLiability
2528
+ ? transition.from.includes(transition.to)
2529
+ : !transition.from.includes(transition.to))
2530
+ ) {
1473
2531
  add(
1474
- [...verbBase, "due"],
1475
- "a recurring due verb must be stationary: its transition's to must name one of its from states",
2532
+ [...actionBase, "due"],
2533
+ serialLiability
2534
+ ? "a one-open recurring due action must leave its source states while the period liability is open"
2535
+ : "a recurring due action must be stationary: its transition's to must name one of its from states",
2536
+ "UDL5008",
1476
2537
  );
1477
2538
  }
1478
2539
  } else if (!transition || transition.from.includes(transition.to)) {
1479
2540
  add(
1480
- [...verbBase, "due"],
1481
- "a due verb must leave every source state so the maintenance loop fires its anchor exactly once",
2541
+ [...actionBase, "due"],
2542
+ "a due action must leave every source state so the maintenance loop fires its anchor exactly once",
2543
+ "UDL3001",
1482
2544
  );
1483
2545
  }
1484
- const schema = noun.fields[definition.due.field];
2546
+ const schema = instrument.fields[definition.due.field];
1485
2547
  if (!schema) {
1486
2548
  add(
1487
- [...verbBase, "due", "field"],
2549
+ [...actionBase, "due", "field"],
1488
2550
  `due condition references unknown field ${definition.due.field}`,
2551
+ "UDL3001",
1489
2552
  );
1490
2553
  } else if (!isDateTimeFormat(schema.format)) {
1491
2554
  add(
1492
- [...verbBase, "due", "field"],
2555
+ [...actionBase, "due", "field"],
1493
2556
  "due field must be a date-time field",
2557
+ "UDL5008",
1494
2558
  );
1495
2559
  }
1496
2560
  if (
@@ -1498,29 +2562,34 @@ function validateVerbs(
1498
2562
  fixedIsoDurationMs(definition.due.offset) === null
1499
2563
  ) {
1500
2564
  add(
1501
- [...verbBase, "due", "offset"],
2565
+ [...actionBase, "due", "offset"],
1502
2566
  "due.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
2567
+ "UDL3001",
1503
2568
  );
1504
2569
  }
2570
+ validateDueRecurrence(instrument, action, actionBase, add);
1505
2571
  }
1506
2572
 
1507
2573
  if (definition.deadline) {
1508
2574
  if (definition.due) {
1509
2575
  add(
1510
- [...verbBase, "deadline"],
1511
- "a verb cannot declare both a due condition and a deadline",
2576
+ [...actionBase, "deadline"],
2577
+ "a action cannot declare both a due condition and a deadline",
2578
+ "UDL3001",
1512
2579
  );
1513
2580
  }
1514
- const schema = noun.fields[definition.deadline.field];
2581
+ const schema = instrument.fields[definition.deadline.field];
1515
2582
  if (!schema) {
1516
2583
  add(
1517
- [...verbBase, "deadline", "field"],
2584
+ [...actionBase, "deadline", "field"],
1518
2585
  `deadline references unknown field ${definition.deadline.field}`,
2586
+ "UDL3001",
1519
2587
  );
1520
2588
  } else if (!isDateTimeFormat(schema.format)) {
1521
2589
  add(
1522
- [...verbBase, "deadline", "field"],
2590
+ [...actionBase, "deadline", "field"],
1523
2591
  "deadline field must be a date-time field",
2592
+ "UDL5008",
1524
2593
  );
1525
2594
  }
1526
2595
  if (
@@ -1528,8 +2597,9 @@ function validateVerbs(
1528
2597
  fixedIsoDurationMs(definition.deadline.offset) === null
1529
2598
  ) {
1530
2599
  add(
1531
- [...verbBase, "deadline", "offset"],
2600
+ [...actionBase, "deadline", "offset"],
1532
2601
  "deadline.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
2602
+ "UDL3001",
1533
2603
  );
1534
2604
  }
1535
2605
  }
@@ -1537,249 +2607,989 @@ function validateVerbs(
1537
2607
  if (definition.examples) {
1538
2608
  addDuplicateIssues(
1539
2609
  definition.examples.map((example) => example.name),
1540
- [...verbBase, "examples"],
2610
+ [...actionBase, "examples"],
1541
2611
  "example name",
1542
2612
  add,
1543
2613
  );
1544
- const inputSchema = exampleInputSchema(noun, verb, definition);
2614
+ const inputSchema = exampleInputSchema(instrument, action, definition);
1545
2615
  definition.examples.forEach((example, exampleIndex) => {
1546
- for (const error of validateUdlSchemaValue(inputSchema, example.input)
1547
- .errors) {
2616
+ const input = exampleInputForUdlValidation(instrument, example.input);
2617
+ for (const error of validateUdlSchemaValue(inputSchema, input).errors) {
1548
2618
  add(
1549
- [...verbBase, "examples", exampleIndex, "input"],
2619
+ [...actionBase, "examples", exampleIndex, "input"],
1550
2620
  `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`,
2621
+ "UDL5008",
1551
2622
  );
1552
2623
  }
1553
2624
  });
1554
2625
  }
1555
2626
  }
2627
+ validateRecurringLiabilityOverlap(instrument, base, add);
2628
+ }
2629
+
2630
+ function validateDueRecurrence(
2631
+ instrument: UdlInstrument,
2632
+ action: string,
2633
+ actionBase: readonly PropertyKey[],
2634
+ add: AddIssue,
2635
+ ): void {
2636
+ const definition = instrument.actions[action];
2637
+ const every = definition?.due?.every;
2638
+ if (!definition || !every) return;
2639
+
2640
+ const recurrenceBase = [...actionBase, "due", "every"] as const;
2641
+ if (
2642
+ typeof every.period === "string" &&
2643
+ fixedIsoDurationMs(every.period) === null
2644
+ ) {
2645
+ add(
2646
+ [...recurrenceBase, "period"],
2647
+ "recurrence period must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
2648
+ "UDL3001",
2649
+ );
2650
+ }
2651
+
2652
+ const terminations = [
2653
+ every.countField === undefined ? undefined : "countField",
2654
+ every.untilField === undefined ? undefined : "untilField",
2655
+ every.untilAction === undefined ? undefined : "untilAction",
2656
+ ].filter((value): value is string => value !== undefined);
2657
+ if (terminations.length === 0) {
2658
+ add(
2659
+ recurrenceBase,
2660
+ "recurrence must declare a termination using countField, untilField, or untilAction",
2661
+ "UDL3001",
2662
+ );
2663
+ } else if (terminations.length > 1) {
2664
+ add(
2665
+ recurrenceBase,
2666
+ `recurrence termination is ambiguous: ${terminations.join(", ")}`,
2667
+ "UDL3001",
2668
+ );
2669
+ }
2670
+ if (
2671
+ (every.untilField !== undefined || every.untilAction !== undefined) &&
2672
+ every.liability !== "one_open"
2673
+ ) {
2674
+ add(
2675
+ [...recurrenceBase, "liability"],
2676
+ "open-ended recurrence must declare liability one_open",
2677
+ "UDL3001",
2678
+ );
2679
+ }
2680
+ if (every.liability === "one_open" && every.delinquency !== "parent_policy") {
2681
+ add(
2682
+ [...recurrenceBase, "delinquency"],
2683
+ "one-open recurrence must reuse delinquency parent_policy",
2684
+ "UDL3001",
2685
+ );
2686
+ }
2687
+ if (
2688
+ (every.untilField !== undefined || every.untilAction !== undefined) &&
2689
+ every.drainAction === undefined
2690
+ ) {
2691
+ add(
2692
+ [...recurrenceBase, "drainAction"],
2693
+ "open-ended recurrence must declare its drain action",
2694
+ "UDL3001",
2695
+ );
2696
+ }
2697
+
2698
+ if (every.countField) {
2699
+ const count = instrument.fields[every.countField];
2700
+ if (!count) {
2701
+ add(
2702
+ [...recurrenceBase, "countField"],
2703
+ `recurrence count references unknown field ${every.countField}`,
2704
+ "UDL3001",
2705
+ );
2706
+ } else if (count.type !== "integer") {
2707
+ add(
2708
+ [...recurrenceBase, "countField"],
2709
+ "recurrence count field must be an integer field",
2710
+ "UDL3001",
2711
+ );
2712
+ } else if (!instrument.required.includes(every.countField)) {
2713
+ add(
2714
+ [...recurrenceBase, "countField"],
2715
+ "recurrence count field must be required",
2716
+ "UDL3001",
2717
+ );
2718
+ } else if (instrument.update?.fields.includes(every.countField)) {
2719
+ add(
2720
+ [...recurrenceBase, "countField"],
2721
+ "recurrence count field cannot be mutable",
2722
+ "UDL3001",
2723
+ );
2724
+ }
2725
+ }
2726
+
2727
+ if (every.untilField) {
2728
+ const until = instrument.fields[every.untilField];
2729
+ if (!until) {
2730
+ add(
2731
+ [...recurrenceBase, "untilField"],
2732
+ `recurrence termination references unknown field ${every.untilField}`,
2733
+ "UDL3001",
2734
+ );
2735
+ } else if (!isDateTimeFormat(until.format)) {
2736
+ add(
2737
+ [...recurrenceBase, "untilField"],
2738
+ "recurrence termination field must be a date-time field",
2739
+ "UDL3001",
2740
+ );
2741
+ } else if (!instrument.required.includes(every.untilField)) {
2742
+ add(
2743
+ [...recurrenceBase, "untilField"],
2744
+ "recurrence termination field must be required",
2745
+ "UDL3001",
2746
+ );
2747
+ } else if (instrument.update?.fields.includes(every.untilField)) {
2748
+ add(
2749
+ [...recurrenceBase, "untilField"],
2750
+ "recurrence termination field cannot be mutable",
2751
+ "UDL3001",
2752
+ );
2753
+ }
2754
+ }
2755
+
2756
+ if (
2757
+ every.liability === "one_open" &&
2758
+ (definition.moves.length > 0 || definition.payout !== undefined)
2759
+ ) {
2760
+ add(
2761
+ [...actionBase, "moves"],
2762
+ "a recurring due action cannot move money; it may only open one period or invoke a declared system action",
2763
+ "UDL3001",
2764
+ );
2765
+ }
2766
+
2767
+ if (every.untilAction) {
2768
+ if (every.drainAction && every.drainAction !== every.untilAction) {
2769
+ add(
2770
+ [...recurrenceBase, "drainAction"],
2771
+ "port recurrence termination must use untilAction as its drainAction",
2772
+ "UDL3001",
2773
+ );
2774
+ }
2775
+ validateRecurrenceTerminationAction(
2776
+ instrument,
2777
+ action,
2778
+ every.untilAction,
2779
+ recurrenceBase,
2780
+ add,
2781
+ );
2782
+ } else if (every.untilField) {
2783
+ const drainAction = every.drainAction;
2784
+ const drainDefinition = drainAction
2785
+ ? instrument.actions[drainAction]
2786
+ : undefined;
2787
+ if (
2788
+ drainAction &&
2789
+ (!drainDefinition ||
2790
+ drainDefinition.due?.every !== undefined ||
2791
+ drainDefinition.due?.field !== every.untilField ||
2792
+ !recurrenceActionExits(instrument, action, drainAction))
2793
+ ) {
2794
+ add(
2795
+ [...recurrenceBase, "drainAction"],
2796
+ `stored-date recurrence drain action ${drainAction} must be a one-shot due action on ${every.untilField} that exits every recurring state`,
2797
+ "UDL3001",
2798
+ );
2799
+ } else if (
2800
+ drainAction &&
2801
+ drainDefinition &&
2802
+ instrumentOwnsReservation(instrument) &&
2803
+ !actionDrains(drainDefinition)
2804
+ ) {
2805
+ add(
2806
+ [...actionPath(recurrenceBase, drainAction), "moves"],
2807
+ `recurrence termination action ${drainAction} must drain its reserved money`,
2808
+ "UDL3001",
2809
+ );
2810
+ }
2811
+ if (
2812
+ drainAction &&
2813
+ drainDefinition &&
2814
+ (drainDefinition.payout !== undefined ||
2815
+ drainDefinition.moves.some(
2816
+ (move) => move.operation !== "internal_transfer.void",
2817
+ ))
2818
+ ) {
2819
+ add(
2820
+ [...actionPath(recurrenceBase, drainAction), "moves"],
2821
+ "a stored-date recurrence termination may only void reserved money; no timeout creates, reserves, posts, or pays out money",
2822
+ "UDL3001",
2823
+ );
2824
+ }
2825
+ }
2826
+ }
2827
+
2828
+ function validateRecurrenceTerminationAction(
2829
+ instrument: UdlInstrument,
2830
+ recurringAction: string,
2831
+ terminationAction: string,
2832
+ recurrenceBase: readonly PropertyKey[],
2833
+ add: AddIssue,
2834
+ ): void {
2835
+ const definition = instrument.actions[terminationAction];
2836
+ const terminationPath = actionPath(recurrenceBase, terminationAction);
2837
+ if (!definition) {
2838
+ add(
2839
+ [...recurrenceBase, "untilAction"],
2840
+ `recurrence termination references unknown action ${terminationAction}`,
2841
+ "UDL3001",
2842
+ );
2843
+ return;
2844
+ }
2845
+ if (!definition.port) {
2846
+ add(
2847
+ [...recurrenceBase, "untilAction"],
2848
+ `recurrence termination action ${terminationAction} must declare a port`,
2849
+ "UDL3001",
2850
+ );
2851
+ }
2852
+ if (!recurrenceActionExits(instrument, recurringAction, terminationAction)) {
2853
+ add(
2854
+ [...recurrenceBase, "untilAction"],
2855
+ `recurrence termination action ${terminationAction} must exit every recurring source state`,
2856
+ "UDL3001",
2857
+ );
2858
+ }
2859
+ if (instrumentOwnsReservation(instrument) && !actionDrains(definition)) {
2860
+ add(
2861
+ [...terminationPath, "moves"],
2862
+ `recurrence termination action ${terminationAction} must drain its reserved money`,
2863
+ "UDL3001",
2864
+ );
2865
+ }
2866
+ }
2867
+
2868
+ function actionPath(
2869
+ recurrenceBase: readonly PropertyKey[],
2870
+ action: string,
2871
+ ): readonly PropertyKey[] {
2872
+ return [...recurrenceBase.slice(0, -3), action];
2873
+ }
2874
+
2875
+ function recurrenceActionExits(
2876
+ instrument: UdlInstrument,
2877
+ recurringAction: string,
2878
+ terminationAction: string,
2879
+ ): boolean {
2880
+ const recurring = instrument.lifecycle.transitions[recurringAction];
2881
+ const termination = instrument.lifecycle.transitions[terminationAction];
2882
+ if (!recurring || !termination) return false;
2883
+ const recurringStates = new Set([...recurring.from, recurring.to]);
2884
+ return (
2885
+ [...recurringStates].every((state) => termination.from.includes(state)) &&
2886
+ !recurringStates.has(termination.to)
2887
+ );
2888
+ }
2889
+
2890
+ function actionDrains(definition: UdlAction): boolean {
2891
+ return (
2892
+ definition.requiresDrainedAccount !== undefined ||
2893
+ definition.moves.some((move) => move.operation === "internal_transfer.void")
2894
+ );
2895
+ }
2896
+
2897
+ function instrumentOwnsReservation(instrument: UdlInstrument): boolean {
2898
+ return Object.values(instrument.actions).some((definition) =>
2899
+ definition.moves.some(
2900
+ (move) => move.operation === "internal_transfer.reserve",
2901
+ ),
2902
+ );
2903
+ }
2904
+
2905
+ function validateRecurringLiabilityOverlap(
2906
+ instrument: UdlInstrument,
2907
+ base: readonly PropertyKey[],
2908
+ add: AddIssue,
2909
+ ): void {
2910
+ const recurring = Object.entries(instrument.actions).filter(
2911
+ ([, definition]) => definition.due?.every?.liability === "one_open",
2912
+ );
2913
+ for (const [index, [action, definition]] of recurring.entries()) {
2914
+ const transition = instrument.lifecycle.transitions[action];
2915
+ if (!transition || !definition.due) continue;
2916
+ for (let priorIndex = 0; priorIndex < index; priorIndex += 1) {
2917
+ const [priorAction, priorDefinition] = recurring[
2918
+ priorIndex
2919
+ ] as (typeof recurring)[number];
2920
+ const priorTransition = instrument.lifecycle.transitions[priorAction];
2921
+ if (
2922
+ !priorTransition ||
2923
+ priorDefinition.due?.field !== definition.due.field
2924
+ ) {
2925
+ continue;
2926
+ }
2927
+ const priorStates = new Set(priorTransition.from);
2928
+ const overlap = transition.from.filter((state) => priorStates.has(state));
2929
+ if (overlap.length === 0) continue;
2930
+ add(
2931
+ [...base, "actions", action, "due", "every"],
2932
+ `recurring due actions ${priorAction} and ${action} overlap period liability in states ${overlap.join(", ")}`,
2933
+ "UDL3001",
2934
+ );
2935
+ }
2936
+ }
2937
+ }
2938
+
2939
+ function validateDecidedAmount(
2940
+ instrument: UdlInstrument,
2941
+ action: string,
2942
+ actionBase: readonly PropertyKey[],
2943
+ add: AddIssue,
2944
+ ): void {
2945
+ const definition = instrument.actions[action];
2946
+ const clause = definition?.decidedAmount;
2947
+ if (!definition || !clause) return;
2948
+
2949
+ const clauseBase = [...actionBase, "decidedAmount"] as const;
2950
+ const inputField = recordValue(definition.input?.properties)[clause.field];
2951
+ if (!inputField || !isMoneySchema(recordValue(inputField))) {
2952
+ add(
2953
+ [...clauseBase, "field"],
2954
+ `decided amount field ${clause.field} must be a declared action input money field`,
2955
+ "UDL4001",
2956
+ );
2957
+ }
2958
+ const inputRequired = definition.input?.required;
2959
+ if (!Array.isArray(inputRequired) || !inputRequired.includes(clause.field)) {
2960
+ add(
2961
+ [...clauseBase, "field"],
2962
+ `decided amount field ${clause.field} must be required by the action input`,
2963
+ "UDL2002",
2964
+ );
2965
+ }
2966
+
2967
+ const boundSchema = instrument.fields[clause.boundField];
2968
+ if (!boundSchema || !isMoneySchema(boundSchema)) {
2969
+ add(
2970
+ [...clauseBase, "boundField"],
2971
+ `decided amount bound ${clause.boundField} must be a declared instrument money field`,
2972
+ "UDL4001",
2973
+ );
2974
+ }
2975
+ if (!instrument.required.includes(clause.boundField)) {
2976
+ add(
2977
+ [...clauseBase, "boundField"],
2978
+ `decided amount bound ${clause.boundField} must be required`,
2979
+ "UDL2002",
2980
+ );
2981
+ }
2982
+ if (instrument.update?.fields.includes(clause.boundField)) {
2983
+ add(
2984
+ [...clauseBase, "boundField"],
2985
+ `decided amount bound ${clause.boundField} cannot be mutable`,
2986
+ "UDL5008",
2987
+ );
2988
+ }
2989
+
2990
+ const decidedMoves = definition.moves.filter(
2991
+ (move) =>
2992
+ move.operation === "internal_transfer.post" &&
2993
+ move.bind.amount?.from === "input" &&
2994
+ move.bind.amount.path === clause.field,
2995
+ );
2996
+ if (decidedMoves.length !== 1) {
2997
+ add(
2998
+ [...clauseBase, "field"],
2999
+ `decided amount field ${clause.field} must fund exactly one internal_transfer.post move`,
3000
+ "UDL4001",
3001
+ );
3002
+ }
3003
+ const decidedMove = decidedMoves.length === 1 ? decidedMoves[0] : undefined;
3004
+ if (decidedMove) {
3005
+ const moveIndex = definition.moves.indexOf(decidedMove);
3006
+ const postMode = decidedMove.bind.postMode;
3007
+ if (postMode?.from !== "const" || postMode.value !== "partial_only") {
3008
+ add(
3009
+ [...actionBase, "moves", moveIndex, "bind", "postMode"],
3010
+ "decided amount post must use partial_only so the remainder stays reserved",
3011
+ "UDL5008",
3012
+ );
3013
+ }
3014
+ const currencyFields = Object.entries(instrument.fields).filter(
3015
+ ([, schema]) => isCurrencySchema(schema),
3016
+ );
3017
+ const currency = decidedMove.bind.currency;
3018
+ if (
3019
+ currencyFields.length !== 1 ||
3020
+ currency?.from !== "instance" ||
3021
+ currency.path !== `fields.${currencyFields[0]?.[0]}`
3022
+ ) {
3023
+ add(
3024
+ [...actionBase, "moves", moveIndex, "bind", "currency"],
3025
+ "decided amount post must bind the instrument currency",
3026
+ "UDL5008",
3027
+ );
3028
+ }
3029
+ }
3030
+
3031
+ const remainderMoves = definition.moves.filter(
3032
+ (move) => move.key === "remainder",
3033
+ );
3034
+ if (remainderMoves.length !== 1) {
3035
+ add(
3036
+ [...actionBase, "moves"],
3037
+ `decided amount action must declare exactly one remainder move; found ${remainderMoves.length}`,
3038
+ "UDL5008",
3039
+ );
3040
+ }
3041
+ const remainderMove =
3042
+ remainderMoves.length === 1 ? remainderMoves[0] : undefined;
3043
+ if (remainderMove && remainderMove.operation !== "internal_transfer.void") {
3044
+ add(
3045
+ [
3046
+ ...actionBase,
3047
+ "moves",
3048
+ definition.moves.indexOf(remainderMove),
3049
+ "operation",
3050
+ ],
3051
+ "decided amount remainder move must drain the reservation with internal_transfer.void",
3052
+ "UDL4001",
3053
+ );
3054
+ }
3055
+
3056
+ const remainderDefinition = instrument.actions[clause.remainderAction];
3057
+ if (!remainderDefinition) {
3058
+ add(
3059
+ [...clauseBase, "remainderAction"],
3060
+ `decided amount remainder action ${clause.remainderAction} is not declared`,
3061
+ "UDL4001",
3062
+ );
3063
+ return;
3064
+ }
3065
+ if (clause.remainderAction === action) {
3066
+ add(
3067
+ [...clauseBase, "remainderAction"],
3068
+ "decided amount remainder action must be a distinct action",
3069
+ "UDL4001",
3070
+ );
3071
+ }
3072
+
3073
+ const transition = instrument.lifecycle.transitions[action];
3074
+ const remainderTransition =
3075
+ instrument.lifecycle.transitions[clause.remainderAction];
3076
+ if (
3077
+ transition &&
3078
+ remainderTransition &&
3079
+ (transition.from.length !== remainderTransition.from.length ||
3080
+ transition.from.some(
3081
+ (state) => !remainderTransition.from.includes(state),
3082
+ ))
3083
+ ) {
3084
+ add(
3085
+ [...clauseBase, "remainderAction"],
3086
+ `decided amount remainder action ${clause.remainderAction} must start from the same lifecycle states as ${action}`,
3087
+ "UDL4001",
3088
+ );
3089
+ }
3090
+
3091
+ const namedDrains = remainderDefinition.moves.filter(
3092
+ (move) => move.operation === "internal_transfer.void",
3093
+ );
3094
+ if (namedDrains.length !== 1 || remainderDefinition.moves.length !== 1) {
3095
+ add(
3096
+ [...actionBase.slice(0, -1), clause.remainderAction, "moves"],
3097
+ `decided amount remainder action ${clause.remainderAction} must declare exactly one reservation drain; found ${namedDrains.length}`,
3098
+ "UDL4001",
3099
+ );
3100
+ }
3101
+ const namedDrain =
3102
+ namedDrains.length === 1 && remainderDefinition.moves.length === 1
3103
+ ? namedDrains[0]
3104
+ : undefined;
3105
+ if (!remainderMove || !namedDrain) return;
3106
+
3107
+ const decidedTransfer = decidedMove?.bind.transferId;
3108
+ const remainderTransfer = remainderMove.bind.transferId;
3109
+ const namedTransfer = namedDrain.bind.transferId;
3110
+ if (
3111
+ !sameInstanceBinding(decidedTransfer, remainderTransfer) ||
3112
+ !sameInstanceBinding(remainderTransfer, namedTransfer)
3113
+ ) {
3114
+ add(
3115
+ [...clauseBase, "remainderAction"],
3116
+ `decided amount and remainder action ${clause.remainderAction} must drain the same reservation`,
3117
+ "UDL4001",
3118
+ );
3119
+ }
3120
+ }
3121
+
3122
+ function sameInstanceBinding(
3123
+ left: UdlMove["bind"][string] | undefined,
3124
+ right: UdlMove["bind"][string] | undefined,
3125
+ ): boolean {
3126
+ return (
3127
+ left?.from === "instance" &&
3128
+ right?.from === "instance" &&
3129
+ left.path.startsWith("refs.") &&
3130
+ left.path === right.path
3131
+ );
1556
3132
  }
1557
3133
 
1558
3134
  function validatePayoutsAndSettlement(
1559
- noun: UdlNoun,
3135
+ instrument: UdlInstrument,
1560
3136
  base: readonly PropertyKey[],
3137
+ instruments: ReadonlyMap<string, UdlInstrument>,
1561
3138
  references: ReferenceShapeBudget,
1562
3139
  add: AddIssue,
1563
3140
  ): void {
1564
- const payoutEntries = Object.entries(noun.verbs).filter(
3141
+ const payoutEntries = Object.entries(instrument.actions).filter(
1565
3142
  ([, definition]) => definition.payout !== undefined,
1566
3143
  );
1567
- const settlementEntries = Object.entries(noun.verbs).filter(
1568
- ([, definition]) => definition.requiresSettlement !== undefined,
3144
+ const reconcilingEntries = Object.entries(instrument.actions).filter(
3145
+ ([, definition]) => definition.reconcile !== undefined,
1569
3146
  );
1570
- if (payoutEntries.length > 0 && settlementEntries.length !== 1) {
3147
+ if (payoutEntries.length > 0 && reconcilingEntries.length !== 1) {
1571
3148
  add(
1572
- [...base, "verbs"],
1573
- `payout-owning noun must declare exactly one settlement gate; found ${settlementEntries.length}`,
3149
+ [...base, "actions"],
3150
+ `payout-owning instrument must declare exactly one reconciling action; found ${reconcilingEntries.length}`,
3151
+ "UDL5005",
1574
3152
  );
1575
3153
  }
1576
- const settlementRequirement =
1577
- settlementEntries.length === 1
1578
- ? settlementEntries[0]?.[1].requiresSettlement
3154
+ if (payoutEntries.length > 0) {
3155
+ // The bank's own line is the only evidence that a payout left the estate.
3156
+ // A provider confirmation is our row read back to us, and a credit is
3157
+ // money arriving, so neither closes a payout. The match law stays the
3158
+ // author's call.
3159
+ for (const [action, definition] of reconcilingEntries) {
3160
+ (definition.reconcile ?? []).forEach((reconcile, index) => {
3161
+ if (
3162
+ reconcile.evidence === "statement_line" &&
3163
+ reconcile.direction === "debit"
3164
+ ) {
3165
+ return;
3166
+ }
3167
+ add(
3168
+ [...base, "actions", action, "reconcile", index, "evidence"],
3169
+ `payout-owning instrument ${instrument.id} action ${action} must reconcile against a debit statement_line; found ${reconcile.direction} ${reconcile.evidence}`,
3170
+ "UDL5005",
3171
+ );
3172
+ });
3173
+ }
3174
+ }
3175
+ const soleReconciles =
3176
+ reconcilingEntries.length === 1
3177
+ ? reconcilingEntries[0]?.[1].reconcile
1579
3178
  : undefined;
1580
- if (settlementRequirement) {
1581
- for (const [verb, definition] of payoutEntries) {
1582
- if (definition.payout?.capture === settlementRequirement.payoutRef) {
3179
+ if (soleReconciles) {
3180
+ const expected = new Set(
3181
+ soleReconciles.map((reconcile) => reconcile.counterpartyRef),
3182
+ );
3183
+ for (const [action, definition] of payoutEntries) {
3184
+ if (definition.payout && expected.has(definition.payout.capture)) {
1583
3185
  continue;
1584
3186
  }
1585
3187
  add(
1586
- [...base, "verbs", verb, "payout", "capture"],
1587
- `verb ${verb} payout capture ${definition.payout?.capture} is not covered by settlement payoutRef ${settlementRequirement.payoutRef}`,
3188
+ [...base, "actions", action, "payout", "capture"],
3189
+ `action ${action} payout capture ${definition.payout?.capture} is expected by no reconcile`,
3190
+ "UDL5005",
1588
3191
  );
1589
3192
  }
1590
3193
  }
1591
3194
 
1592
3195
  const reservedRefs = new Set([
1593
- ...Object.values(noun.verbs).flatMap((verb) =>
1594
- Object.keys(verb.captureInput ?? {}),
3196
+ ...Object.values(instrument.actions).flatMap((action) =>
3197
+ Object.keys(action.captureInput ?? {}),
1595
3198
  ),
1596
- ...Object.values(noun.verbs).flatMap((verb) =>
1597
- [...verb.steps, ...verb.moves].flatMap((step) =>
3199
+ ...Object.values(instrument.actions).flatMap((action) =>
3200
+ [...action.steps, ...action.moves].flatMap((step) =>
1598
3201
  Object.keys(step.capture ?? {}),
1599
3202
  ),
1600
3203
  ),
1601
- ...Object.values(noun.verbs).flatMap((verb) =>
1602
- verb.signedSum
3204
+ ...Object.values(instrument.actions).flatMap((action) =>
3205
+ action.signedSum
3206
+ ? [
3207
+ action.signedSum.amountRef,
3208
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
3209
+ ]
3210
+ : [],
3211
+ ),
3212
+ ...Object.values(instrument.actions).flatMap((action) =>
3213
+ action.distribute ? [action.distribute.amountRef] : [],
3214
+ ),
3215
+ ...Object.values(instrument.actions).flatMap((action) =>
3216
+ action.remainder
1603
3217
  ? [
1604
- verb.signedSum.amountRef,
1605
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
3218
+ action.remainder.amountRef,
3219
+ ...(action.remainder.accumulateRef
3220
+ ? [action.remainder.accumulateRef]
3221
+ : []),
1606
3222
  ]
1607
3223
  : [],
1608
3224
  ),
1609
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1610
- ...(noun.subject ? ["subject"] : []),
3225
+ ...quoteRefKeys(instrument),
3226
+ ...(instrument.subject ? ["subject"] : []),
1611
3227
  ]);
1612
- const moneyWriters = moneyRefWriters(noun);
3228
+ const moneyWriters = moneyRefWriters(instrument);
1613
3229
  const payoutWriters = new Map<string, string[]>();
1614
- for (const [verb, definition] of Object.entries(noun.verbs)) {
3230
+ for (const [action, definition] of Object.entries(instrument.actions)) {
1615
3231
  if (!definition.payout) continue;
1616
3232
  const payout = definition.payout;
1617
- const payoutBase = [...base, "verbs", verb, "payout"] as const;
1618
- if (verb === "create") {
1619
- add(payoutBase, "create cannot declare a payout intent");
3233
+ const payoutBase = [...base, "actions", action, "payout"] as const;
3234
+ if (action === "create") {
3235
+ add(payoutBase, "create cannot declare a payout intent", "UDL5005");
1620
3236
  }
1621
3237
  if (definition.steps.length > 0 || definition.moves.length > 0) {
1622
3238
  add(
1623
3239
  payoutBase,
1624
- `verb ${verb} payout intent cannot combine with kernel steps or moves`,
3240
+ `action ${action} payout intent cannot combine with kernel steps or moves`,
3241
+ "UDL5005",
1625
3242
  );
1626
3243
  }
1627
3244
  const prior = payoutWriters.get(payout.capture) ?? [];
1628
3245
  if (reservedRefs.has(payout.capture) || prior.length > 0) {
1629
3246
  add(
1630
3247
  [...payoutBase, "capture"],
1631
- `payout capture ${payout.capture} collides with an existing noun ref key`,
3248
+ `payout capture ${payout.capture} collides with an existing instrument ref key`,
3249
+ "UDL5005",
1632
3250
  );
1633
3251
  }
1634
- payoutWriters.set(payout.capture, [...prior, verb]);
3252
+ payoutWriters.set(payout.capture, [...prior, action]);
1635
3253
 
1636
3254
  const amount = payout.amount;
1637
3255
  const [scope, key] = amount.split(".") as [string, string];
1638
3256
  const amountWriters = moneyWriters.get(key) ?? [];
1639
3257
  const amountDeclared =
1640
3258
  scope === "fields"
1641
- ? isMoneySchema(noun.fields[key] ?? {})
3259
+ ? isMoneySchema(instrument.fields[key] ?? {})
1642
3260
  : scope === "refs" && amountWriters.length > 0;
1643
3261
  if (!amountDeclared) {
1644
3262
  add(
1645
3263
  [...payoutBase, "amount"],
1646
3264
  `payout amount ${amount} must name a declared money field or money ref`,
3265
+ "UDL5005",
1647
3266
  );
1648
3267
  } else if (
1649
3268
  scope === "refs" &&
1650
- !amountWriters.includes(verb) &&
3269
+ !amountWriters.includes(action) &&
1651
3270
  !amountWriters.includes("create") &&
1652
- !verbGroupDominatesReader(noun, amountWriters, verb)
3271
+ !actionGroupDominatesReader(instrument, amountWriters, action)
1653
3272
  ) {
1654
3273
  add(
1655
3274
  [...payoutBase, "amount"],
1656
3275
  `payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`,
3276
+ "UDL5005",
1657
3277
  );
1658
3278
  }
1659
3279
 
1660
- const currency = noun.fields[payout.currencyField];
3280
+ const currency = instrument.fields[payout.currencyField];
1661
3281
  if (!currency || !isCurrencySchema(currency)) {
1662
3282
  add(
1663
3283
  [...payoutBase, "currencyField"],
1664
3284
  `payout currencyField ${payout.currencyField} must name a currency field`,
3285
+ "UDL5005",
1665
3286
  );
1666
3287
  }
1667
- const source = noun.fields[payout.sourceAccountField];
3288
+ const source = instrument.fields[payout.sourceAccountField];
1668
3289
  if (!source || !references.accepts(source, "acct")) {
1669
3290
  add(
1670
3291
  [...payoutBase, "sourceAccountField"],
1671
3292
  `payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`,
3293
+ "UDL5005",
1672
3294
  );
1673
3295
  }
1674
- const beneficiary = noun.fields[payout.beneficiaryField];
3296
+ const beneficiary = instrument.fields[payout.beneficiaryField];
1675
3297
  if (!beneficiary || !references.accepts(beneficiary, "ben")) {
1676
3298
  add(
1677
3299
  [...payoutBase, "beneficiaryField"],
1678
3300
  `payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`,
3301
+ "UDL5005",
1679
3302
  );
1680
3303
  }
1681
- const beneficiaryPartyField = noun.parties?.beneficiary;
3304
+ const beneficiaryPartyField = instrument.parties?.beneficiary;
1682
3305
  if (!beneficiaryPartyField) {
1683
3306
  add(
1684
3307
  [...payoutBase, "beneficiaryPartyField"],
1685
3308
  "payout requires parties.beneficiary to bind its destination party",
3309
+ "UDL5005",
1686
3310
  );
1687
3311
  } else if (payout.beneficiaryPartyField !== beneficiaryPartyField) {
1688
3312
  add(
1689
3313
  [...payoutBase, "beneficiaryPartyField"],
1690
3314
  `payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`,
3315
+ "UDL5005",
1691
3316
  );
1692
3317
  }
1693
3318
  }
1694
3319
 
1695
- const settlementCaptures = new Set<string>();
1696
- for (const [verb, definition] of Object.entries(noun.verbs)) {
1697
- if (!definition.requiresSettlement) continue;
1698
- const requirement = definition.requiresSettlement;
1699
- const requirementBase = [
1700
- ...base,
1701
- "verbs",
1702
- verb,
1703
- "requiresSettlement",
1704
- ] as const;
1705
- if (
1706
- reservedRefs.has(requirement.capture) ||
1707
- payoutWriters.has(requirement.capture) ||
1708
- settlementCaptures.has(requirement.capture)
1709
- ) {
1710
- add(
1711
- [...requirementBase, "capture"],
1712
- `settlement capture ${requirement.capture} collides with an existing noun ref key`,
1713
- );
1714
- }
1715
- settlementCaptures.add(requirement.capture);
3320
+ validateReconciles(
3321
+ instrument,
3322
+ base,
3323
+ instruments,
3324
+ reservedRefs,
3325
+ payoutWriters,
3326
+ moneyWriters,
3327
+ references,
3328
+ add,
3329
+ );
3330
+ }
1716
3331
 
1717
- const writers = payoutWriters.get(requirement.payoutRef) ?? [];
1718
- if (writers.length === 0) {
1719
- add(
1720
- [...requirementBase, "payoutRef"],
1721
- `requiresSettlement payoutRef ${requirement.payoutRef} is not captured by a payout intent`,
1722
- );
1723
- } else if (!verbGroupDominatesReader(noun, writers, verb)) {
1724
- add(
1725
- requirementBase,
1726
- `requiresSettlement can read ${requirement.payoutRef} before payout writers ${writers.join(" or ")}`,
1727
- );
1728
- }
3332
+ /**
3333
+ * The five reconcile laws. An expectation names money the instrument can
3334
+ * describe but cannot yet see, so every part of it has to be checkable before
3335
+ * an instance exists: the expected fields carry money, one source answers one
3336
+ * counterparty row, the window closes under a sweep that is actually declared,
3337
+ * the forgiveness is bounded by a dial, and the unmatched case has a capped
3338
+ * child to land in.
3339
+ */
3340
+ function validateReconciles(
3341
+ instrument: UdlInstrument,
3342
+ base: readonly PropertyKey[],
3343
+ instruments: ReadonlyMap<string, UdlInstrument>,
3344
+ reservedRefs: ReadonlySet<string>,
3345
+ payoutWriters: ReadonlyMap<string, string[]>,
3346
+ moneyWriters: ReadonlyMap<string, string[]>,
3347
+ references: ReferenceShapeBudget,
3348
+ add: AddIssue,
3349
+ ): void {
3350
+ const captures = new Set<string>();
3351
+ for (const [action, definition] of Object.entries(instrument.actions)) {
3352
+ if (!definition.reconcile) continue;
3353
+ const counterparties = new Set<string>();
3354
+ definition.reconcile.forEach((reconcile, index) => {
3355
+ const path = [...base, "actions", action, "reconcile", index] as const;
3356
+
3357
+ // Law: the expectation is money this instrument can already describe,
3358
+ // read the same way a payout intent reads its amount.
3359
+ const [amountScope, amountKey] = reconcile.amount.split(".") as [
3360
+ string,
3361
+ string,
3362
+ ];
3363
+ const amountDeclared =
3364
+ amountScope === "fields"
3365
+ ? isMoneySchema(instrument.fields[amountKey] ?? {})
3366
+ : amountScope === "refs" &&
3367
+ (moneyWriters.get(amountKey) ?? []).length > 0;
3368
+ if (!amountDeclared) {
3369
+ add(
3370
+ [...path, "amount"],
3371
+ `reconcile amount ${reconcile.amount} must name a declared money field or money ref`,
3372
+ "UDL5005",
3373
+ );
3374
+ }
3375
+ const currency = instrument.fields[reconcile.currencyField];
3376
+ if (!currency) {
3377
+ add(
3378
+ [...path, "currencyField"],
3379
+ `reconcile expects unknown field ${reconcile.currencyField}`,
3380
+ "UDL5005",
3381
+ );
3382
+ } else if (
3383
+ currency.type !== "string" ||
3384
+ !instrument.required.includes(reconcile.currencyField)
3385
+ ) {
3386
+ add(
3387
+ [...path, "currencyField"],
3388
+ `${instrument.id}.${reconcile.currencyField} must be a required text field to carry the expected currency`,
3389
+ "UDL5005",
3390
+ );
3391
+ }
3392
+
3393
+ // Law: one evidence source answers one counterparty row. Two
3394
+ // expectations over the same row would match the same money twice.
3395
+ if (counterparties.has(reconcile.counterpartyRef)) {
3396
+ add(
3397
+ [...path, "counterpartyRef"],
3398
+ `action ${action} expects ${reconcile.counterpartyRef} twice; one counterparty row answers one reconcile`,
3399
+ "UDL5005",
3400
+ );
3401
+ }
3402
+ counterparties.add(reconcile.counterpartyRef);
3403
+ const writers = payoutWriters.get(reconcile.counterpartyRef) ?? [];
3404
+ if (reconcile.evidence === "statement_line") {
3405
+ if (writers.length === 0) {
3406
+ add(
3407
+ [...path, "counterpartyRef"],
3408
+ `reconcile counterpartyRef ${reconcile.counterpartyRef} is not captured by a payout intent`,
3409
+ "UDL5005",
3410
+ );
3411
+ } else if (!actionGroupDominatesReader(instrument, writers, action)) {
3412
+ add(
3413
+ path,
3414
+ `reconcile can read ${reconcile.counterpartyRef} before payout writers ${writers.join(" or ")}`,
3415
+ "UDL5005",
3416
+ );
3417
+ }
3418
+ }
3419
+
3420
+ // Law: the window is a fixed duration or a stored deadline, and the due
3421
+ // sweep that closes it is declared on the same action. A window nothing
3422
+ // fires is a wait with no end.
3423
+ const due = definition.due;
3424
+ if ("offset" in reconcile.within) {
3425
+ const offset = reconcile.within.offset;
3426
+ if (fixedIsoDurationMs(offset) === null) {
3427
+ add(
3428
+ [...path, "within", "offset"],
3429
+ `reconcile window ${offset} is not a fixed ISO-8601 duration`,
3430
+ "UDL5005",
3431
+ );
3432
+ } else if (!due || due.offset !== offset) {
3433
+ add(
3434
+ [...path, "within", "offset"],
3435
+ `reconcile window ${offset} is closed by no due condition on ${action}`,
3436
+ "UDL5005",
3437
+ );
3438
+ }
3439
+ } else {
3440
+ const deadline = instrument.fields[reconcile.within.field];
3441
+ if (!deadline || !isDateTimeFormat(deadline.format)) {
3442
+ add(
3443
+ [...path, "within", "field"],
3444
+ `reconcile window ${reconcile.within.field} must be a stored date-time field`,
3445
+ "UDL5005",
3446
+ );
3447
+ } else if (
3448
+ !due ||
3449
+ due.field !== reconcile.within.field ||
3450
+ due.offset !== undefined
3451
+ ) {
3452
+ add(
3453
+ [...path, "within", "field"],
3454
+ `reconcile window ${reconcile.within.field} is closed by no due condition on ${action}`,
3455
+ "UDL5005",
3456
+ );
3457
+ }
3458
+ }
3459
+
3460
+ // Law: tolerance never exceeds its dial. The authored number is the
3461
+ // forgiveness; the dial is the ceiling a tenant may not raise past.
3462
+ const match = reconcile.match;
3463
+ if (match.law === "tolerance") {
3464
+ const dial = instrument.dials?.find(
3465
+ (candidate) => candidate.key === match.dial,
3466
+ );
3467
+ if (!dial || dial.kind !== "reconcile_tolerance") {
3468
+ add(
3469
+ [...path, "match", "dial"],
3470
+ `reconcile tolerance names no reconcile_tolerance dial ${match.dial}`,
3471
+ "UDL5005",
3472
+ );
3473
+ } else if (match.minorUnits > dial.maxMinorUnits) {
3474
+ add(
3475
+ [...path, "match", "minorUnits"],
3476
+ `reconcile tolerance ${match.minorUnits} exceeds dial ${dial.key} ceiling ${dial.maxMinorUnits}`,
3477
+ "UDL5005",
3478
+ );
3479
+ }
3480
+ }
3481
+
3482
+ // Law: matched or exception. The break lands in a child of this
3483
+ // instrument that points back at it, never in a status nobody reads.
3484
+ const child = instruments.get(reconcile.exception.childInstrumentId);
3485
+ if (!child) {
3486
+ add(
3487
+ [...path, "exception", "childInstrumentId"],
3488
+ `reconcile raises unknown exception instrument ${reconcile.exception.childInstrumentId}`,
3489
+ "UDL5007",
3490
+ );
3491
+ } else {
3492
+ const exception = reconcile.exception as typeof reconcile.exception & {
3493
+ readonly amountField: string;
3494
+ readonly reasonField: string;
3495
+ };
3496
+ for (const problem of reconcileExceptionChildProblems(
3497
+ instrument.idPrefix,
3498
+ child,
3499
+ exception,
3500
+ references,
3501
+ )) {
3502
+ const field =
3503
+ problem === "UDL5009" || problem === "UDL5010"
3504
+ ? "amountField"
3505
+ : problem === "UDL5011" || problem === "UDL5012"
3506
+ ? "reasonField"
3507
+ : "refField";
3508
+ add(
3509
+ [...path, "exception", field],
3510
+ reconcileExceptionProblemMessage(
3511
+ problem,
3512
+ instrument,
3513
+ child,
3514
+ exception,
3515
+ ),
3516
+ problem,
3517
+ );
3518
+ }
3519
+ }
1729
3520
 
1730
- if (!noun.lifecycle.transitions[verb]) {
3521
+ if (
3522
+ reservedRefs.has(reconcile.capture) ||
3523
+ payoutWriters.has(reconcile.capture) ||
3524
+ captures.has(reconcile.capture)
3525
+ ) {
3526
+ add(
3527
+ [...path, "capture"],
3528
+ `reconcile capture ${reconcile.capture} collides with an existing instrument ref key`,
3529
+ "UDL5005",
3530
+ );
3531
+ }
3532
+ captures.add(reconcile.capture);
3533
+ });
3534
+
3535
+ if (!instrument.lifecycle.transitions[action]) {
1731
3536
  add(
1732
- requirementBase,
1733
- "requiresSettlement needs a lifecycle transition and cannot run on create",
3537
+ [...base, "actions", action, "reconcile"],
3538
+ "a reconciling action needs a lifecycle transition and cannot run on create",
3539
+ "UDL5005",
1734
3540
  );
1735
3541
  }
1736
3542
  const callerFacets = [
1737
3543
  definition.port ? "port" : undefined,
1738
- definition.publicIntent ? "publicIntent" : undefined,
3544
+ definition.publicAction ? "publicAction" : undefined,
1739
3545
  definition.input ? "input" : undefined,
1740
3546
  definition.captureInput ? "captureInput" : undefined,
1741
- definition.due ? "due" : undefined,
1742
3547
  definition.deadline ? "deadline" : undefined,
1743
3548
  ].filter((facet): facet is string => facet !== undefined);
1744
3549
  for (const facet of callerFacets) {
1745
3550
  add(
1746
- [...base, "verbs", verb, facet],
1747
- `requiresSettlement is system-only and cannot declare ${facet}`,
3551
+ [...base, "actions", action, facet],
3552
+ `a reconciling action is system-only and cannot declare ${facet}`,
3553
+ "UDL5005",
1748
3554
  );
1749
3555
  }
1750
3556
  if (definition.steps.length > 0) {
1751
3557
  add(
1752
- [...base, "verbs", verb, "steps"],
1753
- "requiresSettlement cannot add kernel steps",
3558
+ [...base, "actions", action, "steps"],
3559
+ "a reconciling action cannot add kernel steps",
3560
+ "UDL5005",
1754
3561
  );
1755
3562
  }
1756
3563
  if (definition.moves.length > 0) {
1757
3564
  add(
1758
- [...base, "verbs", verb, "moves"],
1759
- "requiresSettlement cannot move money",
3565
+ [...base, "actions", action, "moves"],
3566
+ "a reconciling action cannot move money",
3567
+ "UDL5005",
1760
3568
  );
1761
3569
  }
1762
3570
  }
1763
3571
  }
1764
3572
 
1765
- function moneyRefWriters(noun: UdlNoun): ReadonlyMap<string, string[]> {
3573
+ function moneyRefWriters(
3574
+ instrument: UdlInstrument,
3575
+ ): ReadonlyMap<string, string[]> {
1766
3576
  const writers = new Map<string, string[]>();
1767
- for (const [verbName, verb] of Object.entries(noun.verbs)) {
3577
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
1768
3578
  const refs = [
1769
- ...(verb.signedSum
3579
+ ...(action.signedSum
1770
3580
  ? [
1771
- verb.signedSum.amountRef,
1772
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
3581
+ action.signedSum.amountRef,
3582
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
1773
3583
  ]
1774
3584
  : []),
1775
- ...[...verb.steps, ...verb.moves].flatMap((step) =>
3585
+ ...[...action.steps, ...action.moves].flatMap((step) =>
1776
3586
  Object.entries(step.capture ?? {}).flatMap(([ref, output]) =>
1777
3587
  output === "postedAmount" ? [ref] : [],
1778
3588
  ),
1779
3589
  ),
1780
3590
  ];
1781
3591
  for (const ref of refs) {
1782
- writers.set(ref, [...(writers.get(ref) ?? []), verbName]);
3592
+ writers.set(ref, [...(writers.get(ref) ?? []), actionName]);
1783
3593
  }
1784
3594
  }
1785
3595
  return writers;
@@ -1793,122 +3603,140 @@ function moneyRefWriters(noun: UdlNoun): ReadonlyMap<string, string[]> {
1793
3603
  * open-grammar clean also loads registry clean.
1794
3604
  */
1795
3605
  function validateGateShape(
1796
- noun: UdlNoun,
1797
- verb: string,
3606
+ instrument: UdlInstrument,
3607
+ action: string,
1798
3608
  gate: UdlGate,
1799
- target: UdlNoun,
3609
+ target: UdlInstrument,
1800
3610
  base: readonly PropertyKey[],
1801
3611
  add: AddIssue,
1802
3612
  ): void {
1803
- if (gate.unique && verb !== "create") {
1804
- add([...base, "unique"], "unique is a create admission gate");
3613
+ if (gate.unique && action !== "create") {
3614
+ add([...base, "unique"], "unique is a create admission gate", "UDL5001");
1805
3615
  }
1806
- if (gate.bind && verb !== "create") {
1807
- add([...base, "bind"], "bind derives fields at create only");
3616
+ if (gate.bind && action !== "create") {
3617
+ add([...base, "bind"], "bind derives fields at create only", "UDL5001");
1808
3618
  }
1809
3619
  if (gate.bind && Object.keys(gate.bind).length === 0) {
1810
- add([...base, "bind"], "bind must not be empty");
3620
+ add([...base, "bind"], "bind must not be empty", "UDL5001");
1811
3621
  }
1812
3622
  if (gate.match && Object.keys(gate.match).length === 0) {
1813
- add([...base, "match"], "match must not be empty");
3623
+ add([...base, "match"], "match must not be empty", "UDL5001");
1814
3624
  }
1815
- if (gate.optional && noun.required.includes(gate.field)) {
3625
+ if (gate.optional && instrument.required.includes(gate.field)) {
1816
3626
  add(
1817
3627
  [...base, "optional"],
1818
3628
  `optional declares an opt-out on ${gate.field}, which required lists`,
3629
+ "UDL5001",
1819
3630
  );
1820
3631
  }
1821
3632
  for (const [key, path] of Object.entries(gate.bind ?? {})) {
1822
- if (gate.optional && noun.required.includes(key)) {
3633
+ if (gate.optional && instrument.required.includes(key)) {
1823
3634
  add(
1824
3635
  [...base, "bind", key],
1825
3636
  `an optional reference can only bind optional fields; required lists ${key}`,
3637
+ "UDL5001",
1826
3638
  );
1827
3639
  }
1828
- if (!Object.hasOwn(noun.fields, key)) {
1829
- add([...base, "bind", key], `bind targets unknown field ${key}`);
3640
+ if (!Object.hasOwn(instrument.fields, key)) {
3641
+ add(
3642
+ [...base, "bind", key],
3643
+ `bind targets unknown field ${key}`,
3644
+ "UDL5001",
3645
+ );
1830
3646
  }
1831
3647
  if (key === gate.field) {
1832
- add([...base, "bind", key], "bind cannot target the gate field itself");
3648
+ add(
3649
+ [...base, "bind", key],
3650
+ "bind cannot target the gate field itself",
3651
+ "UDL5001",
3652
+ );
1833
3653
  }
1834
- if (noun.update?.fields.includes(key)) {
3654
+ if (instrument.update?.fields.includes(key)) {
1835
3655
  add(
1836
3656
  [...base, "bind", key],
1837
3657
  `bind targets ${key}, which update declares mutable`,
3658
+ "UDL5001",
1838
3659
  );
1839
3660
  }
1840
- if (path === "nounInstanceId") {
3661
+ if (path === "instrumentInstanceId") {
1841
3662
  add(
1842
3663
  [...base, "bind", key],
1843
3664
  "the gate field already carries the referenced id",
3665
+ "UDL5001",
1844
3666
  );
1845
3667
  } else if (!referencedPathDeclared(target, path)) {
1846
3668
  add(
1847
3669
  [...base, "bind", key],
1848
3670
  `bind reads ${path}, which ${target.id} does not declare`,
3671
+ "UDL5001",
1849
3672
  );
1850
3673
  }
1851
3674
  }
1852
3675
  for (const [localPath, path] of Object.entries(gate.match ?? {})) {
1853
3676
  const localOk =
1854
- verb === "create"
3677
+ action === "create"
1855
3678
  ? localPath.startsWith("fields.") &&
1856
- Object.hasOwn(noun.fields, localPath.slice("fields.".length))
1857
- : localPath === "nounInstanceId" ||
3679
+ Object.hasOwn(instrument.fields, localPath.slice("fields.".length))
3680
+ : localPath === "instrumentInstanceId" ||
1858
3681
  (localPath.startsWith("fields.") &&
1859
- Object.hasOwn(noun.fields, localPath.slice("fields.".length))) ||
3682
+ Object.hasOwn(
3683
+ instrument.fields,
3684
+ localPath.slice("fields.".length),
3685
+ )) ||
1860
3686
  (localPath.startsWith("refs.") &&
1861
- declaredRefKeys(noun).has(localPath.slice("refs.".length)));
3687
+ declaredRefKeys(instrument).has(localPath.slice("refs.".length)));
1862
3688
  if (!localOk) {
1863
3689
  add(
1864
3690
  [...base, "match", localPath],
1865
- verb === "create"
1866
- ? "a create match may only read the noun's own declared fields"
3691
+ action === "create"
3692
+ ? "a create match may only read the instrument's own declared fields"
1867
3693
  : `match reads unknown local path ${localPath}`,
3694
+ "UDL5001",
1868
3695
  );
1869
3696
  }
1870
3697
  if (!referencedPathDeclared(target, path)) {
1871
3698
  add(
1872
3699
  [...base, "match", localPath],
1873
3700
  `match reads ${path}, which ${target.id} does not declare`,
3701
+ "UDL5001",
1874
3702
  );
1875
3703
  }
1876
3704
  }
1877
3705
  }
1878
3706
 
1879
- /** Every `refs.<key>` a noun's instances can legitimately carry. */
1880
- function declaredRefKeys(noun: UdlNoun): ReadonlySet<string> {
3707
+ /** Every `refs.<key>` a instrument's instances can legitimately carry. */
3708
+ function declaredRefKeys(instrument: UdlInstrument): ReadonlySet<string> {
1881
3709
  return new Set([
1882
- ...Object.values(noun.verbs).flatMap((verb) =>
1883
- verb.payout ? [verb.payout.capture] : [],
3710
+ ...Object.values(instrument.actions).flatMap((action) =>
3711
+ action.payout ? [action.payout.capture] : [],
1884
3712
  ),
1885
- ...Object.values(noun.verbs).flatMap((verb) =>
1886
- verb.requiresSettlement ? [verb.requiresSettlement.capture] : [],
3713
+ ...Object.values(instrument.actions).flatMap((action) =>
3714
+ (action.reconcile ?? []).map((reconcile) => reconcile.capture),
1887
3715
  ),
1888
- ...Object.values(noun.verbs).flatMap((verb) =>
1889
- Object.keys(verb.captureInput ?? {}),
3716
+ ...Object.values(instrument.actions).flatMap((action) =>
3717
+ Object.keys(action.captureInput ?? {}),
1890
3718
  ),
1891
- ...Object.values(noun.verbs).flatMap((verb) =>
1892
- [...verb.steps, ...verb.moves].flatMap((step) =>
3719
+ ...Object.values(instrument.actions).flatMap((action) =>
3720
+ [...action.steps, ...action.moves].flatMap((step) =>
1893
3721
  Object.keys(step.capture ?? {}),
1894
3722
  ),
1895
3723
  ),
1896
- ...Object.values(noun.verbs).flatMap((verb) =>
1897
- verb.signedSum
3724
+ ...Object.values(instrument.actions).flatMap((action) =>
3725
+ action.signedSum
1898
3726
  ? [
1899
- verb.signedSum.amountRef,
1900
- ...verb.signedSum.sources.map((source) => source.subtotalRef),
3727
+ action.signedSum.amountRef,
3728
+ ...action.signedSum.sources.map((source) => source.subtotalRef),
1901
3729
  ]
1902
3730
  : [],
1903
3731
  ),
1904
- ...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
1905
- ...(noun.subject ? ["subject"] : []),
3732
+ ...quoteRefKeys(instrument),
3733
+ ...(instrument.subject ? ["subject"] : []),
1906
3734
  ]);
1907
3735
  }
1908
3736
 
1909
3737
  /** Whether a bind/match referenced path names declared target structure. */
1910
- function referencedPathDeclared(target: UdlNoun, path: string): boolean {
1911
- if (path === "nounInstanceId") return true;
3738
+ function referencedPathDeclared(target: UdlInstrument, path: string): boolean {
3739
+ if (path === "instrumentInstanceId") return true;
1912
3740
  if (path.startsWith("fields.")) {
1913
3741
  return Object.hasOwn(target.fields, path.slice("fields.".length));
1914
3742
  }
@@ -1919,19 +3747,23 @@ function referencedPathDeclared(target: UdlNoun, path: string): boolean {
1919
3747
  }
1920
3748
 
1921
3749
  function validateStep(
1922
- noun: UdlNoun,
1923
- verb: UdlVerb,
3750
+ instrument: UdlInstrument,
3751
+ action: UdlAction,
1924
3752
  step: UdlStep | UdlMove,
1925
3753
  base: readonly PropertyKey[],
1926
3754
  add: AddIssue,
1927
3755
  ): void {
1928
3756
  if (Object.keys(step.bind).length === 0) {
1929
- add([...base, "bind"], "a kernel step must bind at least one value");
3757
+ add(
3758
+ [...base, "bind"],
3759
+ "a kernel step must bind at least one value",
3760
+ "UDL5008",
3761
+ );
1930
3762
  }
1931
3763
  if (step.capture && Object.keys(step.capture).length === 0) {
1932
- add([...base, "capture"], "capture must not be empty");
3764
+ add([...base, "capture"], "capture must not be empty", "UDL5008");
1933
3765
  }
1934
- const inputFields = recordValue(verb.input?.properties);
3766
+ const inputFields = recordValue(action.input?.properties);
1935
3767
  for (const [field, binding] of Object.entries(step.bind)) {
1936
3768
  if (binding.from === "const") continue;
1937
3769
  if (binding.from === "input") {
@@ -1939,29 +3771,32 @@ function validateStep(
1939
3771
  if (!Object.hasOwn(inputFields, root)) {
1940
3772
  add(
1941
3773
  [...base, "bind", field, "path"],
1942
- `input binding references undeclared verb input field ${root}`,
3774
+ `input binding references undeclared action input field ${root}`,
3775
+ "UDL5008",
1943
3776
  );
1944
3777
  }
1945
3778
  continue;
1946
3779
  }
1947
3780
  if (binding.path.startsWith("fields.")) {
1948
3781
  const root = binding.path.split(".")[1] as string;
1949
- if (!Object.hasOwn(noun.fields, root)) {
3782
+ if (!Object.hasOwn(instrument.fields, root)) {
1950
3783
  add(
1951
3784
  [...base, "bind", field, "path"],
1952
- `instance binding references unknown noun field ${root}`,
3785
+ `instance binding references unknown instrument field ${root}`,
3786
+ "UDL5008",
1953
3787
  );
1954
3788
  }
1955
3789
  continue;
1956
3790
  }
1957
3791
  if (
1958
- binding.path !== "nounInstanceId" &&
1959
- binding.path !== "workspaceId" &&
3792
+ binding.path !== "instrumentInstanceId" &&
3793
+ binding.path !== "productId" &&
1960
3794
  !binding.path.startsWith("refs.")
1961
3795
  ) {
1962
3796
  add(
1963
3797
  [...base, "bind", field, "path"],
1964
- `instance binding path ${binding.path} must read nounInstanceId, workspaceId, fields.*, or refs.*`,
3798
+ `instance binding path ${binding.path} must read instrumentInstanceId, productId, fields.*, or refs.*`,
3799
+ "UDL5008",
1965
3800
  );
1966
3801
  }
1967
3802
  }
@@ -1977,6 +3812,7 @@ function validateStep(
1977
3812
  add(
1978
3813
  [...base, "bind", "accountId"],
1979
3814
  `${step.operation} must bind accountId from an instance path`,
3815
+ "UDL5008",
1980
3816
  );
1981
3817
  }
1982
3818
  for (const monetary of ["amount", "currency"] as const) {
@@ -1984,6 +3820,7 @@ function validateStep(
1984
3820
  add(
1985
3821
  [...base, "bind", monetary],
1986
3822
  `${step.operation} must not bind ${monetary}`,
3823
+ "UDL5008",
1987
3824
  );
1988
3825
  }
1989
3826
  }
@@ -2002,148 +3839,284 @@ function validateStep(
2002
3839
  add(
2003
3840
  [...base, "bind", "destinationAccountId"],
2004
3841
  "transfer source and destination must be different accounts",
3842
+ "UDL4001",
2005
3843
  );
2006
3844
  }
2007
3845
  }
2008
3846
  }
2009
3847
 
2010
- function validateUnwind(
2011
- noun: UdlNoun,
3848
+ /**
3849
+ * Every ref a quoting action seeds: the charge and the net the author named,
3850
+ * plus the expiry and fingerprint the machinery derives from the net ref.
3851
+ */
3852
+ function quoteRefKeys(instrument: UdlInstrument): readonly string[] {
3853
+ return Object.values(instrument.actions).flatMap((action) =>
3854
+ action.quote ? quoteSeededRefKeys(action.quote) : [],
3855
+ );
3856
+ }
3857
+
3858
+ function validateQuoteCommit(
3859
+ instrument: UdlInstrument,
2012
3860
  base: readonly PropertyKey[],
2013
3861
  references: ReferenceShapeBudget,
2014
3862
  add: AddIssue,
2015
3863
  ): void {
2016
- const unwind = noun.unwind;
2017
- if (!unwind) return;
2018
- if (unwind.quote === unwind.confirm) {
2019
- add(
2020
- [...base, "unwind", "confirm"],
2021
- "unwind quote and confirm verbs must differ",
2022
- );
2023
- }
2024
- for (const [fieldName, field] of [
2025
- ["refundableField", unwind.refundableField],
2026
- ["refundDestinationField", unwind.refundDestinationField],
2027
- ] as const) {
2028
- if (!Object.hasOwn(noun.fields, field)) {
3864
+ const quotingActions = Object.entries(instrument.actions).filter(
3865
+ ([, action]) => action.quote,
3866
+ );
3867
+ for (const [actionName, action] of quotingActions) {
3868
+ const quote = action.quote;
3869
+ if (!quote) continue;
3870
+ const quoteBase = [...base, "actions", actionName, "quote"] as const;
3871
+ if (action.earnable) {
2029
3872
  add(
2030
- [...base, "unwind", fieldName],
2031
- `unwind references unknown field ${field}`,
3873
+ [...base, "actions", actionName, "earnable"],
3874
+ `quoting action ${actionName} prices a refund and cannot be earnable`,
3875
+ "UDL5006",
2032
3876
  );
2033
3877
  }
2034
- }
2035
- const refundable = noun.fields[unwind.refundableField];
2036
- if (refundable && !isMoneySchema(refundable)) {
2037
- add(
2038
- [...base, "unwind", "refundableField"],
2039
- "refundableField must be a money field",
3878
+ for (const [slot, field] of [
3879
+ ["baseField", quote.baseField],
3880
+ ["netDestinationField", quote.netDestinationField],
3881
+ ...quote.fixes.map((fixed, index) => [`fixes.${index}`, fixed] as const),
3882
+ ] as const) {
3883
+ if (Object.hasOwn(instrument.fields, field)) continue;
3884
+ add(
3885
+ [...quoteBase, slot],
3886
+ `quote references unknown field ${field}`,
3887
+ "UDL5006",
3888
+ );
3889
+ }
3890
+ const priced = instrument.fields[quote.baseField];
3891
+ if (priced && !isMoneySchema(priced)) {
3892
+ add(
3893
+ [...quoteBase, "baseField"],
3894
+ "baseField must be a money field",
3895
+ "UDL5006",
3896
+ );
3897
+ }
3898
+ const destination = instrument.fields[quote.netDestinationField];
3899
+ if (destination && !references.accepts(destination, "acct")) {
3900
+ add(
3901
+ [...quoteBase, "netDestinationField"],
3902
+ "netDestinationField must be an account-id field",
3903
+ "UDL5006",
3904
+ );
3905
+ }
3906
+ for (const [slot, label, field] of [
3907
+ ["baseField", "base field", quote.baseField],
3908
+ [
3909
+ "netDestinationField",
3910
+ "net destination field",
3911
+ quote.netDestinationField,
3912
+ ],
3913
+ ] as const) {
3914
+ if (quote.fixes.includes(field)) continue;
3915
+ add(
3916
+ [...quoteBase, slot],
3917
+ `quoting action ${actionName} must freeze its ${label} ${field}`,
3918
+ "UDL5006",
3919
+ );
3920
+ }
3921
+ for (const field of action.updates ?? []) {
3922
+ if (!quote.fixes.includes(field)) continue;
3923
+ add(
3924
+ [...base, "actions", actionName, "updates"],
3925
+ `quoting action ${actionName} freezes ${field} and writes it in the same action`,
3926
+ "UDL5006",
3927
+ );
3928
+ }
3929
+ if (quote.anchorField !== undefined) {
3930
+ const anchor = instrument.fields[quote.anchorField];
3931
+ if (!anchor) {
3932
+ add(
3933
+ [...quoteBase, "anchorField"],
3934
+ `quote references unknown field ${quote.anchorField}`,
3935
+ "UDL5006",
3936
+ );
3937
+ } else if (!isDateTimeFormat(anchor.format)) {
3938
+ add(
3939
+ [...quoteBase, "anchorField"],
3940
+ "anchorField must be a date-time field",
3941
+ "UDL5006",
3942
+ );
3943
+ } else if (!instrument.required.includes(quote.anchorField)) {
3944
+ add(
3945
+ [...quoteBase, "anchorField"],
3946
+ "anchorField must be required",
3947
+ "UDL5006",
3948
+ );
3949
+ }
3950
+ }
3951
+ if ("offset" in quote.expires) {
3952
+ if (fixedIsoDurationMs(quote.expires.offset) === null) {
3953
+ add(
3954
+ [...quoteBase, "expires", "offset"],
3955
+ "the offer's life must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
3956
+ "UDL5006",
3957
+ );
3958
+ }
3959
+ } else {
3960
+ const deadline = instrument.fields[quote.expires.field];
3961
+ if (!deadline) {
3962
+ add(
3963
+ [...quoteBase, "expires", "field"],
3964
+ `quote references unknown field ${quote.expires.field}`,
3965
+ "UDL5006",
3966
+ );
3967
+ } else if (!isDateTimeFormat(deadline.format)) {
3968
+ add(
3969
+ [...quoteBase, "expires", "field"],
3970
+ "the offer's deadline field must be a date-time field",
3971
+ "UDL5006",
3972
+ );
3973
+ } else if (!instrument.required.includes(quote.expires.field)) {
3974
+ add(
3975
+ [...quoteBase, "expires", "field"],
3976
+ "the offer's deadline field must be required",
3977
+ "UDL5006",
3978
+ );
3979
+ }
3980
+ }
3981
+ addDuplicateIssues(
3982
+ quote.fixes,
3983
+ [...quoteBase, "fixes"],
3984
+ "frozen field",
3985
+ add,
2040
3986
  );
2041
- }
2042
- const destination = noun.fields[unwind.refundDestinationField];
2043
- if (destination && !references.accepts(destination, "acct")) {
2044
- add(
2045
- [...base, "unwind", "refundDestinationField"],
2046
- "refundDestinationField must be an account-id field",
3987
+ const offsets = quote.charges.flatMap((tier) =>
3988
+ tier.withinOffset ? [tier.withinOffset] : [],
2047
3989
  );
2048
- }
2049
- if (unwind.beforeField) {
2050
- const before = noun.fields[unwind.beforeField];
2051
- if (!before) {
3990
+ addDuplicateIssues(
3991
+ offsets,
3992
+ [...quoteBase, "charges"],
3993
+ "charge offset",
3994
+ add,
3995
+ );
3996
+ if (
3997
+ quote.charges.filter((tier) => tier.withinOffset === undefined).length > 1
3998
+ ) {
2052
3999
  add(
2053
- [...base, "unwind", "beforeField"],
2054
- `unwind references unknown field ${unwind.beforeField}`,
4000
+ [...quoteBase, "charges"],
4001
+ "a quote may declare at most one floor tier",
4002
+ "UDL5006",
2055
4003
  );
2056
- } else if (!isDateTimeFormat(before.format)) {
4004
+ }
4005
+ quote.charges.forEach((tier, index) => {
4006
+ if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
4007
+ add(
4008
+ [...quoteBase, "charges", index, "withinOffset"],
4009
+ "withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
4010
+ "UDL5006",
4011
+ );
4012
+ }
4013
+ });
4014
+ const committing = Object.entries(instrument.actions).filter(
4015
+ ([, candidate]) => candidate.commit === actionName,
4016
+ );
4017
+ if (committing.length !== 1) {
2057
4018
  add(
2058
- [...base, "unwind", "beforeField"],
2059
- "beforeField must be a date-time field",
4019
+ [...quoteBase],
4020
+ `quoting action ${actionName} must be committed by exactly one action, not ${committing.length}`,
4021
+ "UDL5006",
2060
4022
  );
2061
- } else if (!noun.required.includes(unwind.beforeField)) {
2062
- add([...base, "unwind", "beforeField"], "beforeField must be required");
2063
4023
  }
2064
4024
  }
2065
- for (const [verbName, verb] of [
2066
- ["quote", unwind.quote],
2067
- ["confirm", unwind.confirm],
2068
- ] as const) {
2069
- const definition = noun.verbs[verb];
2070
- if (!definition) {
4025
+
4026
+ const seededBy = new Map<string, string>();
4027
+ for (const [actionName, action] of quotingActions) {
4028
+ const quote = action.quote;
4029
+ if (!quote) continue;
4030
+ const quoteBase = [...base, "actions", actionName, "quote"] as const;
4031
+ for (const [slot, label, key] of [
4032
+ ["chargeRef", "charge", quote.chargeRef],
4033
+ ["netRef", "net", quote.netRef],
4034
+ ["netRef", "expiry stamp", quoteExpiresAtRefKey(quote)],
4035
+ ["netRef", "frozen fingerprint", quoteFrozenRefKey(quote)],
4036
+ ] as const) {
4037
+ const owner = seededBy.get(key);
4038
+ if (owner === undefined) {
4039
+ seededBy.set(key, `${actionName} ${label}`);
4040
+ continue;
4041
+ }
2071
4042
  add(
2072
- [...base, "unwind", verbName],
2073
- `unwind references unknown verb ${verb}`,
4043
+ [...quoteBase, slot],
4044
+ `quote ref ${key} is seeded twice: ${owner} and ${actionName} ${label}`,
4045
+ "UDL5006",
2074
4046
  );
4047
+ }
4048
+ }
4049
+
4050
+ for (const [actionName, action] of Object.entries(instrument.actions)) {
4051
+ const quotingName = action.commit;
4052
+ if (quotingName === undefined) continue;
4053
+ const commitBase = [...base, "actions", actionName, "commit"] as const;
4054
+ if (quotingName === actionName) {
4055
+ add(commitBase, "an action cannot commit its own quote", "UDL5006");
2075
4056
  continue;
2076
4057
  }
2077
- if (definition.earnable) {
4058
+ const quoting = instrument.actions[quotingName];
4059
+ if (!quoting) {
2078
4060
  add(
2079
- [...base, "verbs", verb, "earnable"],
2080
- `unwind ${verbName} verb ${verb} is refund-shaped and cannot be earnable`,
4061
+ commitBase,
4062
+ `commit references unknown action ${quotingName}`,
4063
+ "UDL5006",
2081
4064
  );
4065
+ continue;
2082
4066
  }
2083
- }
2084
- const confirm = noun.verbs[unwind.confirm];
2085
- if (confirm) {
2086
- const transfers = confirm.moves.filter(
4067
+ const quote = quoting.quote;
4068
+ if (!quote) {
4069
+ add(
4070
+ commitBase,
4071
+ `action ${quotingName} declares no quote to commit`,
4072
+ "UDL5006",
4073
+ );
4074
+ continue;
4075
+ }
4076
+ if (action.earnable) {
4077
+ add(
4078
+ [...base, "actions", actionName, "earnable"],
4079
+ `commit action ${actionName} spends a quoted refund and cannot be earnable`,
4080
+ "UDL5006",
4081
+ );
4082
+ }
4083
+ const transfers = action.moves.filter(
2087
4084
  (move) => move.operation === "internal_transfer.create",
2088
4085
  );
2089
- const refund = transfers[0];
2090
- const amount = refund?.bind.amount;
2091
- const sourceBinding = refund?.bind.sourceAccountId;
2092
- const destinationBinding = refund?.bind.destinationAccountId;
4086
+ const net = transfers[0];
4087
+ const amount = net?.bind.amount;
4088
+ const sourceBinding = net?.bind.sourceAccountId;
4089
+ const destinationBinding = net?.bind.destinationAccountId;
2093
4090
  if (
2094
4091
  transfers.length !== 1 ||
2095
4092
  amount?.from !== "instance" ||
2096
- amount.path !== "refs.unwindRefund" ||
4093
+ amount.path !== `refs.${quote.netRef}` ||
2097
4094
  sourceBinding?.from !== "instance" ||
2098
4095
  destinationBinding?.from !== "instance" ||
2099
- destinationBinding.path !== `fields.${unwind.refundDestinationField}`
4096
+ destinationBinding.path !== `fields.${quote.netDestinationField}`
2100
4097
  ) {
2101
4098
  add(
2102
- [...base, "verbs", unwind.confirm, "moves"],
2103
- `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}`,
4099
+ [...base, "actions", actionName, "moves"],
4100
+ `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}`,
4101
+ "UDL5006",
2104
4102
  );
2105
4103
  }
2106
4104
  }
2107
- const offsets = unwind.penalty.flatMap((tier) =>
2108
- tier.withinOffset ? [tier.withinOffset] : [],
2109
- );
2110
- addDuplicateIssues(
2111
- offsets,
2112
- [...base, "unwind", "penalty"],
2113
- "penalty offset",
2114
- add,
2115
- );
2116
- if (
2117
- unwind.penalty.filter((tier) => tier.withinOffset === undefined).length > 1
2118
- ) {
2119
- add(
2120
- [...base, "unwind", "penalty"],
2121
- "unwind may declare at most one floor tier",
2122
- );
2123
- }
2124
- unwind.penalty.forEach((tier, index) => {
2125
- if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
2126
- add(
2127
- [...base, "unwind", "penalty", index, "withinOffset"],
2128
- "withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
2129
- );
2130
- }
2131
- });
2132
4105
  }
2133
4106
 
2134
4107
  function validateAggregates(
2135
- noun: UdlNoun,
4108
+ instrument: UdlInstrument,
2136
4109
  base: readonly PropertyKey[],
2137
- nouns: ReadonlyMap<string, UdlNoun>,
4110
+ instruments: ReadonlyMap<string, UdlInstrument>,
2138
4111
  references: ReferenceShapeBudget,
2139
4112
  add: AddIssue,
2140
4113
  ): void {
2141
- const aggregates = noun.aggregateInvariants;
4114
+ const aggregates = instrument.aggregateInvariants;
2142
4115
  if (!aggregates) return;
2143
4116
  addDuplicateIssues(
2144
4117
  aggregates.map(
2145
4118
  (aggregate) =>
2146
- `${aggregate.childNounId}:${aggregate.childRefField}:${aggregateMeasureKey(aggregate)}:${aggregate.parentField}`,
4119
+ `${aggregate.childInstrumentId}:${aggregate.childRefField}:${aggregateMeasureKey(aggregate)}:${aggregate.parentField}`,
2147
4120
  ),
2148
4121
  [...base, "aggregateInvariants"],
2149
4122
  "aggregate invariant",
@@ -2156,37 +4129,42 @@ function validateAggregates(
2156
4129
  aggregateIndex,
2157
4130
  ] as const;
2158
4131
  const sum = "childField" in aggregate ? aggregate : undefined;
2159
- const parentField = noun.fields[aggregate.parentField];
4132
+ const parentField = instrument.fields[aggregate.parentField];
2160
4133
  if (!parentField) {
2161
4134
  add(
2162
4135
  [...aggregateBase, "parentField"],
2163
4136
  `aggregate references unknown parent field ${aggregate.parentField}`,
4137
+ "UDL5004",
2164
4138
  );
2165
4139
  } else if (sum && !isMoneySchema(parentField)) {
2166
4140
  add(
2167
4141
  [...aggregateBase, "parentField"],
2168
- `${noun.id}.${aggregate.parentField} must be a money field`,
4142
+ `${instrument.id}.${aggregate.parentField} must be a money field`,
4143
+ "UDL5004",
2169
4144
  );
2170
4145
  } else if (
2171
4146
  !sum &&
2172
4147
  (parentField.type !== "integer" ||
2173
- !noun.required.includes(aggregate.parentField))
4148
+ !instrument.required.includes(aggregate.parentField))
2174
4149
  ) {
2175
4150
  add(
2176
4151
  [...aggregateBase, "parentField"],
2177
- `${noun.id}.${aggregate.parentField} must be a required integer field to cap a count`,
4152
+ `${instrument.id}.${aggregate.parentField} must be a required integer field to cap a count`,
4153
+ "UDL5004",
2178
4154
  );
2179
- } else if (noun.update?.fields.includes(aggregate.parentField)) {
4155
+ } else if (instrument.update?.fields.includes(aggregate.parentField)) {
2180
4156
  add(
2181
4157
  [...aggregateBase, "parentField"],
2182
- `${noun.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`,
4158
+ `${instrument.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`,
4159
+ "UDL5004",
2183
4160
  );
2184
4161
  }
2185
- const child = nouns.get(aggregate.childNounId);
4162
+ const child = instruments.get(aggregate.childInstrumentId);
2186
4163
  if (!child) {
2187
4164
  add(
2188
- [...aggregateBase, "childNounId"],
2189
- `aggregate references unknown child noun ${aggregate.childNounId}`,
4165
+ [...aggregateBase, "childInstrumentId"],
4166
+ `aggregate references unknown child instrument ${aggregate.childInstrumentId}`,
4167
+ "UDL5004",
2190
4168
  );
2191
4169
  return;
2192
4170
  }
@@ -2196,16 +4174,19 @@ function validateAggregates(
2196
4174
  add(
2197
4175
  [...aggregateBase, "childField"],
2198
4176
  `aggregate references unknown child field ${sum.childField}`,
4177
+ "UDL5004",
2199
4178
  );
2200
4179
  } else if (!isMoneySchema(childField)) {
2201
4180
  add(
2202
4181
  [...aggregateBase, "childField"],
2203
4182
  `${child.id}.${sum.childField} must be a money field`,
4183
+ "UDL5004",
2204
4184
  );
2205
4185
  } else if (child.update?.fields.includes(sum.childField)) {
2206
4186
  add(
2207
4187
  [...aggregateBase, "childField"],
2208
4188
  `${child.id}.${sum.childField} cannot be updateable while it contributes to an aggregate`,
4189
+ "UDL5004",
2209
4190
  );
2210
4191
  }
2211
4192
  }
@@ -2216,6 +4197,7 @@ function validateAggregates(
2216
4197
  add(
2217
4198
  [...aggregateBase, "window", "field"],
2218
4199
  `aggregate window references unknown child field ${window.field}`,
4200
+ "UDL5004",
2219
4201
  );
2220
4202
  } else if (
2221
4203
  !isDateTimeFormat(windowField.format) ||
@@ -2224,11 +4206,13 @@ function validateAggregates(
2224
4206
  add(
2225
4207
  [...aggregateBase, "window", "field"],
2226
4208
  `${child.id}.${window.field} must be a required date-time field to window a count`,
4209
+ "UDL5004",
2227
4210
  );
2228
4211
  } else if (child.update?.fields.includes(window.field)) {
2229
4212
  add(
2230
4213
  [...aggregateBase, "window", "field"],
2231
4214
  `${child.id}.${window.field} cannot be updateable while it windows an aggregate`,
4215
+ "UDL5004",
2232
4216
  );
2233
4217
  }
2234
4218
  }
@@ -2237,16 +4221,19 @@ function validateAggregates(
2237
4221
  add(
2238
4222
  [...aggregateBase, "childRefField"],
2239
4223
  `aggregate references unknown child ref field ${aggregate.childRefField}`,
4224
+ "UDL5004",
2240
4225
  );
2241
- } else if (!references.accepts(refField, noun.idPrefix)) {
4226
+ } else if (!references.accepts(refField, instrument.idPrefix)) {
2242
4227
  add(
2243
4228
  [...aggregateBase, "childRefField"],
2244
- `${child.id}.${aggregate.childRefField} must reference ${noun.id}`,
4229
+ `${child.id}.${aggregate.childRefField} must reference ${instrument.id}`,
4230
+ "UDL5004",
2245
4231
  );
2246
4232
  } else if (child.update?.fields.includes(aggregate.childRefField)) {
2247
4233
  add(
2248
4234
  [...aggregateBase, "childRefField"],
2249
4235
  `${child.id}.${aggregate.childRefField} cannot be updateable while it links an aggregate`,
4236
+ "UDL5004",
2250
4237
  );
2251
4238
  }
2252
4239
  addDuplicateIssues(
@@ -2260,6 +4247,7 @@ function validateAggregates(
2260
4247
  add(
2261
4248
  [...aggregateBase, "childStatuses", statusIndex],
2262
4249
  `aggregate consumes unknown ${child.id} status ${status}`,
4250
+ "UDL5004",
2263
4251
  );
2264
4252
  }
2265
4253
  });
@@ -2267,11 +4255,11 @@ function validateAggregates(
2267
4255
  }
2268
4256
 
2269
4257
  function exampleInputSchema(
2270
- noun: UdlNoun,
2271
- verb: string,
2272
- definition: UdlVerb,
4258
+ instrument: UdlInstrument,
4259
+ action: string,
4260
+ definition: UdlAction,
2273
4261
  ): Schema {
2274
- if (verb !== "create") {
4262
+ if (action !== "create") {
2275
4263
  return (definition.input ?? {
2276
4264
  additionalProperties: false,
2277
4265
  properties: {},
@@ -2284,15 +4272,19 @@ function exampleInputSchema(
2284
4272
  ...(definition.requiresRefs ?? []).flatMap((gate) =>
2285
4273
  Object.keys(gate.bind ?? {}),
2286
4274
  ),
2287
- ...Object.values(noun.verbs).flatMap((candidate) =>
4275
+ ...Object.values(instrument.actions).flatMap((candidate) =>
2288
4276
  candidate.setsAt ? [candidate.setsAt.field] : [],
2289
4277
  ),
4278
+ ...(instrument.derivedAmounts ?? []).map((amount) => amount.field),
4279
+ ...(instrument.feeRules ?? []).flatMap((fee) =>
4280
+ fee.rule.kind === "exact" ? [] : [fee.amountField],
4281
+ ),
2290
4282
  ]);
2291
4283
  const properties: Record<string, unknown> = Object.fromEntries(
2292
- Object.entries(noun.fields).filter(([key]) => !boundKeys.has(key)),
4284
+ Object.entries(instrument.fields).filter(([key]) => !boundKeys.has(key)),
2293
4285
  );
2294
- const required = noun.required.filter((key) => !boundKeys.has(key));
2295
- if (noun.subject) {
4286
+ const required = instrument.required.filter((key) => !boundKeys.has(key));
4287
+ if (instrument.subject) {
2296
4288
  properties.subject = { minLength: 1, type: "string" };
2297
4289
  required.push("subject");
2298
4290
  }
@@ -2304,6 +4296,24 @@ function exampleInputSchema(
2304
4296
  } as Schema;
2305
4297
  }
2306
4298
 
4299
+ function exampleInputForUdlValidation(
4300
+ instrument: UdlInstrument,
4301
+ input: Readonly<Record<string, unknown>>,
4302
+ ): Readonly<Record<string, unknown>> {
4303
+ const camelId = instrument.id.replaceAll(/_([a-z])/g, (_, letter: string) =>
4304
+ letter.toUpperCase(),
4305
+ );
4306
+ const envelopeKeys = new Set([
4307
+ "actorAccountId",
4308
+ "tenantId",
4309
+ "productId",
4310
+ `${camelId}Id`,
4311
+ ]);
4312
+ return Object.fromEntries(
4313
+ Object.entries(input).filter(([key]) => !envelopeKeys.has(key)),
4314
+ );
4315
+ }
4316
+
2307
4317
  /** The measure half of an aggregate's identity: summed field, or count(+window). */
2308
4318
  function aggregateMeasureKey(aggregate: UdlAggregate): string {
2309
4319
  return "childField" in aggregate
@@ -2329,14 +4339,14 @@ function isCurrencySchema(schema: Readonly<Record<string, unknown>>): boolean {
2329
4339
  * call returns its own memo and its own counter, and nothing resets either
2330
4340
  * afterwards. Sharing is the caller's call, and both choices are made today:
2331
4341
  * `validateUdl` opens one budget for its semantic pass, while the engine's
2332
- * `checkComposerDocument` deliberately runs its noun-composition and
4342
+ * `checkComposerDocument` deliberately runs its instrument-composition and
2333
4343
  * gate-deadlock passes on a single shared budget.
2334
4344
  */
2335
4345
  export interface ReferenceShapeBudget {
2336
4346
  /**
2337
- * Does this field schema identify exactly one noun family, by accepting that
4347
+ * Does this field schema identify exactly one instrument family, by accepting that
2338
4348
  * family's scoped id and refusing a foreign one? Answering compiles two JSON
2339
- * Schema validators, and the question is asked once per declared noun per
4349
+ * Schema validators, and the question is asked once per declared instrument per
2340
4350
  * reference gate — a product a document controls both factors of. The answer
2341
4351
  * is pure in (schema, prefix), so each pair is paid for once per budget.
2342
4352
  *
@@ -2358,6 +4368,101 @@ export interface ReferenceShapeBudget {
2358
4368
  readonly exhausted: boolean;
2359
4369
  }
2360
4370
 
4371
+ /** Whether a reconcile exception child owns a reference back to its parent. */
4372
+ export function reconcileExceptionChildProblems(
4373
+ parentIdPrefix: string,
4374
+ child: {
4375
+ readonly fields: Readonly<
4376
+ Record<string, Readonly<Record<string, unknown>>>
4377
+ >;
4378
+ readonly required: readonly string[];
4379
+ },
4380
+ exception: {
4381
+ readonly amountField: string;
4382
+ readonly reasonField: string;
4383
+ readonly refField: string;
4384
+ },
4385
+ references: ReferenceShapeBudget,
4386
+ ): readonly UdlIssueCode[] {
4387
+ const problems: UdlIssueCode[] = [];
4388
+ const refSchema = child.fields[exception.refField];
4389
+ if (refSchema === undefined || !references.accepts(refSchema, parentIdPrefix))
4390
+ problems.push("UDL5007");
4391
+ const amountSchema = child.fields[exception.amountField];
4392
+ if (
4393
+ amountSchema === undefined ||
4394
+ !child.required.includes(exception.amountField)
4395
+ )
4396
+ problems.push("UDL5009");
4397
+ else if (!isMoneySchema(amountSchema)) problems.push("UDL5010");
4398
+ const reasonSchema = child.fields[exception.reasonField];
4399
+ if (
4400
+ reasonSchema === undefined ||
4401
+ !child.required.includes(exception.reasonField)
4402
+ )
4403
+ problems.push("UDL5011");
4404
+ else if (
4405
+ reasonSchema.type !== "string" ||
4406
+ isMoneySchema(reasonSchema) ||
4407
+ "pattern" in reasonSchema ||
4408
+ "format" in reasonSchema ||
4409
+ "enum" in reasonSchema
4410
+ )
4411
+ problems.push("UDL5012");
4412
+ return problems;
4413
+ }
4414
+
4415
+ export function isReconcileExceptionChild(
4416
+ parentIdPrefix: string,
4417
+ child: {
4418
+ readonly fields: Readonly<
4419
+ Record<string, Readonly<Record<string, unknown>>>
4420
+ >;
4421
+ readonly required: readonly string[];
4422
+ },
4423
+ exception: {
4424
+ readonly amountField: string;
4425
+ readonly reasonField: string;
4426
+ readonly refField: string;
4427
+ },
4428
+ references: ReferenceShapeBudget,
4429
+ ): boolean {
4430
+ return (
4431
+ reconcileExceptionChildProblems(
4432
+ parentIdPrefix,
4433
+ child,
4434
+ exception,
4435
+ references,
4436
+ ).length === 0
4437
+ );
4438
+ }
4439
+
4440
+ function reconcileExceptionProblemMessage(
4441
+ code: UdlIssueCode,
4442
+ parent: UdlInstrument,
4443
+ child: UdlInstrument,
4444
+ exception: {
4445
+ readonly amountField: string;
4446
+ readonly reasonField: string;
4447
+ readonly refField: string;
4448
+ },
4449
+ ): string {
4450
+ switch (code) {
4451
+ case "UDL5007":
4452
+ return `${child.id}.${exception.refField} must reference ${parent.id} to carry its breaks`;
4453
+ case "UDL5009":
4454
+ return `${child.id}.${exception.amountField} must be a required child money field`;
4455
+ case "UDL5010":
4456
+ return `${child.id}.${exception.amountField} must be a money field`;
4457
+ case "UDL5011":
4458
+ return `${child.id}.${exception.reasonField} must be a required child text field`;
4459
+ case "UDL5012":
4460
+ return `${child.id}.${exception.reasonField} must be a text field`;
4461
+ default:
4462
+ return "reconcile exception child is invalid";
4463
+ }
4464
+ }
4465
+
2361
4466
  /**
2362
4467
  * One sample id per prefix, built here and nowhere else. The probe below asks
2363
4468
  * a schema two questions and the answers are only comparable while both
@@ -2519,6 +4624,7 @@ function addDuplicateIssues(
2519
4624
  add(
2520
4625
  [...path, index],
2521
4626
  `duplicate ${label} ${value}; first declared at index ${previous}`,
4627
+ "UDL2001",
2522
4628
  );
2523
4629
  });
2524
4630
  }