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