@hyperscale0/udl 1.0.0 → 1.0.123

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