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