@hyperscale0/udl 2.6.0 → 3.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 +2 -322
- package/LICENSING.md +1 -2
- package/README.md +3 -114
- package/TRADEMARKS.md +2 -2
- package/dist/diagnostics.d.ts +21 -201
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +29 -193
- package/dist/diagnostics.js.map +1 -1
- package/dist/evolution.d.ts +3 -132
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +29 -633
- package/dist/evolution.js.map +1 -1
- package/dist/finance.d.ts +5 -72
- package/dist/finance.d.ts.map +1 -1
- package/dist/finance.js +233 -737
- package/dist/finance.js.map +1 -1
- package/dist/index.d.ts +7 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -12
- package/dist/index.js.map +1 -1
- package/dist/instrument-references.d.ts.map +1 -1
- package/dist/instrument-references.js +3 -2
- package/dist/instrument-references.js.map +1 -1
- package/dist/schema.d.ts +2079 -4564
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +270 -1609
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +8 -104
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +653 -3271
- package/dist/validation.js.map +1 -1
- package/docs/README.md +147 -14
- package/package.json +7 -11
- package/spec/README.md +151 -164
- package/spec/darb.udl.json +249 -0
- package/spec/udl.schema.json +4104 -3202
- package/src/diagnostics.ts +51 -246
- package/src/evolution.ts +39 -1045
- package/src/finance.ts +287 -1127
- package/src/index.ts +18 -142
- package/src/instrument-references.ts +3 -2
- package/src/schema.ts +285 -1843
- package/src/validation.ts +870 -5611
- package/conformance/README.md +0 -82
- package/conformance/evolution/action-contract.expected.json +0 -10
- package/conformance/evolution/action-contract.live.udl +0 -44
- package/conformance/evolution/action-contract.next.udl +0 -45
- package/conformance/evolution/product-identity.expected.json +0 -10
- package/conformance/evolution/product-identity.live.udl +0 -55
- package/conformance/evolution/product-identity.next.udl +0 -55
- package/conformance/evolution/version-required.expected.json +0 -10
- package/conformance/evolution/version-required.live.udl +0 -55
- package/conformance/evolution/version-required.next.udl +0 -58
- package/conformance/invalid/action-without-transition.expected.json +0 -10
- package/conformance/invalid/action-without-transition.udl +0 -60
- package/conformance/invalid/agent-description-too-long.expected.json +0 -10
- package/conformance/invalid/agent-description-too-long.udl +0 -57
- package/conformance/invalid/blank-title.expected.json +0 -10
- package/conformance/invalid/blank-title.udl +0 -54
- package/conformance/invalid/call-binds-results.expected.json +0 -10
- package/conformance/invalid/call-binds-results.udl +0 -314
- package/conformance/invalid/call-unknown-action.expected.json +0 -10
- package/conformance/invalid/call-unknown-action.udl +0 -314
- package/conformance/invalid/composition-dial-duplicate-key.expected.json +0 -10
- package/conformance/invalid/composition-dial-duplicate-key.udl +0 -73
- package/conformance/invalid/depth-budget.expected.json +0 -10
- package/conformance/invalid/depth-budget.udl +0 -49
- package/conformance/invalid/duplicate-subject.expected.json +0 -10
- package/conformance/invalid/duplicate-subject.udl +0 -230
- package/conformance/invalid/forged-effects.expected.json +0 -10
- package/conformance/invalid/forged-effects.udl +0 -76
- package/conformance/invalid/format-version.expected.json +0 -10
- package/conformance/invalid/format-version.udl +0 -54
- package/conformance/invalid/instrument-id-not-snake-case.expected.json +0 -10
- package/conformance/invalid/instrument-id-not-snake-case.udl +0 -54
- package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +0 -10
- package/conformance/invalid/invalid-aggregate-gate-shape.udl +0 -1819
- package/conformance/invalid/invalid-check-duration.expected.json +0 -10
- package/conformance/invalid/invalid-check-duration.udl +0 -219
- package/conformance/invalid/invalid-dial-anchor.expected.json +0 -10
- package/conformance/invalid/invalid-dial-anchor.udl +0 -219
- package/conformance/invalid/invalid-exception-parent-ref.expected.json +0 -10
- package/conformance/invalid/invalid-exception-parent-ref.udl +0 -2487
- package/conformance/invalid/invalid-exposure-shape.expected.json +0 -10
- package/conformance/invalid/invalid-exposure-shape.udl +0 -1819
- package/conformance/invalid/invalid-journeys.expected.json +0 -10
- package/conformance/invalid/invalid-journeys.udl +0 -77
- package/conformance/invalid/invalid-remainder.expected.json +0 -10
- package/conformance/invalid/invalid-remainder.udl +0 -220
- package/conformance/invalid/invalid-schema-keyword.expected.json +0 -10
- package/conformance/invalid/invalid-schema-keyword.udl +0 -220
- package/conformance/invalid/invalid-utf8.expected.json +0 -10
- package/conformance/invalid/invalid-utf8.udl +0 -1
- package/conformance/invalid/leaf-effect-mismatch.expected.json +0 -10
- package/conformance/invalid/leaf-effect-mismatch.udl +0 -314
- package/conformance/invalid/malformed-json.expected.json +0 -10
- package/conformance/invalid/malformed-json.udl +0 -1
- package/conformance/invalid/missing-create-action.expected.json +0 -10
- package/conformance/invalid/missing-create-action.udl +0 -48
- package/conformance/invalid/missing-exception-amount-field.expected.json +0 -10
- package/conformance/invalid/missing-exception-amount-field.udl +0 -2487
- package/conformance/invalid/missing-exception-contract.expected.json +0 -14
- package/conformance/invalid/missing-exception-contract.udl +0 -2523
- package/conformance/invalid/missing-exception-reason-field.expected.json +0 -10
- package/conformance/invalid/missing-exception-reason-field.udl +0 -2487
- package/conformance/invalid/not-an-object.expected.json +0 -10
- package/conformance/invalid/not-an-object.udl +0 -1
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +0 -10
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +0 -259
- package/conformance/invalid/piece-plan-without-partition.expected.json +0 -10
- package/conformance/invalid/piece-plan-without-partition.udl +0 -305
- package/conformance/invalid/private-action-independent-approval.expected.json +0 -10
- package/conformance/invalid/private-action-independent-approval.udl +0 -314
- package/conformance/invalid/quote-freeze-set-incomplete.expected.json +0 -10
- package/conformance/invalid/quote-freeze-set-incomplete.udl +0 -261
- package/conformance/invalid/quote-named-reference-gate.expected.json +0 -10
- package/conformance/invalid/quote-named-reference-gate.udl +0 -50
- package/conformance/invalid/reconcile-named-reference-gate.expected.json +0 -10
- package/conformance/invalid/reconcile-named-reference-gate.udl +0 -50
- package/conformance/invalid/unfund-order-not-reversed.expected.json +0 -10
- package/conformance/invalid/unfund-order-not-reversed.udl +0 -314
- package/conformance/invalid/unknown-key.expected.json +0 -10
- package/conformance/invalid/unknown-key.udl +0 -55
- package/conformance/invalid/unknown-reference-gate-field.expected.json +0 -10
- package/conformance/invalid/unknown-reference-gate-field.udl +0 -1819
- package/conformance/invalid/unknown-required-field.expected.json +0 -10
- package/conformance/invalid/unknown-required-field.udl +0 -220
- package/conformance/invalid/unreachable-state.expected.json +0 -10
- package/conformance/invalid/unreachable-state.udl +0 -55
- package/conformance/invalid/wrong-exception-amount-field.expected.json +0 -10
- package/conformance/invalid/wrong-exception-amount-field.udl +0 -2487
- package/conformance/invalid/wrong-exception-reason-field.expected.json +0 -10
- package/conformance/invalid/wrong-exception-reason-field.udl +0 -2487
- package/conformance/valid/agent-description.expected.json +0 -6
- package/conformance/valid/agent-description.udl +0 -66
- package/conformance/valid/attested.expected.json +0 -6
- package/conformance/valid/attested.udl +0 -251
- package/conformance/valid/cards.expected.json +0 -6
- package/conformance/valid/cards.udl +0 -1579
- package/conformance/valid/commerce-escrow.expected.json +0 -6
- package/conformance/valid/commerce-escrow.udl +0 -1512
- package/conformance/valid/compiled-crowdfunding.expected.json +0 -6
- package/conformance/valid/compiled-crowdfunding.udl +0 -1843
- package/conformance/valid/compiled-watch-club.expected.json +0 -6
- package/conformance/valid/compiled-watch-club.udl +0 -2486
- package/conformance/valid/complete-contract.expected.json +0 -6
- package/conformance/valid/complete-contract.udl +0 -218
- package/conformance/valid/effect-signatures.expected.json +0 -6
- package/conformance/valid/effect-signatures.udl +0 -75
- package/conformance/valid/hand-edited.expected.json +0 -6
- package/conformance/valid/hand-edited.udl +0 -1
- package/conformance/valid/insured-car-marketplace.expected.json +0 -6
- package/conformance/valid/insured-car-marketplace.udl +0 -1050
- package/conformance/valid/insured-travel.expected.json +0 -6
- package/conformance/valid/insured-travel.udl +0 -3469
- package/conformance/valid/minimal.expected.json +0 -6
- package/conformance/valid/minimal.udl +0 -62
- package/conformance/valid/piece-plan-calls.expected.json +0 -6
- package/conformance/valid/piece-plan-calls.udl +0 -314
- package/conformance/valid/protection.expected.json +0 -6
- package/conformance/valid/protection.udl +0 -1551
- package/conformance/valid/string-escaping.expected.json +0 -6
- package/conformance/valid/string-escaping.udl +0 -54
- package/conformance/valid/vocabulary.expected.json +0 -6
- package/conformance/valid/vocabulary.udl +0 -2012
- package/dist/allocation.d.ts +0 -60
- package/dist/allocation.d.ts.map +0 -1
- package/dist/allocation.js +0 -177
- package/dist/allocation.js.map +0 -1
- package/dist/check-profiles.d.ts +0 -57
- package/dist/check-profiles.d.ts.map +0 -1
- package/dist/check-profiles.js +0 -62
- package/dist/check-profiles.js.map +0 -1
- package/dist/distribution.d.ts +0 -15
- package/dist/distribution.d.ts.map +0 -1
- package/dist/distribution.js +0 -49
- package/dist/distribution.js.map +0 -1
- package/dist/effects.d.ts +0 -58
- package/dist/effects.d.ts.map +0 -1
- package/dist/effects.js +0 -1126
- package/dist/effects.js.map +0 -1
- package/dist/reference.d.ts +0 -3
- package/dist/reference.d.ts.map +0 -1
- package/dist/reference.js +0 -28
- package/dist/reference.js.map +0 -1
- package/dist/vocabulary.d.ts +0 -23
- package/dist/vocabulary.d.ts.map +0 -1
- package/dist/vocabulary.js +0 -1052
- package/dist/vocabulary.js.map +0 -1
- package/docs/funding-custody.md +0 -165
- package/docs/guide/01-a-document.md +0 -37
- package/docs/guide/02-money-steps.md +0 -23
- package/docs/guide/03-laws.md +0 -18
- package/docs/guide/04-fees-and-remainder.md +0 -36
- package/docs/guide/05-checks-updates-dials.md +0 -7
- package/docs/guide/06-effects.md +0 -11
- package/docs/guide/07-evolution.md +0 -11
- package/docs/guide/08-implementing.md +0 -13
- package/docs/guide/09-schedules-and-allocation.md +0 -132
- package/docs/llms-full.txt +0 -2008
- package/docs/llms.txt +0 -14
- package/docs/piece-plans.md +0 -148
- package/docs/reference/canonical.md +0 -16
- package/docs/reference/clauses.md +0 -1585
- package/docs/reference/cli.md +0 -24
- package/docs/reference/diagnostics.md +0 -38
- package/skills/udl/SKILL.md +0 -28
- package/src/allocation.ts +0 -259
- package/src/check-profiles.ts +0 -80
- package/src/distribution.ts +0 -61
- package/src/effects.ts +0 -1920
- package/src/reference.ts +0 -31
- package/src/vocabulary.ts +0 -1635
package/dist/validation.js
CHANGED
|
@@ -1,3298 +1,680 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { validateVocabulary } from "./vocabulary.js";
|
|
3
|
-
import { Validator, } from "@cfworker/json-schema";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { udlClauseVocabulary, quoteExpiresAtRefKey, quoteFrozenRefKey, quoteSeededRefKeys, udlDocumentSchema, } from "./schema.js";
|
|
6
|
-
import { fixedIsoDurationMs } from "./duration.js";
|
|
7
|
-
import { analyzeInstrumentFinance, financeAdmissionProblem, } from "./finance.js";
|
|
8
|
-
import { UDL_LIMITS } from "./limits.js";
|
|
9
|
-
import { deriveUdlActionEffects, resolveUdlActionPlans, udlEffectKinds, } from "./effects.js";
|
|
1
|
+
import { udlDocumentSchema, } from "./schema.js";
|
|
10
2
|
import { issue } from "./diagnostics.js";
|
|
3
|
+
import { analyzeInstrumentFinance } from "./finance.js";
|
|
11
4
|
export class UdlError extends Error {
|
|
12
5
|
issues;
|
|
13
6
|
constructor(issues) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
? `Invalid UDL at ${first.path}: ${first.message}`
|
|
17
|
-
: "Invalid UDL document");
|
|
7
|
+
super(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
|
|
8
|
+
this.issues = issues;
|
|
18
9
|
this.name = "UdlError";
|
|
19
|
-
this.issues = Object.freeze([...issues]);
|
|
20
10
|
}
|
|
21
11
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (reconcileShapeIssues.length > 0)
|
|
34
|
-
return { issues: reconcileShapeIssues, ok: false };
|
|
35
|
-
const parsed = udlDocumentSchema.safeParse(value);
|
|
36
|
-
if (!parsed.success) {
|
|
37
|
-
return {
|
|
38
|
-
issues: parsed.error.issues.map((entry) => issue(shapeIssueCode(entry.path), jsonPath(entry.path), entry.message)),
|
|
39
|
-
ok: false,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
const references = openReferenceShapeBudget();
|
|
43
|
-
const issues = semanticIssues(parsed.data, references, options);
|
|
44
|
-
if (references.exhausted) {
|
|
45
|
-
return {
|
|
46
|
-
issues: [
|
|
47
|
-
issue("UDL1004", "$.instruments", `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer instruments, reference gates, or payout intents`),
|
|
48
|
-
],
|
|
49
|
-
ok: false,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
return issues.length === 0
|
|
53
|
-
? { ok: true, value: parsed.data }
|
|
54
|
-
: { issues, ok: false };
|
|
55
|
-
}
|
|
56
|
-
function shapeIssueCode(path) {
|
|
57
|
-
if (path.includes("requiresExposure"))
|
|
58
|
-
return "UDL5003";
|
|
59
|
-
if (path.includes("requiresAggregate"))
|
|
60
|
-
return "UDL5004";
|
|
61
|
-
return "UDL1003";
|
|
62
|
-
}
|
|
63
|
-
function reconcileExceptionRequiredFieldIssues(value) {
|
|
64
|
-
const issues = [];
|
|
65
|
-
const instruments = recordValue(value).instruments;
|
|
66
|
-
if (!Array.isArray(instruments))
|
|
67
|
-
return issues;
|
|
68
|
-
instruments.forEach((instrument, instrumentIndex) => {
|
|
69
|
-
const actions = recordValue(instrument).actions;
|
|
70
|
-
if (!actions || typeof actions !== "object" || Array.isArray(actions))
|
|
71
|
-
return;
|
|
72
|
-
for (const [action, definition] of Object.entries(actions)) {
|
|
73
|
-
const reconciles = recordValue(definition).reconcile;
|
|
74
|
-
if (!Array.isArray(reconciles))
|
|
75
|
-
continue;
|
|
76
|
-
reconciles.forEach((reconcile, reconcileIndex) => {
|
|
77
|
-
const exception = recordValue(recordValue(reconcile).exception);
|
|
78
|
-
const base = jsonPath([
|
|
79
|
-
"instruments",
|
|
80
|
-
instrumentIndex,
|
|
81
|
-
"actions",
|
|
82
|
-
action,
|
|
83
|
-
"reconcile",
|
|
84
|
-
reconcileIndex,
|
|
85
|
-
"exception",
|
|
86
|
-
]);
|
|
87
|
-
if (!("amountField" in exception)) {
|
|
88
|
-
issues.push(issue("UDL5009", `${base}.amountField`));
|
|
89
|
-
}
|
|
90
|
-
if (!("reasonField" in exception)) {
|
|
91
|
-
issues.push(issue("UDL5011", `${base}.reasonField`));
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
return issues;
|
|
97
|
-
}
|
|
98
|
-
export function assertValidUdl(value) {
|
|
99
|
-
const result = validateUdl(value);
|
|
100
|
-
if (!result.ok)
|
|
101
|
-
throw new UdlError(result.issues);
|
|
102
|
-
return result.value;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Applies the sealed UDL JSON Schema subset to one value. Both inputs pass the
|
|
106
|
-
* same deterministic admission budgets as a complete UDL document before the
|
|
107
|
-
* standard validator or sealed format validators run.
|
|
108
|
-
*/
|
|
109
|
-
export function validateUdlSchemaValue(schema, value) {
|
|
110
|
-
const schemaAdmission = structuralBudgetIssue(schema);
|
|
111
|
-
if (schemaAdmission)
|
|
112
|
-
return admissionValidationResult(schemaAdmission, "invalid_schema");
|
|
113
|
-
const valueAdmission = structuralBudgetIssue(value);
|
|
114
|
-
if (valueAdmission)
|
|
115
|
-
return admissionValidationResult(valueAdmission, "invalid_value");
|
|
116
|
-
const schemaErrors = [];
|
|
117
|
-
validateJsonSchema(schema, [], (path, message) => {
|
|
118
|
-
schemaErrors.push({
|
|
119
|
-
error: message,
|
|
120
|
-
instanceLocation: "",
|
|
121
|
-
keyword: "invalid_schema",
|
|
122
|
-
keywordLocation: jsonPath(path),
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
if (schemaErrors.length > 0) {
|
|
126
|
-
return { errors: schemaErrors, valid: false };
|
|
127
|
-
}
|
|
128
|
-
const boundedSchema = structuredClone(schema);
|
|
129
|
-
const validator = new Validator(boundedSchema, "2020-12", false);
|
|
130
|
-
const standard = validator.validate(value);
|
|
131
|
-
const sealedErrors = sealedFormatErrors(value, boundedSchema);
|
|
132
|
-
const errors = [...standard.errors, ...sealedErrors];
|
|
133
|
-
return { errors, valid: errors.length === 0 };
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Validates one schema document against the sealed UDL JSON Schema subset
|
|
137
|
-
* without applying it to a value. Frozen tenant contracts use this at their
|
|
138
|
-
* persistence boundary so an unreadable schema cannot become immutable state.
|
|
139
|
-
*/
|
|
140
|
-
export function validateUdlJsonSchema(schema) {
|
|
141
|
-
const admissionIssue = structuralBudgetIssue(schema);
|
|
142
|
-
if (admissionIssue)
|
|
143
|
-
return [admissionIssue];
|
|
144
|
-
if (!isRecord(schema)) {
|
|
145
|
-
return [issue("UDL1003", "$", "JSON Schema must be an object")];
|
|
146
|
-
}
|
|
147
|
-
const issues = [];
|
|
148
|
-
validateJsonSchema(schema, [], (path, message) => {
|
|
149
|
-
issues.push(issue("UDL6001", jsonPath(path), message));
|
|
150
|
-
});
|
|
151
|
-
return issues;
|
|
152
|
-
}
|
|
153
|
-
function admissionValidationResult(issue, invalidKeyword) {
|
|
154
|
-
return {
|
|
155
|
-
errors: [
|
|
156
|
-
{
|
|
157
|
-
error: issue.message,
|
|
158
|
-
instanceLocation: issue.path,
|
|
159
|
-
keyword: issue.category === "resource_limit"
|
|
160
|
-
? "resource_limit"
|
|
161
|
-
: invalidKeyword,
|
|
162
|
-
keywordLocation: "$",
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
valid: false,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
|
|
169
|
-
const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
|
|
170
|
-
const currencyPattern = "^[A-Z]{3}$";
|
|
171
|
-
const sealedDateTimeFormat = "hyperscale-date-time";
|
|
172
|
-
const jsonSchemaFormats = new Set([
|
|
173
|
-
"hyperscale-date",
|
|
174
|
-
sealedDateTimeFormat,
|
|
175
|
-
"hyperscale-email",
|
|
176
|
-
"hyperscale-uri",
|
|
177
|
-
]);
|
|
178
|
-
const jsonSchemaTypes = new Set([
|
|
179
|
-
"array",
|
|
180
|
-
"boolean",
|
|
181
|
-
"integer",
|
|
182
|
-
"object",
|
|
183
|
-
"string",
|
|
184
|
-
]);
|
|
185
|
-
const jsonSchemaKeywords = new Set([
|
|
186
|
-
"additionalProperties",
|
|
187
|
-
"const",
|
|
188
|
-
"description",
|
|
189
|
-
"enum",
|
|
190
|
-
"format",
|
|
191
|
-
"items",
|
|
192
|
-
"maxItems",
|
|
193
|
-
"maxLength",
|
|
194
|
-
"maximum",
|
|
195
|
-
"minItems",
|
|
196
|
-
"minLength",
|
|
197
|
-
"minimum",
|
|
198
|
-
"pattern",
|
|
199
|
-
"properties",
|
|
200
|
-
"required",
|
|
201
|
-
"title",
|
|
202
|
-
"type",
|
|
203
|
-
"x-hyperscale-currency",
|
|
204
|
-
"x-hyperscale-fee-collection-port",
|
|
205
|
-
"x-hyperscale-reference-filter",
|
|
206
|
-
]);
|
|
207
|
-
function semanticIssues(document, references, options) {
|
|
208
|
-
const issues = [];
|
|
209
|
-
const add = (path, message, code) => {
|
|
210
|
-
issues.push(issue(code, jsonPath(path), message));
|
|
211
|
-
};
|
|
212
|
-
document.instruments.forEach((instrument, instrumentIndex) => {
|
|
213
|
-
const problem = financeAdmissionProblem(instrument);
|
|
214
|
-
if (problem)
|
|
215
|
-
add(["instruments", instrumentIndex, "lifecycle"], problem, "UDL1004");
|
|
216
|
-
});
|
|
217
|
-
if (issues.length > 0)
|
|
218
|
-
return issues;
|
|
219
|
-
validateDocumentSchemas(document, add);
|
|
220
|
-
if (issues.length > 0)
|
|
221
|
-
return issues;
|
|
222
|
-
// Vocabulary laws must accumulate with the existing semantic diagnostics.
|
|
223
|
-
validateVocabulary(document.instruments, (path, message) => add(path, message, path.some((part) => [
|
|
224
|
-
"attests",
|
|
225
|
-
"requiresInput",
|
|
226
|
-
"engineOwned",
|
|
227
|
-
"captureEngine",
|
|
228
|
-
"unique",
|
|
229
|
-
"port",
|
|
230
|
-
].includes(String(part)))
|
|
231
|
-
? "UDL5001"
|
|
232
|
-
: shapeIssueCode(path)));
|
|
233
|
-
addDuplicateIssues(document.subjects.map((subject) => subject.kind), ["subjects"], "subject kind", add);
|
|
234
|
-
addDuplicateIssues(document.instruments.map((instrument) => instrument.id), ["instruments"], "instrument id", add);
|
|
235
|
-
addDuplicateIssues(document.instruments.map((instrument) => instrument.idPrefix), ["instruments"], "instrument idPrefix", add);
|
|
236
|
-
validateCompositionDials(document, add);
|
|
237
|
-
const subjects = new Map(document.subjects.map((subject) => [subject.kind, subject]));
|
|
238
|
-
const instruments = new Map(document.instruments.map((instrument) => [instrument.id, instrument]));
|
|
239
|
-
for (const [subjectIndex, subject] of document.subjects.entries()) {
|
|
240
|
-
if (subject.schema.type !== "object") {
|
|
241
|
-
add(["subjects", subjectIndex, "schema", "type"], "a subject attribute schema must declare type object", "UDL2002");
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
for (const [instrumentIndex, instrument] of document.instruments.entries()) {
|
|
245
|
-
const resolvedPlansResult = resolveUdlActionPlans(instrument);
|
|
246
|
-
for (const [actionName, action] of Object.entries(instrument.actions)) {
|
|
247
|
-
if (options.requireDecisionPartyBindings) {
|
|
248
|
-
for (const [index, role] of (action.port?.allowedParties ?? []).entries()) {
|
|
249
|
-
if (!Object.hasOwn(instrument.parties ?? {}, role)) {
|
|
250
|
-
add([
|
|
251
|
-
"instruments",
|
|
252
|
-
instrumentIndex,
|
|
253
|
-
"actions",
|
|
254
|
-
actionName,
|
|
255
|
-
"port",
|
|
256
|
-
"allowedParties",
|
|
257
|
-
index,
|
|
258
|
-
], `decision port allows party role ${role}, which the instrument does not declare`, "UDL5008");
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
if (!action.effects)
|
|
263
|
-
continue;
|
|
264
|
-
let expected;
|
|
265
|
-
if (action.calls && action.calls.length > 0) {
|
|
266
|
-
const plan = resolvedPlansResult.plans.find((p) => p.action === actionName);
|
|
267
|
-
expected = plan ? plan.effects : {};
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
expected = deriveUdlActionEffects(action, udlClauseVocabulary);
|
|
271
|
-
}
|
|
272
|
-
for (const kind of udlEffectKinds) {
|
|
273
|
-
const actualRows = action.effects[kind] ?? [];
|
|
274
|
-
const expectedRows = expected[kind] ?? [];
|
|
275
|
-
if (actualRows.length === expectedRows.length &&
|
|
276
|
-
actualRows.every((row, index) => {
|
|
277
|
-
const expectedRow = expectedRows[index];
|
|
278
|
-
return (expectedRow !== undefined &&
|
|
279
|
-
row.signature === expectedRow.signature &&
|
|
280
|
-
row.source === expectedRow.source &&
|
|
281
|
-
("channel" in row ? row.channel : undefined) ===
|
|
282
|
-
expectedRow.channel &&
|
|
283
|
-
("role" in row ? row.role : undefined) === expectedRow.role);
|
|
284
|
-
})) {
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
add([
|
|
288
|
-
"instruments",
|
|
289
|
-
instrumentIndex,
|
|
290
|
-
"actions",
|
|
291
|
-
actionName,
|
|
292
|
-
"effects",
|
|
293
|
-
kind,
|
|
294
|
-
], `derived ${kind} effects do not match the action clauses`, "UDL2005");
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
validateInstrument(instrument, instrumentIndex, instruments, subjects, references, add);
|
|
298
|
-
}
|
|
299
|
-
return issues;
|
|
300
|
-
}
|
|
301
|
-
function structuralBudgetIssue(value) {
|
|
302
|
-
let discovered = 1;
|
|
303
|
-
let nodes = 0;
|
|
304
|
-
let totalStringLength = 0;
|
|
305
|
-
const pending = [{ ancestors: new Set(), countNode: true, depth: 1, path: [], value }];
|
|
306
|
-
while (pending.length > 0) {
|
|
307
|
-
const entry = pending.pop();
|
|
308
|
-
if (entry.countNode)
|
|
309
|
-
nodes += 1;
|
|
310
|
-
if (nodes > UDL_LIMITS.maxNodes) {
|
|
311
|
-
return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
|
|
312
|
-
}
|
|
313
|
-
if (entry.depth > UDL_LIMITS.maxDepth) {
|
|
314
|
-
return resourceIssue(entry.path, `UDL nesting exceeds ${UDL_LIMITS.maxDepth} levels`);
|
|
315
|
-
}
|
|
316
|
-
if (typeof entry.value === "string") {
|
|
317
|
-
if (entry.value.length > UDL_LIMITS.maxStringLength) {
|
|
318
|
-
return resourceIssue(entry.path, `UDL string exceeds ${UDL_LIMITS.maxStringLength} characters`);
|
|
319
|
-
}
|
|
320
|
-
totalStringLength += entry.value.length;
|
|
321
|
-
if (totalStringLength > UDL_LIMITS.maxTotalStringLength) {
|
|
322
|
-
return resourceIssue(entry.path, `UDL strings exceed ${UDL_LIMITS.maxTotalStringLength} total characters`);
|
|
323
|
-
}
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (entry.value === null || typeof entry.value === "boolean")
|
|
327
|
-
continue;
|
|
328
|
-
if (typeof entry.value === "number") {
|
|
329
|
-
if (Number.isFinite(entry.value))
|
|
330
|
-
continue;
|
|
331
|
-
return jsonValueIssue(entry.path);
|
|
332
|
-
}
|
|
333
|
-
if (typeof entry.value !== "object") {
|
|
334
|
-
return jsonValueIssue(entry.path);
|
|
335
|
-
}
|
|
336
|
-
if (entry.ancestors.has(entry.value)) {
|
|
337
|
-
return resourceIssue(entry.path, "UDL must not contain object cycles");
|
|
338
|
-
}
|
|
339
|
-
const childAncestors = new Set(entry.ancestors);
|
|
340
|
-
childAncestors.add(entry.value);
|
|
341
|
-
if (Array.isArray(entry.value)) {
|
|
342
|
-
if (entry.countNode &&
|
|
343
|
-
entry.value.length > UDL_LIMITS.maxNodes - discovered) {
|
|
344
|
-
return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
|
|
345
|
-
}
|
|
346
|
-
if (entry.countNode)
|
|
347
|
-
discovered += entry.value.length;
|
|
348
|
-
for (let index = entry.value.length - 1; index >= 0; index -= 1) {
|
|
349
|
-
pending.push({
|
|
350
|
-
ancestors: childAncestors,
|
|
351
|
-
countNode: entry.countNode,
|
|
352
|
-
depth: entry.depth + 1,
|
|
353
|
-
path: [...entry.path, index],
|
|
354
|
-
value: entry.value[index],
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
const prototype = Object.getPrototypeOf(entry.value);
|
|
360
|
-
if (prototype !== Object.prototype && prototype !== null) {
|
|
361
|
-
return jsonValueIssue(entry.path);
|
|
362
|
-
}
|
|
363
|
-
let childCount = 0;
|
|
364
|
-
for (const key in entry.value) {
|
|
365
|
-
if (!Object.hasOwn(entry.value, key))
|
|
366
|
-
continue;
|
|
367
|
-
if (countsAsAuthoredNode(entry, key))
|
|
368
|
-
childCount += 1;
|
|
369
|
-
if (childCount > UDL_LIMITS.maxNodes - discovered) {
|
|
370
|
-
return resourceIssue(entry.path, `UDL contains more than ${UDL_LIMITS.maxNodes} values`);
|
|
371
|
-
}
|
|
372
|
-
if (key.length > UDL_LIMITS.maxKeyLength) {
|
|
373
|
-
return resourceIssue([...entry.path, key], `UDL key exceeds ${UDL_LIMITS.maxKeyLength} characters`);
|
|
374
|
-
}
|
|
375
|
-
totalStringLength += key.length;
|
|
376
|
-
if (totalStringLength > UDL_LIMITS.maxTotalStringLength) {
|
|
377
|
-
return resourceIssue([...entry.path, key], `UDL strings exceed ${UDL_LIMITS.maxTotalStringLength} total characters`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
discovered += childCount;
|
|
381
|
-
for (const key in entry.value) {
|
|
382
|
-
if (!Object.hasOwn(entry.value, key))
|
|
383
|
-
continue;
|
|
384
|
-
pending.push({
|
|
385
|
-
ancestors: childAncestors,
|
|
386
|
-
countNode: countsAsAuthoredNode(entry, key),
|
|
387
|
-
depth: entry.depth + 1,
|
|
388
|
-
path: [...entry.path, key],
|
|
389
|
-
value: entry.value[key],
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return undefined;
|
|
394
|
-
}
|
|
395
|
-
function countsAsAuthoredNode(entry, key) {
|
|
396
|
-
return !(!entry.countNode ||
|
|
397
|
-
(key === "effects" &&
|
|
398
|
-
entry.path.length === 4 &&
|
|
399
|
-
entry.path[0] === "instruments" &&
|
|
400
|
-
typeof entry.path[1] === "number" &&
|
|
401
|
-
entry.path[2] === "actions" &&
|
|
402
|
-
typeof entry.path[3] === "string"));
|
|
403
|
-
}
|
|
404
|
-
function resourceIssue(path, message) {
|
|
405
|
-
return issue("UDL1004", jsonPath(path), message);
|
|
406
|
-
}
|
|
407
|
-
function jsonValueIssue(path) {
|
|
408
|
-
return issue("UDL1003", jsonPath(path), "UDL must contain only JSON values");
|
|
409
|
-
}
|
|
410
|
-
function isRecord(value) {
|
|
411
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
412
|
-
}
|
|
413
|
-
function validateDocumentSchemas(document, add) {
|
|
414
|
-
const addSchemaIssue = (path, message) => add(path, message, "UDL6001");
|
|
415
|
-
document.subjects.forEach((subject, subjectIndex) => validateJsonSchema(subject.schema, ["subjects", subjectIndex, "schema"], addSchemaIssue));
|
|
416
|
-
document.instruments.forEach((instrument, instrumentIndex) => {
|
|
417
|
-
for (const [field, schema] of Object.entries(instrument.fields)) {
|
|
418
|
-
validateJsonSchema(schema, ["instruments", instrumentIndex, "fields", field], addSchemaIssue);
|
|
419
|
-
}
|
|
420
|
-
for (const [action, definition] of Object.entries(instrument.actions)) {
|
|
421
|
-
if (definition.input) {
|
|
422
|
-
validateJsonSchema(definition.input, ["instruments", instrumentIndex, "actions", action, "input"], addSchemaIssue);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
function validateJsonSchema(schema, path, add) {
|
|
428
|
-
for (const keyword of Object.keys(schema)) {
|
|
429
|
-
if (!jsonSchemaKeywords.has(keyword)) {
|
|
430
|
-
add([...path, keyword], `JSON Schema keyword ${keyword} is not in the UDL schema subset`, "UDL6001");
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
if (typeof schema.type !== "string" || !jsonSchemaTypes.has(schema.type)) {
|
|
434
|
-
add([...path, "type"], `JSON Schema type must be one of ${[...jsonSchemaTypes].join(", ")}`, "UDL6001");
|
|
435
|
-
}
|
|
436
|
-
for (const keyword of ["description", "title"]) {
|
|
437
|
-
if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "string") {
|
|
438
|
-
add([...path, keyword], `JSON Schema ${keyword} must be a string`, "UDL6001");
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
if (Object.hasOwn(schema, "enum") &&
|
|
442
|
-
(!Array.isArray(schema.enum) || schema.enum.length === 0)) {
|
|
443
|
-
add([...path, "enum"], "JSON Schema enum must be a non-empty array", "UDL6001");
|
|
444
|
-
}
|
|
445
|
-
if (Object.hasOwn(schema, "properties") &&
|
|
446
|
-
(!isRecord(schema.properties) || schema.type !== "object")) {
|
|
447
|
-
add([...path, "properties"], "JSON Schema properties require an object schema and object value", "UDL6001");
|
|
448
|
-
}
|
|
449
|
-
if (Object.hasOwn(schema, "required")) {
|
|
450
|
-
if (schema.type !== "object") {
|
|
451
|
-
add([...path, "required"], "JSON Schema required is only valid on an object schema", "UDL6001");
|
|
452
|
-
}
|
|
453
|
-
else if (!Array.isArray(schema.required) ||
|
|
454
|
-
!schema.required.every((field) => typeof field === "string")) {
|
|
455
|
-
add([...path, "required"], "JSON Schema required must be an array of property names", "UDL6001");
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
const properties = recordValue(schema.properties);
|
|
459
|
-
for (const field of schema.required) {
|
|
460
|
-
if (!Object.hasOwn(properties, field)) {
|
|
461
|
-
add([...path, "required"], `JSON Schema required references undeclared property ${field}`, "UDL6001");
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (Object.hasOwn(schema, "items") &&
|
|
467
|
-
(!isRecord(schema.items) || schema.type !== "array")) {
|
|
468
|
-
add([...path, "items"], "JSON Schema items require an array schema and object value", "UDL6001");
|
|
469
|
-
}
|
|
470
|
-
if (Object.hasOwn(schema, "additionalProperties") &&
|
|
471
|
-
(schema.type !== "object" ||
|
|
472
|
-
(typeof schema.additionalProperties !== "boolean" &&
|
|
473
|
-
!isRecord(schema.additionalProperties)))) {
|
|
474
|
-
add([...path, "additionalProperties"], "JSON Schema additionalProperties requires an object schema and a boolean or schema value", "UDL6001");
|
|
475
|
-
}
|
|
476
|
-
for (const keyword of [
|
|
477
|
-
"maxItems",
|
|
478
|
-
"maxLength",
|
|
479
|
-
"minItems",
|
|
480
|
-
"minLength",
|
|
481
|
-
]) {
|
|
482
|
-
const value = schema[keyword];
|
|
483
|
-
if (Object.hasOwn(schema, keyword) &&
|
|
484
|
-
((keyword.endsWith("Items")
|
|
485
|
-
? schema.type !== "array"
|
|
486
|
-
: schema.type !== "string") ||
|
|
487
|
-
typeof value !== "number" ||
|
|
488
|
-
!Number.isInteger(value) ||
|
|
489
|
-
value < 0)) {
|
|
490
|
-
add([...path, keyword], `JSON Schema ${keyword} must be a non-negative integer on the matching schema type`, "UDL6001");
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
for (const keyword of ["maximum", "minimum"]) {
|
|
494
|
-
const value = schema[keyword];
|
|
495
|
-
if (Object.hasOwn(schema, keyword) &&
|
|
496
|
-
(schema.type !== "integer" ||
|
|
497
|
-
typeof value !== "number" ||
|
|
498
|
-
!Number.isFinite(value))) {
|
|
499
|
-
add([...path, keyword], `JSON Schema ${keyword} must be finite on an integer schema`, "UDL6001");
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
if (typeof schema.minLength === "number" &&
|
|
503
|
-
typeof schema.maxLength === "number" &&
|
|
504
|
-
schema.minLength > schema.maxLength) {
|
|
505
|
-
add([...path, "maxLength"], "JSON Schema maxLength is below minLength", "UDL6001");
|
|
506
|
-
}
|
|
507
|
-
if (typeof schema.minItems === "number" &&
|
|
508
|
-
typeof schema.maxItems === "number" &&
|
|
509
|
-
schema.minItems > schema.maxItems) {
|
|
510
|
-
add([...path, "maxItems"], "JSON Schema maxItems is below minItems", "UDL6001");
|
|
511
|
-
}
|
|
512
|
-
if (typeof schema.minimum === "number" &&
|
|
513
|
-
typeof schema.maximum === "number" &&
|
|
514
|
-
schema.minimum > schema.maximum) {
|
|
515
|
-
add([...path, "maximum"], "JSON Schema maximum is below minimum", "UDL6001");
|
|
516
|
-
}
|
|
517
|
-
if (Object.hasOwn(schema, "x-hyperscale-reference-filter")) {
|
|
518
|
-
const filter = schema["x-hyperscale-reference-filter"];
|
|
519
|
-
if (!isRecord(filter) ||
|
|
520
|
-
schema.type !== "string" ||
|
|
521
|
-
Object.keys(filter).some((key) => key !== "column" && key !== "values") ||
|
|
522
|
-
typeof filter.column !== "string" ||
|
|
523
|
-
!Array.isArray(filter.values) ||
|
|
524
|
-
!filter.values.every((value) => typeof value === "string")) {
|
|
525
|
-
add([...path, "x-hyperscale-reference-filter"], "x-hyperscale-reference-filter requires only string column and string-array values", "UDL6001");
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
if (Object.hasOwn(schema, "x-hyperscale-currency") &&
|
|
529
|
-
(schema.type !== "string" ||
|
|
530
|
-
typeof schema["x-hyperscale-currency"] !== "string" ||
|
|
531
|
-
!/^[A-Z]{3}$/.test(schema["x-hyperscale-currency"]))) {
|
|
532
|
-
add([...path, "x-hyperscale-currency"], "x-hyperscale-currency must be a three-letter uppercase code on a string schema", "UDL6001");
|
|
533
|
-
}
|
|
534
|
-
if (Object.hasOwn(schema, "x-hyperscale-fee-collection-port") &&
|
|
535
|
-
(schema.type !== "string" ||
|
|
536
|
-
schema["x-hyperscale-fee-collection-port"] !== true)) {
|
|
537
|
-
add([...path, "x-hyperscale-fee-collection-port"], "x-hyperscale-fee-collection-port must be true on a string schema", "UDL6001");
|
|
538
|
-
}
|
|
539
|
-
if (Object.hasOwn(schema, "pattern")) {
|
|
540
|
-
if (schema.type !== "string" || typeof schema.pattern !== "string") {
|
|
541
|
-
add([...path, "pattern"], "JSON Schema pattern must be a string", "UDL6001");
|
|
542
|
-
}
|
|
543
|
-
else {
|
|
544
|
-
const problem = regexProblem(schema.pattern);
|
|
545
|
-
if (problem)
|
|
546
|
-
add([...path, "pattern"], problem, "UDL6001");
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if (Object.hasOwn(schema, "format") &&
|
|
550
|
-
(schema.type !== "string" ||
|
|
551
|
-
typeof schema.format !== "string" ||
|
|
552
|
-
!jsonSchemaFormats.has(schema.format))) {
|
|
553
|
-
add([...path, "format"], `JSON Schema format must be one of ${[...jsonSchemaFormats].join(", ")}`, "UDL6001");
|
|
554
|
-
}
|
|
555
|
-
const properties = recordValue(schema.properties);
|
|
556
|
-
for (const [property, child] of Object.entries(properties)) {
|
|
557
|
-
if (isRecord(child)) {
|
|
558
|
-
validateJsonSchema(child, [...path, "properties", property], add);
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
add([...path, "properties", property], "JSON Schema property must be a schema object", "UDL6001");
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
if (isRecord(schema.items)) {
|
|
565
|
-
validateJsonSchema(schema.items, [...path, "items"], add);
|
|
566
|
-
}
|
|
567
|
-
if (isRecord(schema.additionalProperties)) {
|
|
568
|
-
validateJsonSchema(schema.additionalProperties, [...path, "additionalProperties"], add);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Admits a document-authored `pattern` only when its worst-case match cost is
|
|
573
|
-
* bounded, by multiplying out the search space instead of guessing at which
|
|
574
|
-
* shapes are dangerous.
|
|
575
|
-
*
|
|
576
|
-
* The sealed subset leaves exactly three ways to make one anchored match
|
|
577
|
-
* attempt branch: an alternation group (as many ways as it has branches), an
|
|
578
|
-
* optional atom (two), and a variable-width `{n,m}` (span many). Unbounded
|
|
579
|
-
* quantifiers, group repetition, backreferences, lookaround and `.` are
|
|
580
|
-
* refused, so every remaining branch point is a finite factor and their
|
|
581
|
-
* product is an upper bound on the attempts the backtracking engine can be
|
|
582
|
-
* made to explore. Cap that product and matching cost is at most the budget
|
|
583
|
-
* times the pattern length — no ambiguity heuristic to out-think, because
|
|
584
|
-
* catastrophic backtracking IS an unbounded product.
|
|
585
|
-
*/
|
|
586
|
-
function regexProblem(pattern) {
|
|
587
|
-
if (pattern.length > UDL_LIMITS.maxPatternLength) {
|
|
588
|
-
return `JSON Schema pattern exceeds ${UDL_LIMITS.maxPatternLength} characters`;
|
|
589
|
-
}
|
|
590
|
-
if (!pattern.startsWith("^") ||
|
|
591
|
-
!pattern.endsWith("$") ||
|
|
592
|
-
isEscapedRegexToken(pattern, pattern.length - 1)) {
|
|
593
|
-
return "JSON Schema pattern must be explicitly anchored with ^ and $";
|
|
594
|
-
}
|
|
595
|
-
let paths = 1;
|
|
596
|
-
const overBudget = (factor) => {
|
|
597
|
-
paths *= factor;
|
|
598
|
-
return paths > UDL_LIMITS.maxPatternPaths;
|
|
599
|
-
};
|
|
600
|
-
const budgetProblem = `JSON Schema pattern may branch more than ${UDL_LIMITS.maxPatternPaths} ways`;
|
|
601
|
-
const branches = [];
|
|
602
|
-
let inClass = false;
|
|
603
|
-
let escaped = false;
|
|
604
|
-
let previous;
|
|
605
|
-
for (let index = 0; index < pattern.length; index += 1) {
|
|
606
|
-
const token = pattern[index];
|
|
607
|
-
if (escaped) {
|
|
608
|
-
if (/[1-9k]/.test(token)) {
|
|
609
|
-
return "JSON Schema pattern may not contain backreferences";
|
|
610
|
-
}
|
|
611
|
-
escaped = false;
|
|
612
|
-
previous = "atom";
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
if (token === "\\") {
|
|
616
|
-
escaped = true;
|
|
617
|
-
continue;
|
|
618
|
-
}
|
|
619
|
-
if (inClass) {
|
|
620
|
-
if (token === "]") {
|
|
621
|
-
inClass = false;
|
|
622
|
-
previous = "atom";
|
|
623
|
-
}
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
if (token === "[") {
|
|
627
|
-
inClass = true;
|
|
628
|
-
continue;
|
|
629
|
-
}
|
|
630
|
-
if (token === ".") {
|
|
631
|
-
return "JSON Schema pattern may not contain the unbounded wildcard .";
|
|
632
|
-
}
|
|
633
|
-
if (token === "(") {
|
|
634
|
-
if (pattern[index + 1] === "?" && pattern[index + 2] !== ":") {
|
|
635
|
-
return "JSON Schema pattern may not contain lookaround or inline flags";
|
|
636
|
-
}
|
|
637
|
-
branches.push(1);
|
|
638
|
-
previous = undefined;
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
if (token === ")") {
|
|
642
|
-
const groupBranches = branches.pop();
|
|
643
|
-
if (groupBranches === undefined) {
|
|
644
|
-
return "JSON Schema pattern contains an unmatched closing group";
|
|
645
|
-
}
|
|
646
|
-
if (overBudget(groupBranches))
|
|
647
|
-
return budgetProblem;
|
|
648
|
-
previous = "group";
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
if (token === "|") {
|
|
652
|
-
const enclosing = branches.length - 1;
|
|
653
|
-
if (enclosing < 0) {
|
|
654
|
-
return "JSON Schema pattern alternation must be enclosed in a group";
|
|
655
|
-
}
|
|
656
|
-
branches[enclosing] = branches[enclosing] + 1;
|
|
657
|
-
previous = undefined;
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
if (token === "*" || token === "+") {
|
|
661
|
-
return "JSON Schema pattern may not contain unbounded quantifiers";
|
|
662
|
-
}
|
|
663
|
-
if (token === "?") {
|
|
664
|
-
if (pattern[index - 1] === "(")
|
|
665
|
-
continue;
|
|
666
|
-
if (!previous || previous === "quantifier") {
|
|
667
|
-
return "JSON Schema pattern contains an ambiguous quantifier";
|
|
668
|
-
}
|
|
669
|
-
if (overBudget(2))
|
|
670
|
-
return budgetProblem;
|
|
671
|
-
previous = "quantifier";
|
|
672
|
-
continue;
|
|
673
|
-
}
|
|
674
|
-
if (token === "{") {
|
|
675
|
-
const end = pattern.indexOf("}", index + 1);
|
|
676
|
-
const bounds = end < 0 ? "" : pattern.slice(index + 1, end);
|
|
677
|
-
const match = /^(\d+)(?:,(\d+))?$/.exec(bounds);
|
|
678
|
-
if (!match) {
|
|
679
|
-
return "JSON Schema pattern quantifiers must have a finite upper bound";
|
|
680
|
-
}
|
|
681
|
-
if (previous === "group") {
|
|
682
|
-
return "JSON Schema pattern may not repeat a group";
|
|
683
|
-
}
|
|
684
|
-
if (!previous || previous === "quantifier") {
|
|
685
|
-
return "JSON Schema pattern contains an ambiguous quantifier";
|
|
686
|
-
}
|
|
687
|
-
const lower = Number(match[1]);
|
|
688
|
-
const upper = Number(match[2] ?? match[1]);
|
|
689
|
-
if (upper < lower || upper > UDL_LIMITS.maxStringLength) {
|
|
690
|
-
return `JSON Schema pattern quantifier upper bound must not exceed ${UDL_LIMITS.maxStringLength}`;
|
|
691
|
-
}
|
|
692
|
-
if (overBudget(upper - lower + 1))
|
|
693
|
-
return budgetProblem;
|
|
694
|
-
previous = "quantifier";
|
|
695
|
-
index = end;
|
|
696
|
-
continue;
|
|
697
|
-
}
|
|
698
|
-
if (token !== "^" && token !== "$" && token !== ":")
|
|
699
|
-
previous = "atom";
|
|
700
|
-
}
|
|
701
|
-
if (escaped || inClass || branches.length !== 0) {
|
|
702
|
-
return "JSON Schema pattern is not syntactically closed";
|
|
703
|
-
}
|
|
704
|
-
try {
|
|
705
|
-
new RegExp(pattern, "u");
|
|
706
|
-
}
|
|
707
|
-
catch {
|
|
708
|
-
return "JSON Schema pattern is not valid ECMAScript syntax";
|
|
709
|
-
}
|
|
710
|
-
return undefined;
|
|
711
|
-
}
|
|
712
|
-
function isEscapedRegexToken(pattern, index) {
|
|
713
|
-
let slashes = 0;
|
|
714
|
-
for (let cursor = index - 1; cursor >= 0 && pattern[cursor] === "\\"; cursor -= 1) {
|
|
715
|
-
slashes += 1;
|
|
716
|
-
}
|
|
717
|
-
return slashes % 2 === 1;
|
|
718
|
-
}
|
|
719
|
-
function validateInstrument(instrument, instrumentIndex, instruments, subjects, references, add) {
|
|
720
|
-
const base = ["instruments", instrumentIndex];
|
|
721
|
-
const states = new Set(instrument.lifecycle.states);
|
|
722
|
-
const orderedActions = new Set(instrument.actionOrder);
|
|
723
|
-
addDuplicateIssues(instrument.actionOrder, [...base, "actionOrder"], "action id", add);
|
|
724
|
-
instrument.actionOrder.forEach((action, actionIndex) => {
|
|
725
|
-
if (!Object.hasOwn(instrument.actions, action)) {
|
|
726
|
-
add([...base, "actionOrder", actionIndex], `action order references unknown action ${action}`, "UDL3001");
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
for (const action of Object.keys(instrument.actions)) {
|
|
730
|
-
if (!orderedActions.has(action)) {
|
|
731
|
-
add([...base, "actions", action], `action ${action} is missing from actionOrder`, "UDL3001");
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
addDuplicateIssues(instrument.lifecycle.states, [...base, "lifecycle", "states"], "lifecycle state", add);
|
|
735
|
-
if (!states.has(instrument.lifecycle.initial)) {
|
|
736
|
-
add([...base, "lifecycle", "initial"], `initial state ${instrument.lifecycle.initial} is not declared in lifecycle.states`, "UDL3001");
|
|
737
|
-
}
|
|
738
|
-
if (!Object.hasOwn(instrument.actions, "create")) {
|
|
739
|
-
add([...base, "actions"], "every instrument must declare the create action", "UDL3001");
|
|
740
|
-
}
|
|
741
|
-
if (Object.hasOwn(instrument.lifecycle.transitions, "create")) {
|
|
742
|
-
add([...base, "lifecycle", "transitions", "create"], "create lands on lifecycle.initial and must not declare a transition", "UDL3001");
|
|
743
|
-
}
|
|
744
|
-
for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
|
|
745
|
-
if (!Object.hasOwn(instrument.actions, action)) {
|
|
746
|
-
add([...base, "lifecycle", "transitions", action], `transition ${action} has no matching action`, "UDL3001");
|
|
747
|
-
}
|
|
748
|
-
addDuplicateIssues(transition.from, [...base, "lifecycle", "transitions", action, "from"], "source state", add);
|
|
749
|
-
transition.from.forEach((from, fromIndex) => {
|
|
750
|
-
if (!states.has(from)) {
|
|
751
|
-
add([...base, "lifecycle", "transitions", action, "from", fromIndex], `source state ${from} is not declared in lifecycle.states`, "UDL3001");
|
|
752
|
-
}
|
|
753
|
-
});
|
|
754
|
-
if (!states.has(transition.to)) {
|
|
755
|
-
add([...base, "lifecycle", "transitions", action, "to"], `target state ${transition.to} is not declared in lifecycle.states`, "UDL3001");
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
for (const action of Object.keys(instrument.actions)) {
|
|
759
|
-
if (action !== "create" &&
|
|
760
|
-
!Object.hasOwn(instrument.lifecycle.transitions, action)) {
|
|
761
|
-
add([...base, "actions", action], `action ${action} must declare a lifecycle transition`, "UDL3001");
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
validateReachability(instrument, base, add);
|
|
765
|
-
const reservedFields = new Set([
|
|
766
|
-
"createdAt",
|
|
767
|
-
"id",
|
|
768
|
-
"metadata",
|
|
769
|
-
"refs",
|
|
770
|
-
"status",
|
|
771
|
-
]);
|
|
772
|
-
for (const field of Object.keys(instrument.fields)) {
|
|
773
|
-
if (reservedFields.has(field)) {
|
|
774
|
-
add([...base, "fields", field], `${field} is an envelope field and cannot be authored`, "UDL2002");
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
addDuplicateIssues(instrument.required, [...base, "required"], "required field", add);
|
|
778
|
-
instrument.required.forEach((field, fieldIndex) => {
|
|
779
|
-
if (!Object.hasOwn(instrument.fields, field)) {
|
|
780
|
-
add([...base, "required", fieldIndex], `required references unknown field ${field}`, "UDL2002");
|
|
781
|
-
}
|
|
782
|
-
});
|
|
783
|
-
if (instrument.subject) {
|
|
784
|
-
addDuplicateIssues(instrument.subject.kinds, [...base, "subject", "kinds"], "subject kind", add);
|
|
785
|
-
instrument.subject.kinds.forEach((kind, kindIndex) => {
|
|
786
|
-
if (!subjects.has(kind)) {
|
|
787
|
-
add([...base, "subject", "kinds", kindIndex], `instrument subject references unknown kind ${kind}`, "UDL2002");
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
validateParties(instrument, base, references, add);
|
|
792
|
-
validateUpdate(instrument, base, states, add);
|
|
793
|
-
validateDials(instrument, base, add);
|
|
794
|
-
validateCallerParkedStates(instrument, base, states, add);
|
|
795
|
-
validateFeeRules(instrument, base, references, add);
|
|
796
|
-
validateSetsAt(instrument, base, add);
|
|
797
|
-
validateActions(instrument, base, instruments, references, add);
|
|
798
|
-
validateQuoteCommit(instrument, base, references, add);
|
|
799
|
-
validatePiecePlan(instrument, base, references, add);
|
|
800
|
-
const planResolution = resolveUdlActionPlans(instrument);
|
|
801
|
-
for (const planIssue of planResolution.issues) {
|
|
802
|
-
const rawPath = planIssue.path.startsWith("$.")
|
|
803
|
-
? planIssue.path.slice(2).split(".")
|
|
804
|
-
: [planIssue.path];
|
|
805
|
-
add([...base, ...rawPath], planIssue.message, planIssue.code);
|
|
806
|
-
}
|
|
807
|
-
// Validate leaf steps
|
|
808
|
-
const validatedLeaves = new Set();
|
|
809
|
-
for (const plan of planResolution.plans) {
|
|
810
|
-
const actionDef = instrument.actions[plan.action];
|
|
811
|
-
if (!actionDef)
|
|
812
|
-
continue;
|
|
813
|
-
for (const leaf of plan.leaves) {
|
|
814
|
-
const leafKey = `${plan.pieceId ?? ""}:${leaf.originPath.join(".")}`;
|
|
815
|
-
if (!validatedLeaves.has(leafKey)) {
|
|
816
|
-
validatedLeaves.add(leafKey);
|
|
817
|
-
validateStep(instrument, actionDef, leaf.step, [...base, "actions", ...leaf.originPath], add);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
for (const finIssue of instrumentFinanceIssues(instrument, {
|
|
822
|
-
plans: planResolution.plans,
|
|
823
|
-
})) {
|
|
824
|
-
add([...base, ...finIssue.path], finIssue.message, finIssue.code);
|
|
825
|
-
}
|
|
826
|
-
validateAggregates(instrument, base, instruments, references, add);
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* The finance oracle over an instrument's resolved action plans, with paths
|
|
830
|
-
* rooted at the instrument. A piece-plan instrument is unfolded over the
|
|
831
|
-
* runtime's piece progress; any other instrument runs once per action plan
|
|
832
|
-
* combination. Contract-side callers pass the UDL projection of a blueprint
|
|
833
|
-
* definition so both boundaries prove the same machine.
|
|
834
|
-
*/
|
|
835
|
-
export function instrumentFinanceIssues(instrument, options = {}) {
|
|
836
|
-
const plans = options.plans ?? resolveUdlActionPlans(instrument).plans;
|
|
837
|
-
const financeOptions = options.penaltyMayBeNonzero === undefined
|
|
838
|
-
? {}
|
|
839
|
-
: { penaltyMayBeNonzero: options.penaltyMayBeNonzero };
|
|
840
|
-
const issues = [];
|
|
841
|
-
const seen = new Set();
|
|
842
|
-
const add = (path, message, code) => {
|
|
843
|
-
const key = `${code}:${path.join(".")}:${message}`;
|
|
844
|
-
if (seen.has(key))
|
|
845
|
-
return;
|
|
846
|
-
seen.add(key);
|
|
847
|
-
issues.push({ code, message, path });
|
|
848
|
-
};
|
|
849
|
-
const expansion = instrument.piecePlan
|
|
850
|
-
? expandPieceProgress(instrument, plans)
|
|
851
|
-
: undefined;
|
|
852
|
-
if (expansion === "bound") {
|
|
853
|
-
add(["actions"], `piece progress expansion exceeds reachable variant bound of ${UDL_LIMITS.maxActionExpansion}`, "UDL2010");
|
|
854
|
-
return issues;
|
|
855
|
-
}
|
|
856
|
-
if (expansion) {
|
|
857
|
-
for (const finIssue of analyzeInstrumentFinance(expansion.instrument, financeOptions)) {
|
|
858
|
-
const message = expansion.displayNames.reduce((text, [expanded, display]) => text.replaceAll(expanded, display), finIssue.message);
|
|
859
|
-
add(originFinancePath(expansion, finIssue.path), message, finIssue.code);
|
|
860
|
-
}
|
|
861
|
-
return issues;
|
|
862
|
-
}
|
|
863
|
-
const actionsWithPlans = Object.keys(instrument.actions).filter((aName) => plans.some((p) => p.action === aName));
|
|
864
|
-
let totalCombinations = 1;
|
|
865
|
-
const actionPlanMap = {};
|
|
866
|
-
for (const aName of actionsWithPlans) {
|
|
867
|
-
const actionPlans = plans.filter((p) => p.action === aName);
|
|
868
|
-
actionPlanMap[aName] = actionPlans;
|
|
869
|
-
totalCombinations *= actionPlans.length;
|
|
870
|
-
}
|
|
871
|
-
if (actionsWithPlans.length > 0 &&
|
|
872
|
-
totalCombinations > UDL_LIMITS.maxActionExpansion) {
|
|
873
|
-
add(["actions"], `variant expansion exceeds combination bound of ${UDL_LIMITS.maxActionExpansion} (${totalCombinations} combinations)`, "UDL2010");
|
|
874
|
-
return issues;
|
|
875
|
-
}
|
|
876
|
-
const generateCombos = (keys) => {
|
|
877
|
-
if (keys.length === 0)
|
|
878
|
-
return [{}];
|
|
879
|
-
const [first, ...rest] = keys;
|
|
880
|
-
const restCombos = generateCombos(rest);
|
|
881
|
-
const result = [];
|
|
882
|
-
for (const plan of actionPlanMap[first]) {
|
|
883
|
-
for (const combo of restCombos) {
|
|
884
|
-
result.push({ ...combo, [first]: plan });
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return result;
|
|
888
|
-
};
|
|
889
|
-
const combinations = actionsWithPlans.length > 0 ? generateCombos(actionsWithPlans) : [{}];
|
|
890
|
-
for (const combo of combinations) {
|
|
891
|
-
const expandedActions = {};
|
|
892
|
-
for (const [aName, aDef] of Object.entries(instrument.actions)) {
|
|
893
|
-
expandedActions[aName] = planExpandedAction(aDef, combo[aName]);
|
|
894
|
-
}
|
|
895
|
-
for (const finIssue of analyzeInstrumentFinance({ ...instrument, actions: expandedActions }, financeOptions)) {
|
|
896
|
-
add(finIssue.path, finIssue.message, finIssue.code);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
return issues;
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* Only an action that moves money through calls is replaced by its expanded
|
|
903
|
-
* leaves. Authored moves and steps always reach the oracle.
|
|
904
|
-
*/
|
|
905
|
-
function planExpandedAction(definition, plan) {
|
|
906
|
-
if (!plan || (definition.calls?.length ?? 0) === 0)
|
|
907
|
-
return definition;
|
|
908
|
-
const steps = [];
|
|
909
|
-
const moves = [];
|
|
910
|
-
for (const leaf of plan.leaves) {
|
|
911
|
-
if ("key" in leaf.step) {
|
|
912
|
-
moves.push(leaf.step);
|
|
913
|
-
}
|
|
914
|
-
else {
|
|
915
|
-
steps.push(leaf.step);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return { ...definition, moves, steps };
|
|
919
|
-
}
|
|
920
|
-
function progressKey(progress) {
|
|
921
|
-
return `${progress.funded.join(",")}|${progress.consumed.join(",")}`;
|
|
922
|
-
}
|
|
923
|
-
/** Mirrors eligiblePieces in the engine's piece-plan dispatcher. */
|
|
924
|
-
function eligiblePieces(plan, stage, progress) {
|
|
925
|
-
return plan[`${stage}_order`].filter((id) => stage === "fund"
|
|
926
|
-
? !progress.funded.includes(id)
|
|
927
|
-
: progress.funded.includes(id) && !progress.consumed.includes(id));
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Unfolds a piece-plan instrument over the runtime's piece progress so the
|
|
931
|
-
* ordinary lifecycle oracle sees exactly the states the dispatcher admits: a
|
|
932
|
-
* piece-stage action moves the next eligible piece of its stage order, the
|
|
933
|
-
* lifecycle state is retained until the stage's last piece moves, funding
|
|
934
|
-
* cannot resume once a piece has left escrow, and an action gated on a drained
|
|
935
|
-
* account is closed while a funded piece is still held. Every expanded state is
|
|
936
|
-
* one (lifecycle state, progress) pair; expanded action names carry the piece
|
|
937
|
-
* and the source state so each has exactly one transition. A quote-commit pair
|
|
938
|
-
* is not carried through the expansion.
|
|
939
|
-
*/
|
|
940
|
-
function expandPieceProgress(instrument, plans) {
|
|
941
|
-
const plan = instrument.piecePlan;
|
|
942
|
-
if (!plan)
|
|
943
|
-
return undefined;
|
|
944
|
-
const stateName = (state, progress) => `${state}#${progressKey(progress)}`;
|
|
945
|
-
const initialProgress = { funded: [], consumed: [] };
|
|
946
|
-
const stateOrigins = new Map();
|
|
947
|
-
const actionOrigins = new Map();
|
|
948
|
-
const displayNames = [];
|
|
949
|
-
const actions = {};
|
|
950
|
-
const transitions = {};
|
|
951
|
-
const pending = [
|
|
952
|
-
{ state: instrument.lifecycle.initial, progress: initialProgress },
|
|
953
|
-
];
|
|
954
|
-
stateOrigins.set(stateName(instrument.lifecycle.initial, initialProgress), instrument.lifecycle.initial);
|
|
955
|
-
const visit = (state, progress) => {
|
|
956
|
-
const name = stateName(state, progress);
|
|
957
|
-
if (stateOrigins.has(name))
|
|
958
|
-
return;
|
|
959
|
-
stateOrigins.set(name, state);
|
|
960
|
-
pending.push({ state, progress });
|
|
961
|
-
};
|
|
962
|
-
while (pending.length > 0) {
|
|
963
|
-
const { state, progress } = pending.shift();
|
|
964
|
-
if (stateOrigins.size > UDL_LIMITS.maxActionExpansion)
|
|
965
|
-
return "bound";
|
|
966
|
-
const held = progress.funded.filter((id) => !progress.consumed.includes(id));
|
|
967
|
-
for (const [actionName, transition] of Object.entries(instrument.lifecycle.transitions)) {
|
|
968
|
-
if (!transition.from.includes(state))
|
|
969
|
-
continue;
|
|
970
|
-
const definition = instrument.actions[actionName];
|
|
971
|
-
if (!definition)
|
|
972
|
-
continue;
|
|
973
|
-
if (definition.requiresDrainedAccount && held.length > 0)
|
|
974
|
-
continue;
|
|
975
|
-
const stage = definition.pieceStage;
|
|
976
|
-
if (!stage) {
|
|
977
|
-
const expanded = `${actionName}#${state}#${progressKey(progress)}`;
|
|
978
|
-
const variant = plans.find((candidate) => candidate.action === actionName);
|
|
979
|
-
actions[expanded] = planExpandedAction(definition, variant);
|
|
980
|
-
transitions[expanded] = {
|
|
981
|
-
from: [stateName(state, progress)],
|
|
982
|
-
to: stateName(transition.to, progress),
|
|
983
|
-
};
|
|
984
|
-
actionOrigins.set(expanded, {
|
|
985
|
-
action: actionName,
|
|
986
|
-
...(variant && (definition.calls?.length ?? 0) > 0
|
|
987
|
-
? { leafOrigins: variant.leaves.map((leaf) => leaf.originPath) }
|
|
988
|
-
: {}),
|
|
989
|
-
});
|
|
990
|
-
displayNames.push([expanded, actionName]);
|
|
991
|
-
visit(transition.to, progress);
|
|
992
|
-
continue;
|
|
993
|
-
}
|
|
994
|
-
if (stage.plan !== plan.id)
|
|
995
|
-
continue;
|
|
996
|
-
if (stage.stage === "fund" && progress.consumed.length > 0)
|
|
997
|
-
continue;
|
|
998
|
-
const eligible = eligiblePieces(plan, stage.stage, progress);
|
|
999
|
-
const pieceId = eligible[0];
|
|
1000
|
-
if (pieceId === undefined)
|
|
1001
|
-
continue;
|
|
1002
|
-
const variant = plans.find((candidate) => candidate.action === actionName && candidate.pieceId === pieceId);
|
|
1003
|
-
if (!variant)
|
|
1004
|
-
continue;
|
|
1005
|
-
const next = stage.stage === "fund"
|
|
1006
|
-
? {
|
|
1007
|
-
funded: [...progress.funded, pieceId],
|
|
1008
|
-
consumed: progress.consumed,
|
|
1009
|
-
}
|
|
1010
|
-
: {
|
|
1011
|
-
funded: progress.funded,
|
|
1012
|
-
consumed: [...progress.consumed, pieceId],
|
|
1013
|
-
};
|
|
1014
|
-
const stageComplete = eligiblePieces(plan, stage.stage, next).length === 0;
|
|
1015
|
-
const target = stageComplete ? transition.to : state;
|
|
1016
|
-
const expanded = `${actionName}@${pieceId}#${state}#${progressKey(progress)}`;
|
|
1017
|
-
actions[expanded] = planExpandedAction(definition, variant);
|
|
1018
|
-
transitions[expanded] = {
|
|
1019
|
-
from: [stateName(state, progress)],
|
|
1020
|
-
to: stateName(target, next),
|
|
1021
|
-
};
|
|
1022
|
-
actionOrigins.set(expanded, {
|
|
1023
|
-
action: actionName,
|
|
1024
|
-
leafOrigins: variant.leaves.map((leaf) => leaf.originPath),
|
|
1025
|
-
});
|
|
1026
|
-
displayNames.push([expanded, `${actionName}[${pieceId}]`]);
|
|
1027
|
-
visit(target, next);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
for (const [actionName, definition] of Object.entries(instrument.actions)) {
|
|
1031
|
-
if (Object.hasOwn(instrument.lifecycle.transitions, actionName))
|
|
1032
|
-
continue;
|
|
1033
|
-
actions[actionName] = planExpandedAction(definition, plans.find((candidate) => candidate.action === actionName));
|
|
1034
|
-
actionOrigins.set(actionName, { action: actionName });
|
|
1035
|
-
}
|
|
1036
|
-
return {
|
|
1037
|
-
instrument: {
|
|
1038
|
-
...instrument,
|
|
1039
|
-
actions,
|
|
1040
|
-
lifecycle: {
|
|
1041
|
-
initial: stateName(instrument.lifecycle.initial, initialProgress),
|
|
1042
|
-
states: [...stateOrigins.keys()],
|
|
1043
|
-
transitions,
|
|
1044
|
-
},
|
|
1045
|
-
},
|
|
1046
|
-
displayNames: [
|
|
1047
|
-
...displayNames,
|
|
1048
|
-
...[...stateOrigins.entries()].map(([expanded, origin]) => [expanded, origin]),
|
|
1049
|
-
].sort((left, right) => right[0].length - left[0].length),
|
|
1050
|
-
actionOrigins,
|
|
1051
|
-
stateOrigins,
|
|
1052
|
-
originStates: instrument.lifecycle.states,
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
function originFinancePath(expansion, path) {
|
|
1056
|
-
const [head, second, third, fourth, ...rest] = path;
|
|
1057
|
-
if (head === "lifecycle" &&
|
|
1058
|
-
second === "states" &&
|
|
1059
|
-
typeof third === "number") {
|
|
1060
|
-
const expandedState = expansion.instrument.lifecycle.states[third];
|
|
1061
|
-
const origin = expandedState === undefined
|
|
1062
|
-
? undefined
|
|
1063
|
-
: expansion.stateOrigins.get(expandedState);
|
|
1064
|
-
const index = origin === undefined ? -1 : expansion.originStates.indexOf(origin);
|
|
1065
|
-
return index >= 0
|
|
1066
|
-
? ["lifecycle", "states", index]
|
|
1067
|
-
: ["lifecycle", "states"];
|
|
1068
|
-
}
|
|
1069
|
-
if (head === "actions" && typeof second === "string") {
|
|
1070
|
-
const origin = expansion.actionOrigins.get(second);
|
|
1071
|
-
if (!origin)
|
|
1072
|
-
return path;
|
|
1073
|
-
if (third === "moves" &&
|
|
1074
|
-
typeof fourth === "number" &&
|
|
1075
|
-
origin.leafOrigins?.[fourth]) {
|
|
1076
|
-
return ["actions", ...origin.leafOrigins[fourth], ...rest];
|
|
1077
|
-
}
|
|
1078
|
-
return [
|
|
1079
|
-
"actions",
|
|
1080
|
-
origin.action,
|
|
1081
|
-
...(third === undefined ? [] : [third]),
|
|
1082
|
-
...(fourth === undefined ? [] : [fourth]),
|
|
1083
|
-
...rest,
|
|
1084
|
-
];
|
|
1085
|
-
}
|
|
1086
|
-
return path;
|
|
1087
|
-
}
|
|
1088
|
-
function validatePiecePlan(instrument, base, references, add) {
|
|
1089
|
-
const plan = instrument.piecePlan;
|
|
1090
|
-
if (!plan)
|
|
1091
|
-
return;
|
|
1092
|
-
const planBase = [...base, "piecePlan"];
|
|
1093
|
-
const mutableFields = new Set(instrument.update?.fields ?? []);
|
|
1094
|
-
const allUpdatedFields = new Set(Object.values(instrument.actions).flatMap((a) => a.updates ?? []));
|
|
1095
|
-
// Validate total field
|
|
1096
|
-
const totalSchema = instrument.fields[plan.total];
|
|
1097
|
-
if (!totalSchema ||
|
|
1098
|
-
totalSchema.type !== "string" ||
|
|
1099
|
-
!isMoneySchema(totalSchema)) {
|
|
1100
|
-
add([...planBase, "total"], `piece plan total ${plan.total} must be a declared money field`, "UDL4002");
|
|
1101
|
-
}
|
|
1102
|
-
if (!instrument.required.includes(plan.total)) {
|
|
1103
|
-
add([...planBase, "total"], `piece plan total ${plan.total} must be required`, "UDL4002");
|
|
1104
|
-
}
|
|
1105
|
-
if (mutableFields.has(plan.total) || allUpdatedFields.has(plan.total)) {
|
|
1106
|
-
add([...planBase, "total"], `piece plan total ${plan.total} cannot be updated`, "UDL4002");
|
|
1107
|
-
}
|
|
1108
|
-
const pieceIds = new Set();
|
|
1109
|
-
const pieceAmountFields = [];
|
|
1110
|
-
plan.pieces.forEach((piece, index) => {
|
|
1111
|
-
const pieceBase = [...planBase, "pieces", index];
|
|
1112
|
-
if (pieceIds.has(piece.id)) {
|
|
1113
|
-
add([...pieceBase, "id"], `duplicate piece id ${piece.id} in piece plan`, "UDL4002");
|
|
1114
|
-
}
|
|
1115
|
-
pieceIds.add(piece.id);
|
|
1116
|
-
pieceAmountFields.push(piece.amount);
|
|
1117
|
-
const amountSchema = instrument.fields[piece.amount];
|
|
1118
|
-
if (!amountSchema ||
|
|
1119
|
-
amountSchema.type !== "string" ||
|
|
1120
|
-
!isMoneySchema(amountSchema)) {
|
|
1121
|
-
add([...pieceBase, "amount"], `piece amount ${piece.amount} must be a declared money field`, "UDL4002");
|
|
1122
|
-
}
|
|
1123
|
-
if (!instrument.required.includes(piece.amount)) {
|
|
1124
|
-
add([...pieceBase, "amount"], `piece amount ${piece.amount} must be required`, "UDL4002");
|
|
1125
|
-
}
|
|
1126
|
-
if (mutableFields.has(piece.amount) || allUpdatedFields.has(piece.amount)) {
|
|
1127
|
-
add([...pieceBase, "amount"], `piece amount ${piece.amount} cannot be updated`, "UDL4002");
|
|
1128
|
-
}
|
|
1129
|
-
const releaseSchema = instrument.fields[piece.release_to];
|
|
1130
|
-
if (!releaseSchema || !references.accepts(releaseSchema, "acct")) {
|
|
1131
|
-
add([...pieceBase, "release_to"], `piece release_to ${piece.release_to} must be a declared account field`, "UDL4002");
|
|
1132
|
-
}
|
|
1133
|
-
if (!instrument.required.includes(piece.release_to)) {
|
|
1134
|
-
add([...pieceBase, "release_to"], `piece release_to ${piece.release_to} must be required`, "UDL4002");
|
|
1135
|
-
}
|
|
1136
|
-
if (mutableFields.has(piece.release_to) ||
|
|
1137
|
-
allUpdatedFields.has(piece.release_to)) {
|
|
1138
|
-
add([...pieceBase, "release_to"], `piece release_to ${piece.release_to} cannot be updated`, "UDL4002");
|
|
1139
|
-
}
|
|
1140
|
-
const refundSchema = instrument.fields[piece.refund_to];
|
|
1141
|
-
if (!refundSchema || !references.accepts(refundSchema, "acct")) {
|
|
1142
|
-
add([...pieceBase, "refund_to"], `piece refund_to ${piece.refund_to} must be a declared account field`, "UDL4002");
|
|
1143
|
-
}
|
|
1144
|
-
if (!instrument.required.includes(piece.refund_to)) {
|
|
1145
|
-
add([...pieceBase, "refund_to"], `piece refund_to ${piece.refund_to} must be required`, "UDL4002");
|
|
1146
|
-
}
|
|
1147
|
-
if (mutableFields.has(piece.refund_to) ||
|
|
1148
|
-
allUpdatedFields.has(piece.refund_to)) {
|
|
1149
|
-
add([...pieceBase, "refund_to"], `piece refund_to ${piece.refund_to} cannot be updated`, "UDL4002");
|
|
1150
|
-
}
|
|
1151
|
-
});
|
|
1152
|
-
// Check currency agreement
|
|
1153
|
-
const totalCurrency = totalSchema && typeof totalSchema["x-hyperscale-currency"] === "string"
|
|
1154
|
-
? totalSchema["x-hyperscale-currency"]
|
|
1155
|
-
: undefined;
|
|
1156
|
-
const pieceCurrencies = plan.pieces.map((p) => {
|
|
1157
|
-
const s = instrument.fields[p.amount];
|
|
1158
|
-
return s && typeof s["x-hyperscale-currency"] === "string"
|
|
1159
|
-
? s["x-hyperscale-currency"]
|
|
1160
|
-
: undefined;
|
|
1161
|
-
});
|
|
1162
|
-
const allCurrencies = [totalCurrency, ...pieceCurrencies];
|
|
1163
|
-
const definedCurrencies = allCurrencies.filter((c) => c !== undefined);
|
|
1164
|
-
if (definedCurrencies.length > 0) {
|
|
1165
|
-
const firstCurrency = definedCurrencies[0];
|
|
1166
|
-
if (definedCurrencies.length !== allCurrencies.length ||
|
|
1167
|
-
definedCurrencies.some((c) => c !== firstCurrency)) {
|
|
1168
|
-
add(planBase, `piece plan money fields must agree on one declared currency`, "UDL4002");
|
|
1169
|
-
}
|
|
1170
|
-
else if (!/^[A-Z]{3}$/.test(firstCurrency)) {
|
|
1171
|
-
add(planBase, `piece plan currency ${firstCurrency} must be a concrete ISO currency`, "UDL4002");
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
else {
|
|
1175
|
-
const concreteCurrency = (() => {
|
|
1176
|
-
const currencyFields = Object.entries(instrument.fields).filter(([, schema]) => isCurrencySchema(schema));
|
|
1177
|
-
if (currencyFields.length === 1) {
|
|
1178
|
-
const [fName, fSchema] = currencyFields[0];
|
|
1179
|
-
if (mutableFields.has(fName) || allUpdatedFields.has(fName)) {
|
|
1180
|
-
return undefined;
|
|
1181
|
-
}
|
|
1182
|
-
if (typeof fSchema.const === "string" &&
|
|
1183
|
-
/^[A-Z]{3}$/.test(fSchema.const)) {
|
|
1184
|
-
return fSchema.const;
|
|
1185
|
-
}
|
|
1186
|
-
if (Array.isArray(fSchema.enum) &&
|
|
1187
|
-
fSchema.enum.length === 1 &&
|
|
1188
|
-
typeof fSchema.enum[0] === "string" &&
|
|
1189
|
-
/^[A-Z]{3}$/.test(fSchema.enum[0])) {
|
|
1190
|
-
return fSchema.enum[0];
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
return undefined;
|
|
1194
|
-
})();
|
|
1195
|
-
if (!concreteCurrency) {
|
|
1196
|
-
add(planBase, `piece plan requires an actual single concrete currency via x-hyperscale-currency tags or an immutable concrete instrument currency declaration`, "UDL4002");
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
// Disallow updates of the instance currency field
|
|
1200
|
-
const instanceCurrencyFields = Object.entries(instrument.fields)
|
|
1201
|
-
.filter(([, schema]) => isCurrencySchema(schema))
|
|
1202
|
-
.map(([field]) => field);
|
|
1203
|
-
for (const cField of instanceCurrencyFields) {
|
|
1204
|
-
if (mutableFields.has(cField) || allUpdatedFields.has(cField)) {
|
|
1205
|
-
add([...planBase, "currency"], `piece plan instance currency field ${cField} cannot be updated`, "UDL4002");
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
// Check fixed amounts against total
|
|
1209
|
-
const totalVal = getFixedMoneyValue(totalSchema);
|
|
1210
|
-
const pieceVals = plan.pieces.map((p) => getFixedMoneyValue(instrument.fields[p.amount]));
|
|
1211
|
-
if (totalVal !== undefined &&
|
|
1212
|
-
pieceVals.every((v) => v !== undefined)) {
|
|
1213
|
-
const sum = pieceVals.reduce((acc, v) => acc + v, 0n);
|
|
1214
|
-
if (sum !== totalVal) {
|
|
1215
|
-
add([...planBase, "total"], `sum of fixed piece amounts (${sum}) does not equal plan total (${totalVal})`, "UDL4002");
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// Check partition
|
|
1219
|
-
const isSingletonTotal = plan.pieces.length === 1 && plan.pieces[0].amount === plan.total;
|
|
1220
|
-
if (new Set(pieceAmountFields).size !== pieceAmountFields.length) {
|
|
1221
|
-
add([...planBase, "pieces"], `piece amount fields must be distinct: [${pieceAmountFields.join(", ")}]`, "UDL4002");
|
|
1222
|
-
}
|
|
1223
|
-
else if (!isSingletonTotal) {
|
|
1224
|
-
const planAmounts = [...pieceAmountFields].sort();
|
|
1225
|
-
const matchingPartition = (instrument.partitions ?? []).find((p) => {
|
|
1226
|
-
if (p.totalField !== plan.total)
|
|
1227
|
-
return false;
|
|
1228
|
-
const partAmounts = [...p.pieceFields].sort();
|
|
1229
|
-
return (partAmounts.length === planAmounts.length &&
|
|
1230
|
-
partAmounts.every((f, i) => f === planAmounts[i]));
|
|
1231
|
-
});
|
|
1232
|
-
if (!matchingPartition) {
|
|
1233
|
-
add(planBase, `piece plan must match a declared partition with totalField ${plan.total} and pieceFields [${pieceAmountFields.join(", ")}]`, "UDL4002");
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
// Check orders (UDL5013)
|
|
1237
|
-
const validateOrder = (order, orderName, exactSet) => {
|
|
1238
|
-
const orderPath = [...planBase, orderName];
|
|
1239
|
-
if (new Set(order).size !== order.length) {
|
|
1240
|
-
add(orderPath, `${orderName} contains duplicate piece ids`, "UDL5013");
|
|
1241
|
-
}
|
|
1242
|
-
for (const id of order) {
|
|
1243
|
-
if (!pieceIds.has(id)) {
|
|
1244
|
-
add(orderPath, `${orderName} contains undeclared piece id ${id}`, "UDL5013");
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
if (exactSet) {
|
|
1248
|
-
if (order.length !== pieceIds.size) {
|
|
1249
|
-
add(orderPath, `${orderName} must cover every declared piece id`, "UDL5013");
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
};
|
|
1253
|
-
validateOrder(plan.fund_order, "fund_order", true);
|
|
1254
|
-
validateOrder(plan.release_order, "release_order", false);
|
|
1255
|
-
validateOrder(plan.refund_order, "refund_order", false);
|
|
1256
|
-
validateOrder(plan.unfund_order, "unfund_order", true);
|
|
1257
|
-
if (plan.fund_order.length === plan.unfund_order.length &&
|
|
1258
|
-
!plan.unfund_order.every((id, idx) => id === plan.fund_order[plan.fund_order.length - 1 - idx])) {
|
|
1259
|
-
add([...planBase, "unfund_order"], `unfund_order must be the reverse of fund_order`, "UDL5013");
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
function getFixedMoneyValue(schema) {
|
|
1263
|
-
if (!schema || typeof schema !== "object")
|
|
1264
|
-
return undefined;
|
|
1265
|
-
const s = schema;
|
|
1266
|
-
if (typeof s.const === "string" && /^[0-9]+$/.test(s.const)) {
|
|
1267
|
-
return BigInt(s.const);
|
|
1268
|
-
}
|
|
1269
|
-
if (Array.isArray(s.enum) &&
|
|
1270
|
-
s.enum.length === 1 &&
|
|
1271
|
-
typeof s.enum[0] === "string" &&
|
|
1272
|
-
/^[0-9]+$/.test(s.enum[0])) {
|
|
1273
|
-
return BigInt(s.enum[0]);
|
|
1274
|
-
}
|
|
1275
|
-
return undefined;
|
|
1276
|
-
}
|
|
1277
|
-
function validateFeeRules(instrument, base, references, add) {
|
|
1278
|
-
const feeRules = instrument.feeRules ?? [];
|
|
1279
|
-
if (feeRules.length === 0)
|
|
1280
|
-
return;
|
|
1281
|
-
if (Object.keys(instrument.fields).length > 24) {
|
|
1282
|
-
add([...base, "fields"], "fee-bearing instruments must not declare more than 24 fields", "UDL4001");
|
|
1283
|
-
}
|
|
1284
|
-
addDuplicateIssues(feeRules.map((fee) => fee.amountField), [...base, "feeRules"], "fee amount field", add);
|
|
1285
|
-
const currencyFields = Object.entries(instrument.fields)
|
|
1286
|
-
.filter(([, schema]) => isCurrencySchema(schema))
|
|
1287
|
-
.map(([field]) => field);
|
|
1288
|
-
const instrumentCurrency = currencyFields.length === 1 ? currencyFields[0] : undefined;
|
|
1289
|
-
const mutableFields = new Set(instrument.update?.fields ?? []);
|
|
1290
|
-
const partitions = instrument.partitions ?? [];
|
|
1291
|
-
feeRules.forEach((fee, feeIndex) => {
|
|
1292
|
-
const feeBase = [...base, "feeRules", feeIndex];
|
|
1293
|
-
const amountSchema = instrument.fields[fee.amountField];
|
|
1294
|
-
const baseSchema = instrument.fields[fee.baseField];
|
|
1295
|
-
if (!amountSchema || !isMoneySchema(amountSchema)) {
|
|
1296
|
-
add([...feeBase, "amountField"], `fee amount ${fee.amountField} must be a declared money field`, "UDL4001");
|
|
1297
|
-
}
|
|
1298
|
-
if (!baseSchema || !isMoneySchema(baseSchema)) {
|
|
1299
|
-
add([...feeBase, "baseField"], `fee base ${fee.baseField} must be a declared money field`, "UDL4001");
|
|
1300
|
-
}
|
|
1301
|
-
if (!instrument.required.includes(fee.baseField)) {
|
|
1302
|
-
add([...feeBase, "baseField"], `fee base ${fee.baseField} must be required`, "UDL4001");
|
|
1303
|
-
}
|
|
1304
|
-
if (mutableFields.has(fee.baseField)) {
|
|
1305
|
-
add([...feeBase, "baseField"], `fee base ${fee.baseField} cannot be mutable`, "UDL4001");
|
|
1306
|
-
}
|
|
1307
|
-
const bearerSchema = instrument.fields[fee.bearerField];
|
|
1308
|
-
if (!bearerSchema || !references.accepts(bearerSchema, "acct")) {
|
|
1309
|
-
add([...feeBase, "bearerField"], `fee bearer ${fee.bearerField} must be a declared account-id field`, "UDL4001");
|
|
1310
|
-
}
|
|
1311
|
-
else if (!instrument.required.includes(fee.bearerField)) {
|
|
1312
|
-
add([...feeBase, "bearerField"], `fee bearer ${fee.bearerField} must be required`, "UDL4001");
|
|
1313
|
-
}
|
|
1314
|
-
const validateExact = (rule, rulePath, direct) => {
|
|
1315
|
-
if (direct && rule.field !== fee.amountField) {
|
|
1316
|
-
add([...rulePath, "field"], `exact fee field ${rule.field} must equal amountField ${fee.amountField}`, "UDL4001");
|
|
1317
|
-
}
|
|
1318
|
-
if (!direct && rule.field === fee.amountField) {
|
|
1319
|
-
add([...rulePath, "field"], `tiered fee output ${fee.amountField} must differ from exact source ${rule.field}`, "UDL4001");
|
|
1320
|
-
}
|
|
1321
|
-
const exactSchema = instrument.fields[rule.field];
|
|
1322
|
-
if (!exactSchema || !isMoneySchema(exactSchema)) {
|
|
1323
|
-
add([...rulePath, "field"], `exact fee field ${rule.field} must be a declared money field`, "UDL4001");
|
|
1324
|
-
}
|
|
1325
|
-
if (!instrument.required.includes(rule.field)) {
|
|
1326
|
-
add([...rulePath, "field"], `exact fee field ${rule.field} must be required`, "UDL4001");
|
|
1327
|
-
}
|
|
1328
|
-
if (mutableFields.has(rule.field)) {
|
|
1329
|
-
add([...rulePath, "field"], `exact fee field ${rule.field} cannot be mutable`, "UDL4001");
|
|
1330
|
-
}
|
|
1331
|
-
if (instrumentCurrency === undefined ||
|
|
1332
|
-
rule.currencyField !== instrumentCurrency) {
|
|
1333
|
-
add([...rulePath, "currencyField"], `exact fee currency ${rule.currencyField} must equal the fee base currency field`, "UDL4001");
|
|
1334
|
-
}
|
|
1335
|
-
};
|
|
1336
|
-
if (fee.rule.kind === "exact") {
|
|
1337
|
-
validateExact(fee.rule, [...feeBase, "rule"], true);
|
|
1338
|
-
}
|
|
1339
|
-
else {
|
|
1340
|
-
if (instrument.required.includes(fee.amountField)) {
|
|
1341
|
-
add([...feeBase, "amountField"], `computed fee amount ${fee.amountField} must not be required at create`, "UDL4001");
|
|
1342
|
-
}
|
|
1343
|
-
if (fee.rule.kind === "tiered") {
|
|
1344
|
-
const tiers = fee.rule.tiers;
|
|
1345
|
-
const openEnded = tiers.filter((tier) => tier.toExclusive === undefined).length;
|
|
1346
|
-
if (openEnded !== 1) {
|
|
1347
|
-
add([...feeBase, "rule", "tiers"], `tiered fee must declare exactly one open-ended tier; found ${openEnded}`, "UDL4001");
|
|
1348
|
-
}
|
|
1349
|
-
tiers.forEach((tier, tierIndex) => {
|
|
1350
|
-
const tierPath = [...feeBase, "rule", "tiers", tierIndex];
|
|
1351
|
-
const from = BigInt(tier.fromInclusive);
|
|
1352
|
-
if (tierIndex === 0 && from !== 0n) {
|
|
1353
|
-
add([...tierPath, "fromInclusive"], "tiered fee coverage must start at 0", "UDL4001");
|
|
1354
|
-
}
|
|
1355
|
-
if (tier.toExclusive !== undefined) {
|
|
1356
|
-
const to = BigInt(tier.toExclusive);
|
|
1357
|
-
if (to <= from) {
|
|
1358
|
-
add([...tierPath, "toExclusive"], "tier upper bound must be greater than its lower bound", "UDL4001");
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
else if (tierIndex !== tiers.length - 1) {
|
|
1362
|
-
add([...tierPath, "toExclusive"], "only the final fee tier may be open-ended", "UDL4001");
|
|
1363
|
-
}
|
|
1364
|
-
if (tierIndex > 0) {
|
|
1365
|
-
const previous = tiers[tierIndex - 1];
|
|
1366
|
-
if (previous?.toExclusive === undefined) {
|
|
1367
|
-
add([...tierPath, "fromInclusive"], "a fee tier cannot follow an open-ended tier", "UDL4001");
|
|
1368
|
-
}
|
|
1369
|
-
else {
|
|
1370
|
-
const previousEnd = BigInt(previous.toExclusive);
|
|
1371
|
-
if (from > previousEnd) {
|
|
1372
|
-
add([...tierPath, "fromInclusive"], `fee tiers have a gap before ${tier.fromInclusive}`, "UDL4001");
|
|
1373
|
-
}
|
|
1374
|
-
else if (from < previousEnd) {
|
|
1375
|
-
add([...tierPath, "fromInclusive"], `fee tiers overlap at ${tier.fromInclusive}`, "UDL4001");
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
if (tier.rule.kind === "exact") {
|
|
1380
|
-
validateExact(tier.rule, [...tierPath, "rule"], false);
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
const basePartitions = partitions.filter((partition) => partition.totalField === fee.baseField);
|
|
1386
|
-
const direct = basePartitions.some((partition) => partition.pieceFields.includes(fee.amountField));
|
|
1387
|
-
const nested = partitions.filter((partition) => partition.totalField === fee.amountField &&
|
|
1388
|
-
basePartitions.some((basePartition) => partition.pieceFields.every((piece) => basePartition.pieceFields.includes(piece))));
|
|
1389
|
-
const carvedInsideBase = direct || nested.length === 1;
|
|
1390
|
-
if (fee.position === "carved" && !carvedInsideBase) {
|
|
1391
|
-
add([...feeBase, "position"], `carved fee ${fee.amountField} must form part of a partition of ${fee.baseField}`, "UDL4001");
|
|
1392
|
-
}
|
|
1393
|
-
if (fee.position === "on_top" && carvedInsideBase) {
|
|
1394
|
-
add([...feeBase, "position"], `on-top fee ${fee.amountField} must stay outside the partition of ${fee.baseField}`, "UDL4001");
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1398
|
-
function validateSetsAt(instrument, base, add) {
|
|
1399
|
-
const writers = new Map();
|
|
1400
|
-
const markerFields = new Set();
|
|
1401
|
-
for (const [actionName, action] of Object.entries(instrument.actions)) {
|
|
1402
|
-
if (!action.setsAt)
|
|
1403
|
-
continue;
|
|
1404
|
-
const path = [...base, "actions", actionName, "setsAt"];
|
|
1405
|
-
const { field, marker, offset } = action.setsAt;
|
|
1406
|
-
if (marker)
|
|
1407
|
-
markerFields.add(field);
|
|
1408
|
-
if (actionName === "create") {
|
|
1409
|
-
add(path, "setsAt requires a lifecycle transition and cannot run on create", "UDL3001");
|
|
1410
|
-
}
|
|
1411
|
-
const fieldWriters = writers.get(field) ?? [];
|
|
1412
|
-
if (fieldWriters.length > 0) {
|
|
1413
|
-
const alternatives = [...fieldWriters, actionName];
|
|
1414
|
-
const targets = new Set(alternatives.map((writer) => instrument.lifecycle.transitions[writer]?.to));
|
|
1415
|
-
const reentrant = alternatives.some((writer) => {
|
|
1416
|
-
const transition = instrument.lifecycle.transitions[writer];
|
|
1417
|
-
return transition?.from.includes(transition.to) === true;
|
|
1418
|
-
});
|
|
1419
|
-
if (targets.size !== 1 || targets.has(undefined) || reentrant) {
|
|
1420
|
-
add([...path, "field"], `${field} has multiple writers without one shared one-way destination`, "UDL3001");
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
writers.set(field, [...fieldWriters, actionName]);
|
|
1424
|
-
const schema = instrument.fields[field];
|
|
1425
|
-
if (!schema) {
|
|
1426
|
-
add([...path, "field"], `setsAt references unknown field ${field}`, "UDL3001");
|
|
1427
|
-
}
|
|
1428
|
-
else if (schema.type !== "string" || !isDateTimeFormat(schema.format)) {
|
|
1429
|
-
add([...path, "field"], "setsAt must target a date-time field", "UDL3001");
|
|
1430
|
-
}
|
|
1431
|
-
if (instrument.required.includes(field)) {
|
|
1432
|
-
add([...path, "field"], "setsAt target must be optional at create", "UDL3001");
|
|
1433
|
-
}
|
|
1434
|
-
if (instrument.update?.fields.includes(field)) {
|
|
1435
|
-
add([...path, "field"], "setsAt target cannot also be mutable", "UDL3001");
|
|
1436
|
-
}
|
|
1437
|
-
const offsetMs = fixedIsoDurationMs(offset);
|
|
1438
|
-
if (offsetMs === null || offsetMs <= 0) {
|
|
1439
|
-
add([...path, "offset"], "setsAt.offset must be a positive fixed ISO-8601 duration", "UDL3001");
|
|
1440
|
-
}
|
|
1441
|
-
const readers = Object.entries(instrument.actions).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
|
|
1442
|
-
if (marker) {
|
|
1443
|
-
if (readers.length > 0) {
|
|
1444
|
-
add(path, `setsAt marker field ${field} cannot drive a due condition or deadline`, "UDL3001");
|
|
1445
|
-
}
|
|
1446
|
-
continue;
|
|
1447
|
-
}
|
|
1448
|
-
if (readers.length === 0) {
|
|
1449
|
-
add(path, `setsAt field ${field} must anchor at least one due condition or deadline`, "UDL3001");
|
|
1450
|
-
continue;
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
for (const [field, fieldWriters] of writers) {
|
|
1454
|
-
if (markerFields.has(field))
|
|
1455
|
-
continue;
|
|
1456
|
-
const readers = Object.entries(instrument.actions).filter(([, candidate]) => candidate.due?.field === field || candidate.deadline?.field === field);
|
|
1457
|
-
for (const [readerName] of readers) {
|
|
1458
|
-
if (!actionGroupDominatesReader(instrument, fieldWriters, readerName)) {
|
|
1459
|
-
add([...base, "actions", readerName], `action ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`, "UDL3001");
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
function actionGroupDominatesReader(instrument, writers, reader) {
|
|
1465
|
-
if (writers.length === 0)
|
|
1466
|
-
return false;
|
|
1467
|
-
const readerTransition = instrument.lifecycle.transitions[reader];
|
|
1468
|
-
if (!readerTransition)
|
|
1469
|
-
return false;
|
|
1470
|
-
const removed = new Set(writers);
|
|
1471
|
-
const reachable = new Set([instrument.lifecycle.initial]);
|
|
1472
|
-
const pending = [instrument.lifecycle.initial];
|
|
1473
|
-
while (pending.length > 0) {
|
|
1474
|
-
const state = pending.shift();
|
|
1475
|
-
for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
|
|
1476
|
-
if (removed.has(action) ||
|
|
1477
|
-
!transition.from.includes(state) ||
|
|
1478
|
-
reachable.has(transition.to)) {
|
|
1479
|
-
continue;
|
|
1480
|
-
}
|
|
1481
|
-
reachable.add(transition.to);
|
|
1482
|
-
pending.push(transition.to);
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
return readerTransition.from.every((state) => !reachable.has(state));
|
|
1486
|
-
}
|
|
1487
|
-
function validateReachability(instrument, base, add) {
|
|
1488
|
-
const reachable = new Set([instrument.lifecycle.initial]);
|
|
1489
|
-
let changed = true;
|
|
1490
|
-
while (changed) {
|
|
1491
|
-
changed = false;
|
|
1492
|
-
for (const transition of Object.values(instrument.lifecycle.transitions)) {
|
|
1493
|
-
if (transition.from.some((state) => reachable.has(state)) &&
|
|
1494
|
-
!reachable.has(transition.to)) {
|
|
1495
|
-
reachable.add(transition.to);
|
|
1496
|
-
changed = true;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
instrument.lifecycle.states.forEach((state, stateIndex) => {
|
|
1501
|
-
if (!reachable.has(state)) {
|
|
1502
|
-
add([...base, "lifecycle", "states", stateIndex], `lifecycle state ${state} is unreachable from ${instrument.lifecycle.initial}`, "UDL3001");
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
function validateParties(instrument, base, references, add) {
|
|
1507
|
-
if (!instrument.parties)
|
|
1508
|
-
return;
|
|
1509
|
-
const entries = Object.entries(instrument.parties).filter((entry) => typeof entry[1] === "string");
|
|
1510
|
-
if (entries.length === 0) {
|
|
1511
|
-
add([...base, "parties"], "parties must contain at least one declaration", "UDL2002");
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
if (instrument.distinctParties && entries.length < 2) {
|
|
1515
|
-
add([...base, "distinctParties"], "distinctParties requires at least two declared party roles", "UDL2002");
|
|
1516
|
-
}
|
|
1517
|
-
addDuplicateIssues(entries.map(([, field]) => field), [...base, "parties"], "party field", add);
|
|
1518
|
-
for (const [role, field] of entries) {
|
|
1519
|
-
const schema = instrument.fields[field];
|
|
1520
|
-
if (!schema) {
|
|
1521
|
-
add([...base, "parties", role], `party role ${role} references unknown field ${field}`, "UDL2002");
|
|
1522
|
-
}
|
|
1523
|
-
else if (!references.accepts(schema, "acct")) {
|
|
1524
|
-
add([...base, "parties", role], `party role ${role} must reference an account-id field`, "UDL2002");
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
function validateUpdate(instrument, base, states, add) {
|
|
1529
|
-
if (!instrument.update)
|
|
1530
|
-
return;
|
|
1531
|
-
addDuplicateIssues(instrument.update.fields, [...base, "update", "fields"], "field", add);
|
|
1532
|
-
addDuplicateIssues(instrument.update.states, [...base, "update", "states"], "state", add);
|
|
1533
|
-
instrument.update.fields.forEach((field, index) => {
|
|
1534
|
-
if (!Object.hasOwn(instrument.fields, field)) {
|
|
1535
|
-
add([...base, "update", "fields", index], `update references unknown field ${field}`, "UDL2002");
|
|
1536
|
-
}
|
|
1537
|
-
});
|
|
1538
|
-
instrument.update.states.forEach((state, index) => {
|
|
1539
|
-
if (!states.has(state)) {
|
|
1540
|
-
add([...base, "update", "states", index], `update references unknown state ${state}`, "UDL2002");
|
|
1541
|
-
}
|
|
1542
|
-
});
|
|
1543
|
-
if (instrument.update.examples) {
|
|
1544
|
-
addDuplicateIssues(instrument.update.examples.map((example) => example.name), [...base, "update", "examples"], "example name", add);
|
|
1545
|
-
const schema = {
|
|
1546
|
-
additionalProperties: false,
|
|
1547
|
-
properties: Object.fromEntries(instrument.update.fields.flatMap((field) => {
|
|
1548
|
-
const fieldSchema = instrument.fields[field];
|
|
1549
|
-
return fieldSchema ? [[field, fieldSchema]] : [];
|
|
1550
|
-
})),
|
|
1551
|
-
type: "object",
|
|
1552
|
-
};
|
|
1553
|
-
instrument.update.examples.forEach((example, exampleIndex) => {
|
|
1554
|
-
const input = exampleInputForUdlValidation(instrument, example.input);
|
|
1555
|
-
for (const error of validateUdlSchemaValue(schema, input).errors) {
|
|
1556
|
-
add([...base, "update", "examples", exampleIndex, "input"], `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`, "UDL2002");
|
|
1557
|
-
}
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
function validateCallerParkedStates(instrument, base, states, add) {
|
|
1562
|
-
const parked = instrument.callerParkedStates;
|
|
1563
|
-
if (parked === undefined)
|
|
1564
|
-
return;
|
|
1565
|
-
const exitsByState = new Map(instrument.lifecycle.states.map((state) => [state, []]));
|
|
1566
|
-
for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
|
|
1567
|
-
const stationary = instrument.actions[action]?.due?.every !== undefined &&
|
|
1568
|
-
transition.from.includes(transition.to);
|
|
1569
|
-
if (stationary)
|
|
1570
|
-
continue;
|
|
1571
|
-
for (const state of transition.from) {
|
|
1572
|
-
if (state !== transition.to)
|
|
1573
|
-
exitsByState.get(state)?.push(action);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
const isUnconditionallyTimeDriven = (action) => {
|
|
1577
|
-
const due = instrument.actions[action]?.due;
|
|
1578
|
-
if (!due || !isDateTimeFormat(instrument.fields[due.field]?.format)) {
|
|
1579
|
-
return false;
|
|
1580
|
-
}
|
|
1581
|
-
if (instrument.required.includes(due.field))
|
|
1582
|
-
return true;
|
|
1583
|
-
const writers = Object.entries(instrument.actions).flatMap(([writer, definition]) => definition.setsAt?.field === due.field ? [writer] : []);
|
|
1584
|
-
return actionGroupDominatesReader(instrument, writers, action);
|
|
1585
|
-
};
|
|
1586
|
-
for (const state of Object.keys(parked)) {
|
|
1587
|
-
const stateBase = [...base, "callerParkedStates", state];
|
|
1588
|
-
if (!states.has(state)) {
|
|
1589
|
-
add(stateBase, `callerParkedStates references unknown state ${state}`, "UDL3001");
|
|
1590
|
-
continue;
|
|
1591
|
-
}
|
|
1592
|
-
const exits = exitsByState.get(state) ?? [];
|
|
1593
|
-
if (exits.length === 0) {
|
|
1594
|
-
add(stateBase, `terminal state ${state} cannot be caller-parked`, "UDL3001");
|
|
1595
|
-
}
|
|
1596
|
-
else if (exits.some(isUnconditionallyTimeDriven)) {
|
|
1597
|
-
add(stateBase, `caller-parked state ${state} has a time-driven exit`, "UDL3001");
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
for (const [state, exits] of exitsByState) {
|
|
1601
|
-
if (exits.length > 0 &&
|
|
1602
|
-
!exits.some(isUnconditionallyTimeDriven) &&
|
|
1603
|
-
!Object.hasOwn(parked, state)) {
|
|
1604
|
-
add([...base, "callerParkedStates"], `state ${state} has only caller-driven exits and needs a caller-parked reason`, "UDL3001");
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
/**
|
|
1609
|
-
* Composition dials sit on the document, not on an instrument: a confirmation
|
|
1610
|
-
* threshold is a property of the whole product. Keys are unique across the
|
|
1611
|
-
* document the same way instrument dial keys are unique within an instrument.
|
|
1612
|
-
*/
|
|
1613
|
-
function validateCompositionDials(document, add) {
|
|
1614
|
-
const dials = document.dials ?? [];
|
|
1615
|
-
addDuplicateIssues(dials.map((dial) => dial.key), ["dials"], "composition dial key", add);
|
|
1616
|
-
const kinds = new Map();
|
|
1617
|
-
dials.forEach((dial, dialIndex) => {
|
|
1618
|
-
const prior = kinds.get(dial.kind);
|
|
1619
|
-
if (prior !== undefined) {
|
|
1620
|
-
add(["dials", dialIndex, "kind"], `composition dial kind ${dial.kind} duplicates dials[${prior}]`, "UDL2001");
|
|
1621
|
-
return;
|
|
1622
|
-
}
|
|
1623
|
-
kinds.set(dial.kind, dialIndex);
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1626
|
-
function validateDials(instrument, base, add) {
|
|
1627
|
-
const dials = instrument.dials ?? [];
|
|
1628
|
-
addDuplicateIssues(dials.map((dial) => dial.key), [...base, "dials"], "dial key", add);
|
|
1629
|
-
const targets = new Map();
|
|
1630
|
-
dials.forEach((dial, dialIndex) => {
|
|
1631
|
-
const dialBase = [...base, "dials", dialIndex];
|
|
1632
|
-
const target = dial.kind === "window"
|
|
1633
|
-
? `window:${dial.field}`
|
|
1634
|
-
: dial.kind === "decision_deadline_ms"
|
|
1635
|
-
? `decision:${dial.action}`
|
|
1636
|
-
: dial.kind === "reconcile_tolerance"
|
|
1637
|
-
? `reconcile_tolerance:${dial.key}`
|
|
1638
|
-
: "unwind_penalty";
|
|
1639
|
-
const prior = targets.get(target);
|
|
1640
|
-
if (prior !== undefined) {
|
|
1641
|
-
add([...dialBase, "key"], `dial target ${target} duplicates dials[${prior}]`, "UDL5008");
|
|
1642
|
-
}
|
|
1643
|
-
else {
|
|
1644
|
-
targets.set(target, dialIndex);
|
|
1645
|
-
}
|
|
1646
|
-
if (dial.kind === "window") {
|
|
1647
|
-
const anchorsAction = Object.values(instrument.actions).some((definition) => definition.due?.field === dial.field ||
|
|
1648
|
-
definition.deadline?.field === dial.field);
|
|
1649
|
-
if (!anchorsAction) {
|
|
1650
|
-
add([...dialBase, "field"], `window dial field ${dial.field} anchors no action deadline or due condition`, "UDL5008");
|
|
1651
|
-
}
|
|
1652
|
-
const min = dial.minOffset === undefined
|
|
1653
|
-
? undefined
|
|
1654
|
-
: fixedIsoDurationMs(dial.minOffset);
|
|
1655
|
-
const max = dial.maxOffset === undefined
|
|
1656
|
-
? undefined
|
|
1657
|
-
: fixedIsoDurationMs(dial.maxOffset);
|
|
1658
|
-
if (dial.minOffset !== undefined && min === null) {
|
|
1659
|
-
add([...dialBase, "minOffset"], "dial minOffset must be a fixed ISO-8601 duration", "UDL5008");
|
|
1660
|
-
}
|
|
1661
|
-
if (dial.maxOffset !== undefined && max === null) {
|
|
1662
|
-
add([...dialBase, "maxOffset"], "dial maxOffset must be a fixed ISO-8601 duration", "UDL5008");
|
|
1663
|
-
}
|
|
1664
|
-
if (min !== undefined &&
|
|
1665
|
-
min !== null &&
|
|
1666
|
-
max !== undefined &&
|
|
1667
|
-
max !== null &&
|
|
1668
|
-
min > max) {
|
|
1669
|
-
add([...dialBase, "maxOffset"], "dial minOffset exceeds maxOffset", "UDL5008");
|
|
1670
|
-
}
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
if (dial.kind === "decision_deadline_ms") {
|
|
1674
|
-
if (!instrument.actions[dial.action]?.decision) {
|
|
1675
|
-
add([...dialBase, "action"], `decision deadline dial action ${dial.action} declares no decision`, "UDL5008");
|
|
1676
|
-
}
|
|
1677
|
-
if (dial.minMs !== undefined &&
|
|
1678
|
-
dial.maxMs !== undefined &&
|
|
1679
|
-
dial.minMs > dial.maxMs) {
|
|
1680
|
-
add([...dialBase, "maxMs"], "dial minMs exceeds maxMs", "UDL5008");
|
|
1681
|
-
}
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
if (dial.kind === "reconcile_tolerance") {
|
|
1685
|
-
const forgives = Object.values(instrument.actions).some((action) => (action.reconcile ?? []).some((reconcile) => reconcile.match.law === "tolerance" &&
|
|
1686
|
-
reconcile.match.dial === dial.key));
|
|
1687
|
-
if (!forgives) {
|
|
1688
|
-
add(dialBase, `reconcile tolerance dial ${dial.key} bounds no reconcile`, "UDL5008");
|
|
1689
|
-
}
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
if (!Object.values(instrument.actions).some((action) => action.quote)) {
|
|
1693
|
-
add(dialBase, "unwind penalty dial requires a quoting action", "UDL5008");
|
|
1694
|
-
}
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
1697
|
-
function validateActionUpdates(instrument, action, definition, actionBase, add) {
|
|
1698
|
-
if (!definition.updates)
|
|
1699
|
-
return;
|
|
1700
|
-
if (action === "create") {
|
|
1701
|
-
add([...actionBase, "updates"], "create cannot declare updates", "UDL5008");
|
|
1702
|
-
}
|
|
1703
|
-
addDuplicateIssues(definition.updates, [...actionBase, "updates"], "updated field", add);
|
|
1704
|
-
const inputFields = recordValue(definition.input?.properties);
|
|
1705
|
-
const stepBoundFields = new Set(Object.values(instrument.actions).flatMap((candidate) => [...candidate.steps, ...candidate.moves].flatMap((step) => Object.values(step.bind).flatMap((binding) => binding.from === "instance" && binding.path.startsWith("fields.")
|
|
1706
|
-
? [binding.path.slice("fields.".length)]
|
|
1707
|
-
: []))));
|
|
1708
|
-
const setsAtFields = new Set(Object.values(instrument.actions).flatMap((candidate) => candidate.setsAt ? [candidate.setsAt.field] : []));
|
|
1709
|
-
const partitionFields = new Set((instrument.partitions ?? []).flatMap((partition) => [
|
|
1710
|
-
partition.totalField,
|
|
1711
|
-
...partition.pieceFields,
|
|
1712
|
-
]));
|
|
1713
|
-
const aggregateFields = new Set((instrument.aggregateInvariants ?? []).map((invariant) => invariant.parentField));
|
|
1714
|
-
definition.updates.forEach((field, fieldIndex) => {
|
|
1715
|
-
const fieldPath = [...actionBase, "updates", fieldIndex];
|
|
1716
|
-
if (!Object.hasOwn(inputFields, field)) {
|
|
1717
|
-
add(fieldPath, `updated field ${field} is not declared by action input`, "UDL5008");
|
|
1718
|
-
}
|
|
1719
|
-
if (!Object.hasOwn(instrument.fields, field)) {
|
|
1720
|
-
add(fieldPath, `updated field ${field} is not an instrument field`, "UDL5008");
|
|
1721
|
-
}
|
|
1722
|
-
if (stepBoundFields.has(field)) {
|
|
1723
|
-
add(fieldPath, `updated field ${field} is bound by a kernel step`, "UDL5008");
|
|
1724
|
-
}
|
|
1725
|
-
if (setsAtFields.has(field)) {
|
|
1726
|
-
add(fieldPath, `updated field ${field} is owned by setsAt`, "UDL5008");
|
|
1727
|
-
}
|
|
1728
|
-
if (partitionFields.has(field)) {
|
|
1729
|
-
add(fieldPath, `updated field ${field} participates in a partition`, "UDL5008");
|
|
1730
|
-
}
|
|
1731
|
-
if (aggregateFields.has(field)) {
|
|
1732
|
-
add(fieldPath, `updated field ${field} is an aggregate cap`, "UDL5008");
|
|
1733
|
-
}
|
|
1734
|
-
});
|
|
1735
|
-
if (definition.moves.length > 0 ||
|
|
1736
|
-
definition.allocate ||
|
|
1737
|
-
definition.contributionStage) {
|
|
1738
|
-
add([...actionBase, "updates"], "an action cannot update fields while moving money", "UDL5008");
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
function validateCheckRequirements(instrument, definition, actionBase, add) {
|
|
1742
|
-
const checks = definition.requiresChecks ?? [];
|
|
1743
|
-
addDuplicateIssues(checks.map((check) => `${check.family}:${check.checkKind}`), [...actionBase, "requiresChecks"], "check requirement", add);
|
|
1744
|
-
checks.forEach((check, checkIndex) => {
|
|
1745
|
-
const checkBase = [...actionBase, "requiresChecks", checkIndex];
|
|
1746
|
-
if (!Object.hasOwn(instrument.fields, check.subjectField)) {
|
|
1747
|
-
add([...checkBase, "subjectField"], `check subjectField ${check.subjectField} is not declared`, "UDL5002");
|
|
1748
|
-
}
|
|
1749
|
-
addDuplicateIssues(check.statuses, [...checkBase, "statuses"], "accepted check status", add);
|
|
1750
|
-
if (check.maxAge && fixedIsoDurationMs(check.maxAge) === null) {
|
|
1751
|
-
add([...checkBase, "maxAge"], "check maxAge must be a fixed ISO-8601 duration", "UDL5002");
|
|
1752
|
-
}
|
|
1753
|
-
});
|
|
1754
|
-
}
|
|
1755
|
-
function validateRemainder(instrument, action, definition, actionBase, instruments, references, add) {
|
|
1756
|
-
const remainder = definition.remainder;
|
|
1757
|
-
if (!remainder)
|
|
1758
|
-
return;
|
|
1759
|
-
const remainderBase = [...actionBase, "remainder"];
|
|
1760
|
-
if (action === "create") {
|
|
1761
|
-
add(remainderBase, "create cannot declare remainder", "UDL4001");
|
|
1762
|
-
}
|
|
1763
|
-
if (definition.distribute || definition.signedSum) {
|
|
1764
|
-
add(remainderBase, "remainder cannot combine with distribute or signedSum", "UDL4001");
|
|
1765
|
-
}
|
|
1766
|
-
if (remainder.accumulateRef !== undefined &&
|
|
1767
|
-
remainder.accumulateRef === remainder.amountRef) {
|
|
1768
|
-
add([...remainderBase, "accumulateRef"], "remainder accumulateRef must differ from amountRef", "UDL4001");
|
|
1769
|
-
}
|
|
1770
|
-
addDuplicateIssues(remainder.subtractPaths ?? [], [...remainderBase, "subtractPaths"], "remainder operand", add);
|
|
1771
|
-
for (const path of remainder.subtractPaths ?? []) {
|
|
1772
|
-
const [root, key] = path.split(".");
|
|
1773
|
-
const declared = root === "fields" &&
|
|
1774
|
-
key !== undefined &&
|
|
1775
|
-
isMoneySchema(instrument.fields[key] ?? {}) &&
|
|
1776
|
-
!instrument.update?.fields.includes(key) &&
|
|
1777
|
-
!Object.values(instrument.actions).some((action) => action.updates?.includes(key));
|
|
1778
|
-
if (!declared ||
|
|
1779
|
-
path === remainder.totalPath ||
|
|
1780
|
-
path === `refs.${remainder.amountRef}`)
|
|
1781
|
-
add([...remainderBase, "subtractPaths"], `remainder subtraction ${path} must name distinct immutable money`, "UDL4001");
|
|
1782
|
-
}
|
|
1783
|
-
const [totalRoot, totalKey] = remainder.totalPath.split(".");
|
|
1784
|
-
const totalDeclared = (totalRoot === "fields" &&
|
|
1785
|
-
totalKey !== undefined &&
|
|
1786
|
-
isMoneySchema(instrument.fields[totalKey] ?? {})) ||
|
|
1787
|
-
(totalRoot === "refs" &&
|
|
1788
|
-
totalKey !== undefined &&
|
|
1789
|
-
Object.values(instrument.actions).some((candidate) => candidate.remainder?.accumulateRef === totalKey));
|
|
1790
|
-
if (!totalDeclared) {
|
|
1791
|
-
add([...remainderBase, "totalPath"], `remainder total ${remainder.totalPath} is not declared money`, "UDL4001");
|
|
1792
|
-
}
|
|
1793
|
-
if (remainder.inputKey !== undefined &&
|
|
1794
|
-
!Object.hasOwn(recordValue(definition.input?.properties), remainder.inputKey)) {
|
|
1795
|
-
add([...remainderBase, "inputKey"], `remainder inputKey ${remainder.inputKey} is not declared by action input`, "UDL4001");
|
|
1796
|
-
}
|
|
1797
|
-
const transfers = definition.moves.filter((move) => move.operation === "internal_transfer.create" &&
|
|
1798
|
-
move.bind.amount?.from === "instance" &&
|
|
1799
|
-
move.bind.amount.path === `refs.${remainder.amountRef}`);
|
|
1800
|
-
if (transfers.length !== 1) {
|
|
1801
|
-
add(remainderBase, `remainder requires exactly one internal transfer whose amount is refs.${remainder.amountRef}`, "UDL4001");
|
|
1802
|
-
}
|
|
1803
|
-
(remainder.collected ?? []).forEach((collected, collectedIndex) => {
|
|
1804
|
-
const collectedBase = [
|
|
1805
|
-
...remainderBase,
|
|
1806
|
-
"collected",
|
|
1807
|
-
collectedIndex,
|
|
1808
|
-
];
|
|
1809
|
-
addDuplicateIssues(collected.statuses, [...collectedBase, "statuses"], "collected status", add);
|
|
1810
|
-
const child = instruments.get(collected.instrumentId);
|
|
1811
|
-
if (!child) {
|
|
1812
|
-
add([...collectedBase, "instrumentId"], `remainder references unknown instrument ${collected.instrumentId}`, "UDL4001");
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
const refSchema = child.fields[collected.refField];
|
|
1816
|
-
if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
|
|
1817
|
-
add([...collectedBase, "refField"], `${child.id}.${collected.refField} must reference ${instrument.id}`, "UDL4001");
|
|
1818
|
-
}
|
|
1819
|
-
const amountDeclared = collected.path === "refs"
|
|
1820
|
-
? Object.values(child.actions).some((candidate) => candidate.remainder?.amountRef === collected.amountField ||
|
|
1821
|
-
candidate.remainder?.accumulateRef === collected.amountField)
|
|
1822
|
-
: isMoneySchema(child.fields[collected.amountField] ?? {});
|
|
1823
|
-
if (!amountDeclared) {
|
|
1824
|
-
add([...collectedBase, "amountField"], `${child.id}.${collected.amountField} is not declared money`, "UDL4001");
|
|
1825
|
-
}
|
|
1826
|
-
if (collected.path !== "refs" &&
|
|
1827
|
-
!(instrument.aggregateInvariants ?? []).some((invariant) => invariant.childInstrumentId === collected.instrumentId &&
|
|
1828
|
-
invariant.childRefField === collected.refField &&
|
|
1829
|
-
"childField" in invariant &&
|
|
1830
|
-
invariant.childField === collected.amountField)) {
|
|
1831
|
-
add(collectedBase, `remainder collection ${collected.instrumentId}.${collected.amountField} has no congruent aggregate invariant`, "UDL4001");
|
|
1832
|
-
}
|
|
1833
|
-
collected.statuses.forEach((status, statusIndex) => {
|
|
1834
|
-
if (!child.lifecycle.states.includes(status)) {
|
|
1835
|
-
add([...collectedBase, "statuses", statusIndex], `remainder status ${status} is not declared by ${child.id}`, "UDL4001");
|
|
1836
|
-
}
|
|
1837
|
-
});
|
|
1838
|
-
});
|
|
1839
|
-
}
|
|
1840
|
-
function validateActions(instrument, base, instruments, references, add) {
|
|
1841
|
-
validatePayoutsAndSettlement(instrument, base, instruments, references, add);
|
|
1842
|
-
for (const [action, definition] of Object.entries(instrument.actions)) {
|
|
1843
|
-
const actionBase = [...base, "actions", action];
|
|
1844
|
-
if (action === "create" && definition.input) {
|
|
1845
|
-
add([...actionBase, "input"], "create already consumes instrument fields and must not declare input", "UDL5008");
|
|
1846
|
-
}
|
|
1847
|
-
if (definition.input && definition.input.type !== "object") {
|
|
1848
|
-
add([...actionBase, "input", "type"], "action input must declare an object-shaped JSON Schema", "UDL5008");
|
|
1849
|
-
}
|
|
1850
|
-
const inputFields = recordValue(definition.input?.properties);
|
|
1851
|
-
for (const [refKey, inputKey] of Object.entries(definition.captureInput ?? {})) {
|
|
1852
|
-
if (!Object.hasOwn(inputFields, inputKey)) {
|
|
1853
|
-
add([...actionBase, "captureInput", refKey], `captured receipt input references undeclared action input field ${inputKey}`, "UDL5008");
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
validateDecidedAmount(instrument, action, actionBase, add);
|
|
1857
|
-
validateActionUpdates(instrument, action, definition, actionBase, add);
|
|
1858
|
-
validateCheckRequirements(instrument, definition, actionBase, add);
|
|
1859
|
-
validateRemainder(instrument, action, definition, actionBase, instruments, references, add);
|
|
1860
|
-
addDuplicateIssues(definition.moves.map((move) => move.key), [...actionBase, "moves"], "move key", add);
|
|
1861
|
-
if (definition.earnable && definition.moves.length === 0) {
|
|
1862
|
-
add(actionBase, "earnable requires a money move", "UDL5008");
|
|
1863
|
-
}
|
|
1864
|
-
definition.steps.forEach((step, stepIndex) => validateStep(instrument, definition, step, [...actionBase, "steps", stepIndex], add));
|
|
1865
|
-
definition.moves.forEach((move, moveIndex) => validateStep(instrument, definition, move, [...actionBase, "moves", moveIndex], add));
|
|
1866
|
-
if (definition.requiresRefs) {
|
|
1867
|
-
const boundFields = new Map();
|
|
1868
|
-
for (const gate of definition.requiresRefs) {
|
|
1869
|
-
for (const [field, path] of Object.entries(gate.bind ?? {})) {
|
|
1870
|
-
const source = `${gate.field}:${path}`;
|
|
1871
|
-
const previous = boundFields.get(field);
|
|
1872
|
-
if (previous !== undefined && previous !== source)
|
|
1873
|
-
add([...actionBase, "requiresRefs"], `reference gates bind ${field} from conflicting sources`, "UDL5001");
|
|
1874
|
-
boundFields.set(field, source);
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
addDuplicateIssues(definition.requiresRefs.map((gate) => JSON.stringify(sortObject(gate))), [...actionBase, "requiresRefs"], "gate", add);
|
|
1878
|
-
definition.requiresRefs.forEach((gate, gateIndex) => {
|
|
1879
|
-
addDuplicateIssues(gate.statuses, [...actionBase, "requiresRefs", gateIndex, "statuses"], "gate status", add);
|
|
1880
|
-
const schema = instrument.fields[gate.field];
|
|
1881
|
-
if (!schema) {
|
|
1882
|
-
add([...actionBase, "requiresRefs", gateIndex, "field"], `gate references unknown field ${gate.field}`, "UDL5001");
|
|
1883
|
-
return;
|
|
1884
|
-
}
|
|
1885
|
-
const targets = [...instruments.values()].filter((target) => references.accepts(schema, target.idPrefix));
|
|
1886
|
-
if (targets.length !== 1) {
|
|
1887
|
-
add([...actionBase, "requiresRefs", gateIndex, "field"], `gate field ${gate.field} must identify exactly one instrument (found ${targets.length})`, "UDL5001");
|
|
1888
|
-
return;
|
|
1889
|
-
}
|
|
1890
|
-
const target = targets[0];
|
|
1891
|
-
gate.statuses.forEach((status, statusIndex) => {
|
|
1892
|
-
if (!target.lifecycle.states.includes(status)) {
|
|
1893
|
-
add([
|
|
1894
|
-
...actionBase,
|
|
1895
|
-
"requiresRefs",
|
|
1896
|
-
gateIndex,
|
|
1897
|
-
"statuses",
|
|
1898
|
-
statusIndex,
|
|
1899
|
-
], `gate status ${status} is not declared by ${target.id}`, "UDL5008");
|
|
1900
|
-
}
|
|
1901
|
-
});
|
|
1902
|
-
validateGateShape(instrument, action, gate, target, [...actionBase, "requiresRefs", gateIndex], add);
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
if (definition.requiresDrainedAccount) {
|
|
1906
|
-
const drainBase = [...actionBase, "requiresDrainedAccount"];
|
|
1907
|
-
if (action === "create") {
|
|
1908
|
-
add([...drainBase], "requiresDrainedAccount cannot gate create: no account exists yet", "UDL5008");
|
|
1909
|
-
}
|
|
1910
|
-
const path = definition.requiresDrainedAccount.path;
|
|
1911
|
-
const declared = (path.startsWith("fields.") &&
|
|
1912
|
-
Object.hasOwn(instrument.fields, path.slice("fields.".length))) ||
|
|
1913
|
-
(path.startsWith("refs.") &&
|
|
1914
|
-
declaredRefKeys(instrument).has(path.slice("refs.".length)));
|
|
1915
|
-
if (!declared) {
|
|
1916
|
-
add([...drainBase, "path"], `requiresDrainedAccount reads ${path}, which is not declared`, "UDL5008");
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
if (definition.signedSum) {
|
|
1920
|
-
const sumBase = [...actionBase, "signedSum"];
|
|
1921
|
-
if (action === "create") {
|
|
1922
|
-
add(sumBase, "signedSum cannot run on create: no parent row exists yet", "UDL5008");
|
|
1923
|
-
}
|
|
1924
|
-
const generatedRefs = [
|
|
1925
|
-
{
|
|
1926
|
-
key: definition.signedSum.amountRef,
|
|
1927
|
-
path: [...sumBase, "amountRef"],
|
|
1928
|
-
},
|
|
1929
|
-
...definition.signedSum.sources.map((source, sourceIndex) => ({
|
|
1930
|
-
key: source.subtotalRef,
|
|
1931
|
-
path: [...sumBase, "sources", sourceIndex, "subtotalRef"],
|
|
1932
|
-
})),
|
|
1933
|
-
];
|
|
1934
|
-
addDuplicateIssues(generatedRefs.map((ref) => ref.key), sumBase, "signed sum ref", add);
|
|
1935
|
-
const existingRefs = new Set([
|
|
1936
|
-
...Object.entries(instrument.actions).flatMap(([candidateAction, candidate]) => [
|
|
1937
|
-
...Object.keys(candidate.captureInput ?? {}),
|
|
1938
|
-
...Object.keys(candidate.captureEngine ?? {}),
|
|
1939
|
-
...[...candidate.steps, ...candidate.moves].flatMap((step) => Object.keys(step.capture ?? {})),
|
|
1940
|
-
...(candidateAction !== action && candidate.signedSum
|
|
1941
|
-
? [
|
|
1942
|
-
candidate.signedSum.amountRef,
|
|
1943
|
-
...candidate.signedSum.sources.map((source) => source.subtotalRef),
|
|
1944
|
-
]
|
|
1945
|
-
: []),
|
|
1946
|
-
]),
|
|
1947
|
-
...quoteRefKeys(instrument),
|
|
1948
|
-
...(instrument.subject ? ["subject"] : []),
|
|
1949
|
-
]);
|
|
1950
|
-
for (const ref of generatedRefs) {
|
|
1951
|
-
if (existingRefs.has(ref.key)) {
|
|
1952
|
-
add(ref.path, `signed sum ref ${ref.key} collides with an existing instrument ref key`, "UDL5008");
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
if (!definition.signedSum.sources.some((source) => source.sign === "add")) {
|
|
1956
|
-
add([...sumBase, "sources"], "signedSum needs at least one add source", "UDL5008");
|
|
1957
|
-
}
|
|
1958
|
-
const parentCurrencies = Object.entries(instrument.fields).filter(([, schema]) => isCurrencySchema(schema));
|
|
1959
|
-
if (parentCurrencies.length !== 1) {
|
|
1960
|
-
add(sumBase, `signedSum parent ${instrument.id} needs exactly one currency field`, "UDL5008");
|
|
1961
|
-
}
|
|
1962
|
-
const sourceKeys = definition.signedSum.sources.map((source) => `${source.instrumentId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`);
|
|
1963
|
-
addDuplicateIssues(sourceKeys, [...sumBase, "sources"], "signed sum source", add);
|
|
1964
|
-
definition.signedSum.sources.forEach((source, sourceIndex) => {
|
|
1965
|
-
for (let priorIndex = 0; priorIndex < sourceIndex; priorIndex += 1) {
|
|
1966
|
-
const prior = definition.signedSum?.sources[priorIndex];
|
|
1967
|
-
if (!prior ||
|
|
1968
|
-
prior.instrumentId !== source.instrumentId ||
|
|
1969
|
-
prior.refField !== source.refField ||
|
|
1970
|
-
prior.amountField !== source.amountField ||
|
|
1971
|
-
prior.sign !== source.sign) {
|
|
1972
|
-
continue;
|
|
1973
|
-
}
|
|
1974
|
-
const priorStatuses = new Set(prior.statuses);
|
|
1975
|
-
const overlap = source.statuses.filter((status) => priorStatuses.has(status));
|
|
1976
|
-
if (overlap.length === 0)
|
|
1977
|
-
continue;
|
|
1978
|
-
add([...sumBase, "sources", sourceIndex, "statuses"], `signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`, "UDL5008");
|
|
1979
|
-
}
|
|
1980
|
-
});
|
|
1981
|
-
definition.signedSum.sources.forEach((source, sourceIndex) => {
|
|
1982
|
-
const sourceBase = [...sumBase, "sources", sourceIndex];
|
|
1983
|
-
const child = instruments.get(source.instrumentId);
|
|
1984
|
-
if (!child) {
|
|
1985
|
-
add([...sourceBase, "instrumentId"], `signedSum references unknown instrument ${source.instrumentId}`, "UDL5008");
|
|
1986
|
-
return;
|
|
1987
|
-
}
|
|
1988
|
-
const refSchema = child.fields[source.refField];
|
|
1989
|
-
if (!refSchema || !references.accepts(refSchema, instrument.idPrefix)) {
|
|
1990
|
-
add([...sourceBase, "refField"], `${source.instrumentId}.${source.refField} must reference ${instrument.id}`, "UDL5008");
|
|
1991
|
-
}
|
|
1992
|
-
const amountSchema = child.fields[source.amountField];
|
|
1993
|
-
if (!amountSchema || !isMoneySchema(amountSchema)) {
|
|
1994
|
-
add([...sourceBase, "amountField"], `${source.instrumentId}.${source.amountField} must be a money field`, "UDL5008");
|
|
1995
|
-
}
|
|
1996
|
-
const childCurrencies = Object.entries(child.fields).filter(([, schema]) => isCurrencySchema(schema));
|
|
1997
|
-
if (childCurrencies.length !== 1) {
|
|
1998
|
-
add([...sourceBase, "instrumentId"], `signedSum source ${source.instrumentId} needs exactly one currency field`, "UDL5008");
|
|
1999
|
-
}
|
|
2000
|
-
addDuplicateIssues(source.statuses, [...sourceBase, "statuses"], "signed sum status", add);
|
|
2001
|
-
source.statuses.forEach((status, statusIndex) => {
|
|
2002
|
-
if (!child.lifecycle.states.includes(status)) {
|
|
2003
|
-
add([...sourceBase, "statuses", statusIndex], `signedSum status ${status} is not declared by ${source.instrumentId}`, "UDL5008");
|
|
2004
|
-
}
|
|
2005
|
-
});
|
|
2006
|
-
});
|
|
2007
|
-
const amountPath = `refs.${definition.signedSum.amountRef}`;
|
|
2008
|
-
const payoutMoves = Object.values(instrument.actions).flatMap((candidate) => candidate.moves.filter((move) => move.operation === "internal_transfer.create" &&
|
|
2009
|
-
move.bind.amount?.from === "instance" &&
|
|
2010
|
-
move.bind.amount.path === amountPath));
|
|
2011
|
-
const payouts = Object.values(instrument.actions).filter((candidate) => candidate.payout?.amount === amountPath);
|
|
2012
|
-
if (payoutMoves.length + payouts.length !== 1) {
|
|
2013
|
-
add(sumBase, `signedSum requires exactly one payout or instrument transfer whose amount is ${amountPath}`, "UDL5008");
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
if (definition.due) {
|
|
2017
|
-
if (definition.publicAction) {
|
|
2018
|
-
add([...actionBase, "publicAction"], "a system due action cannot declare a public action", "UDL3001");
|
|
2019
|
-
}
|
|
2020
|
-
const transition = instrument.lifecycle.transitions[action];
|
|
2021
|
-
if (definition.due.every) {
|
|
2022
|
-
const serialLiability = definition.due.every.liability === "one_open";
|
|
2023
|
-
if (!transition ||
|
|
2024
|
-
(serialLiability
|
|
2025
|
-
? transition.from.includes(transition.to)
|
|
2026
|
-
: !transition.from.includes(transition.to))) {
|
|
2027
|
-
add([...actionBase, "due"], serialLiability
|
|
2028
|
-
? "a one-open recurring due action must leave its source states while the period liability is open"
|
|
2029
|
-
: "a recurring due action must be stationary: its transition's to must name one of its from states", "UDL5008");
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
else if (!transition || transition.from.includes(transition.to)) {
|
|
2033
|
-
add([...actionBase, "due"], "a due action must leave every source state so the maintenance loop fires its anchor exactly once", "UDL3001");
|
|
2034
|
-
}
|
|
2035
|
-
const schema = instrument.fields[definition.due.field];
|
|
2036
|
-
if (!schema) {
|
|
2037
|
-
add([...actionBase, "due", "field"], `due condition references unknown field ${definition.due.field}`, "UDL3001");
|
|
2038
|
-
}
|
|
2039
|
-
else if (!isDateTimeFormat(schema.format)) {
|
|
2040
|
-
add([...actionBase, "due", "field"], "due field must be a date-time field", "UDL5008");
|
|
2041
|
-
}
|
|
2042
|
-
if (typeof definition.due.offset === "string" &&
|
|
2043
|
-
fixedIsoDurationMs(definition.due.offset) === null) {
|
|
2044
|
-
add([...actionBase, "due", "offset"], "due.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
|
|
2045
|
-
}
|
|
2046
|
-
validateDueRecurrence(instrument, action, actionBase, add);
|
|
2047
|
-
}
|
|
2048
|
-
if (definition.deadline) {
|
|
2049
|
-
if (definition.due) {
|
|
2050
|
-
add([...actionBase, "deadline"], "a action cannot declare both a due condition and a deadline", "UDL3001");
|
|
2051
|
-
}
|
|
2052
|
-
const schema = instrument.fields[definition.deadline.field];
|
|
2053
|
-
if (!schema) {
|
|
2054
|
-
add([...actionBase, "deadline", "field"], `deadline references unknown field ${definition.deadline.field}`, "UDL3001");
|
|
2055
|
-
}
|
|
2056
|
-
else if (!isDateTimeFormat(schema.format)) {
|
|
2057
|
-
add([...actionBase, "deadline", "field"], "deadline field must be a date-time field", "UDL5008");
|
|
2058
|
-
}
|
|
2059
|
-
if (typeof definition.deadline.offset === "string" &&
|
|
2060
|
-
fixedIsoDurationMs(definition.deadline.offset) === null) {
|
|
2061
|
-
add([...actionBase, "deadline", "offset"], "deadline.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
if (definition.examples) {
|
|
2065
|
-
addDuplicateIssues(definition.examples.map((example) => example.name), [...actionBase, "examples"], "example name", add);
|
|
2066
|
-
const inputSchema = exampleInputSchema(instrument, action, definition);
|
|
2067
|
-
definition.examples.forEach((example, exampleIndex) => {
|
|
2068
|
-
const input = exampleInputForUdlValidation(instrument, example.input);
|
|
2069
|
-
for (const error of validateUdlSchemaValue(inputSchema, input).errors) {
|
|
2070
|
-
add([...actionBase, "examples", exampleIndex, "input"], `example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`, "UDL5008");
|
|
2071
|
-
}
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
validateRecurringLiabilityOverlap(instrument, base, add);
|
|
2076
|
-
}
|
|
2077
|
-
function validateDueRecurrence(instrument, action, actionBase, add) {
|
|
2078
|
-
const definition = instrument.actions[action];
|
|
2079
|
-
const every = definition?.due?.every;
|
|
2080
|
-
if (!definition || !every)
|
|
2081
|
-
return;
|
|
2082
|
-
const recurrenceBase = [...actionBase, "due", "every"];
|
|
2083
|
-
if (typeof every.period === "string" &&
|
|
2084
|
-
fixedIsoDurationMs(every.period) === null) {
|
|
2085
|
-
add([...recurrenceBase, "period"], "recurrence period must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL3001");
|
|
2086
|
-
}
|
|
2087
|
-
const terminations = [
|
|
2088
|
-
every.countField === undefined ? undefined : "countField",
|
|
2089
|
-
every.untilField === undefined ? undefined : "untilField",
|
|
2090
|
-
every.untilAction === undefined ? undefined : "untilAction",
|
|
2091
|
-
].filter((value) => value !== undefined);
|
|
2092
|
-
if (terminations.length === 0) {
|
|
2093
|
-
add(recurrenceBase, "recurrence must declare a termination using countField, untilField, or untilAction", "UDL3001");
|
|
2094
|
-
}
|
|
2095
|
-
else if (terminations.length > 1) {
|
|
2096
|
-
add(recurrenceBase, `recurrence termination is ambiguous: ${terminations.join(", ")}`, "UDL3001");
|
|
2097
|
-
}
|
|
2098
|
-
if ((every.untilField !== undefined || every.untilAction !== undefined) &&
|
|
2099
|
-
every.liability !== "one_open") {
|
|
2100
|
-
add([...recurrenceBase, "liability"], "open-ended recurrence must declare liability one_open", "UDL3001");
|
|
2101
|
-
}
|
|
2102
|
-
if (every.liability === "one_open" && every.delinquency !== "parent_policy") {
|
|
2103
|
-
add([...recurrenceBase, "delinquency"], "one-open recurrence must reuse delinquency parent_policy", "UDL3001");
|
|
2104
|
-
}
|
|
2105
|
-
if ((every.untilField !== undefined || every.untilAction !== undefined) &&
|
|
2106
|
-
every.drainAction === undefined) {
|
|
2107
|
-
add([...recurrenceBase, "drainAction"], "open-ended recurrence must declare its drain action", "UDL3001");
|
|
2108
|
-
}
|
|
2109
|
-
if (every.countField) {
|
|
2110
|
-
const count = instrument.fields[every.countField];
|
|
2111
|
-
if (!count) {
|
|
2112
|
-
add([...recurrenceBase, "countField"], `recurrence count references unknown field ${every.countField}`, "UDL3001");
|
|
2113
|
-
}
|
|
2114
|
-
else if (count.type !== "integer") {
|
|
2115
|
-
add([...recurrenceBase, "countField"], "recurrence count field must be an integer field", "UDL3001");
|
|
2116
|
-
}
|
|
2117
|
-
else if (!instrument.required.includes(every.countField)) {
|
|
2118
|
-
add([...recurrenceBase, "countField"], "recurrence count field must be required", "UDL3001");
|
|
2119
|
-
}
|
|
2120
|
-
else if (instrument.update?.fields.includes(every.countField)) {
|
|
2121
|
-
add([...recurrenceBase, "countField"], "recurrence count field cannot be mutable", "UDL3001");
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
if (every.untilField) {
|
|
2125
|
-
const until = instrument.fields[every.untilField];
|
|
2126
|
-
if (!until) {
|
|
2127
|
-
add([...recurrenceBase, "untilField"], `recurrence termination references unknown field ${every.untilField}`, "UDL3001");
|
|
2128
|
-
}
|
|
2129
|
-
else if (!isDateTimeFormat(until.format)) {
|
|
2130
|
-
add([...recurrenceBase, "untilField"], "recurrence termination field must be a date-time field", "UDL3001");
|
|
2131
|
-
}
|
|
2132
|
-
else if (!instrument.required.includes(every.untilField)) {
|
|
2133
|
-
add([...recurrenceBase, "untilField"], "recurrence termination field must be required", "UDL3001");
|
|
2134
|
-
}
|
|
2135
|
-
else if (instrument.update?.fields.includes(every.untilField)) {
|
|
2136
|
-
add([...recurrenceBase, "untilField"], "recurrence termination field cannot be mutable", "UDL3001");
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
if (every.liability === "one_open" &&
|
|
2140
|
-
(definition.moves.length > 0 || definition.payout !== undefined)) {
|
|
2141
|
-
add([...actionBase, "moves"], "a recurring due action cannot move money; it may only open one period or invoke a declared system action", "UDL3001");
|
|
2142
|
-
}
|
|
2143
|
-
if (every.untilAction) {
|
|
2144
|
-
if (every.drainAction && every.drainAction !== every.untilAction) {
|
|
2145
|
-
add([...recurrenceBase, "drainAction"], "port recurrence termination must use untilAction as its drainAction", "UDL3001");
|
|
2146
|
-
}
|
|
2147
|
-
validateRecurrenceTerminationAction(instrument, action, every.untilAction, recurrenceBase, add);
|
|
2148
|
-
}
|
|
2149
|
-
else if (every.untilField) {
|
|
2150
|
-
const drainAction = every.drainAction;
|
|
2151
|
-
const drainDefinition = drainAction
|
|
2152
|
-
? instrument.actions[drainAction]
|
|
2153
|
-
: undefined;
|
|
2154
|
-
if (drainAction &&
|
|
2155
|
-
(!drainDefinition ||
|
|
2156
|
-
drainDefinition.due?.every !== undefined ||
|
|
2157
|
-
drainDefinition.due?.field !== every.untilField ||
|
|
2158
|
-
!recurrenceActionExits(instrument, action, drainAction))) {
|
|
2159
|
-
add([...recurrenceBase, "drainAction"], `stored-date recurrence drain action ${drainAction} must be a one-shot due action on ${every.untilField} that exits every recurring state`, "UDL3001");
|
|
2160
|
-
}
|
|
2161
|
-
else if (drainAction &&
|
|
2162
|
-
drainDefinition &&
|
|
2163
|
-
instrumentOwnsReservation(instrument) &&
|
|
2164
|
-
!actionDrains(drainDefinition)) {
|
|
2165
|
-
add([...actionPath(recurrenceBase, drainAction), "moves"], `recurrence termination action ${drainAction} must drain its reserved money`, "UDL3001");
|
|
2166
|
-
}
|
|
2167
|
-
if (drainAction &&
|
|
2168
|
-
drainDefinition &&
|
|
2169
|
-
(drainDefinition.payout !== undefined ||
|
|
2170
|
-
drainDefinition.moves.some((move) => move.operation !== "internal_transfer.void"))) {
|
|
2171
|
-
add([...actionPath(recurrenceBase, drainAction), "moves"], "a stored-date recurrence termination may only void reserved money; no timeout creates, reserves, posts, or pays out money", "UDL3001");
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
function validateRecurrenceTerminationAction(instrument, recurringAction, terminationAction, recurrenceBase, add) {
|
|
2176
|
-
const definition = instrument.actions[terminationAction];
|
|
2177
|
-
const terminationPath = actionPath(recurrenceBase, terminationAction);
|
|
2178
|
-
if (!definition) {
|
|
2179
|
-
add([...recurrenceBase, "untilAction"], `recurrence termination references unknown action ${terminationAction}`, "UDL3001");
|
|
2180
|
-
return;
|
|
2181
|
-
}
|
|
2182
|
-
if (!definition.port) {
|
|
2183
|
-
add([...recurrenceBase, "untilAction"], `recurrence termination action ${terminationAction} must declare a port`, "UDL3001");
|
|
2184
|
-
}
|
|
2185
|
-
if (!recurrenceActionExits(instrument, recurringAction, terminationAction)) {
|
|
2186
|
-
add([...recurrenceBase, "untilAction"], `recurrence termination action ${terminationAction} must exit every recurring source state`, "UDL3001");
|
|
2187
|
-
}
|
|
2188
|
-
if (instrumentOwnsReservation(instrument) && !actionDrains(definition)) {
|
|
2189
|
-
add([...terminationPath, "moves"], `recurrence termination action ${terminationAction} must drain its reserved money`, "UDL3001");
|
|
2190
|
-
}
|
|
2191
|
-
}
|
|
2192
|
-
function actionPath(recurrenceBase, action) {
|
|
2193
|
-
return [...recurrenceBase.slice(0, -3), action];
|
|
2194
|
-
}
|
|
2195
|
-
function recurrenceActionExits(instrument, recurringAction, terminationAction) {
|
|
2196
|
-
const recurring = instrument.lifecycle.transitions[recurringAction];
|
|
2197
|
-
const termination = instrument.lifecycle.transitions[terminationAction];
|
|
2198
|
-
if (!recurring || !termination)
|
|
2199
|
-
return false;
|
|
2200
|
-
const recurringStates = new Set([...recurring.from, recurring.to]);
|
|
2201
|
-
return ([...recurringStates].every((state) => termination.from.includes(state)) &&
|
|
2202
|
-
!recurringStates.has(termination.to));
|
|
2203
|
-
}
|
|
2204
|
-
function actionDrains(definition) {
|
|
2205
|
-
return (definition.requiresDrainedAccount !== undefined ||
|
|
2206
|
-
definition.moves.some((move) => move.operation === "internal_transfer.void"));
|
|
2207
|
-
}
|
|
2208
|
-
function instrumentOwnsReservation(instrument) {
|
|
2209
|
-
return Object.values(instrument.actions).some((definition) => definition.moves.some((move) => move.operation === "internal_transfer.reserve"));
|
|
2210
|
-
}
|
|
2211
|
-
function validateRecurringLiabilityOverlap(instrument, base, add) {
|
|
2212
|
-
const recurring = Object.entries(instrument.actions).filter(([, definition]) => definition.due?.every?.liability === "one_open");
|
|
2213
|
-
for (const [index, [action, definition]] of recurring.entries()) {
|
|
2214
|
-
const transition = instrument.lifecycle.transitions[action];
|
|
2215
|
-
if (!transition || !definition.due)
|
|
2216
|
-
continue;
|
|
2217
|
-
for (let priorIndex = 0; priorIndex < index; priorIndex += 1) {
|
|
2218
|
-
const [priorAction, priorDefinition] = recurring[priorIndex];
|
|
2219
|
-
const priorTransition = instrument.lifecycle.transitions[priorAction];
|
|
2220
|
-
if (!priorTransition ||
|
|
2221
|
-
priorDefinition.due?.field !== definition.due.field) {
|
|
2222
|
-
continue;
|
|
2223
|
-
}
|
|
2224
|
-
const priorStates = new Set(priorTransition.from);
|
|
2225
|
-
const overlap = transition.from.filter((state) => priorStates.has(state));
|
|
2226
|
-
if (overlap.length === 0)
|
|
2227
|
-
continue;
|
|
2228
|
-
add([...base, "actions", action, "due", "every"], `recurring due actions ${priorAction} and ${action} overlap period liability in states ${overlap.join(", ")}`, "UDL3001");
|
|
12
|
+
/** Reject cycles and oversized structures before the schema walks caller data. */
|
|
13
|
+
function bounded(value) {
|
|
14
|
+
const active = new Set();
|
|
15
|
+
let nodes = 0;
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
const visit = (v, depth) => {
|
|
18
|
+
if (++nodes > 100000 || depth > 32)
|
|
19
|
+
return false;
|
|
20
|
+
if (typeof v === "string") {
|
|
21
|
+
bytes += v.length;
|
|
22
|
+
return bytes <= 1048576;
|
|
2229
23
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
24
|
+
if (v === null || typeof v === "boolean")
|
|
25
|
+
return true;
|
|
26
|
+
if (typeof v === "number")
|
|
27
|
+
return Number.isSafeInteger(v);
|
|
28
|
+
if (typeof v !== "object" || active.has(v))
|
|
29
|
+
return false;
|
|
30
|
+
if (!Array.isArray(v) &&
|
|
31
|
+
Object.getPrototypeOf(v) !== Object.prototype &&
|
|
32
|
+
Object.getPrototypeOf(v) !== null)
|
|
33
|
+
return false;
|
|
34
|
+
active.add(v);
|
|
35
|
+
const ok = Object.entries(v).every(([key, entry]) => key.length <= 240 && visit(entry, depth + 1));
|
|
36
|
+
active.delete(v);
|
|
37
|
+
return ok;
|
|
38
|
+
};
|
|
39
|
+
return visit(value, 0);
|
|
40
|
+
}
|
|
41
|
+
const targetIds = (target) => typeof target === "string" ? [target] : target;
|
|
42
|
+
const overlap = (a, b) => targetIds(a).some((id) => targetIds(b).includes(id));
|
|
43
|
+
/** Union paths retain only fields with compatible types on every member. */
|
|
44
|
+
function commonField(fields) {
|
|
45
|
+
const first = fields[0];
|
|
46
|
+
if (!first ||
|
|
47
|
+
fields.some((field) => !field ||
|
|
48
|
+
field.type !== first.type ||
|
|
49
|
+
field.optional !== first.optional))
|
|
2236
50
|
return;
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
if (
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
if (!instrument.required.includes(clause.boundField)) {
|
|
2251
|
-
add([...clauseBase, "boundField"], `decided amount bound ${clause.boundField} must be required`, "UDL2002");
|
|
2252
|
-
}
|
|
2253
|
-
if (instrument.update?.fields.includes(clause.boundField)) {
|
|
2254
|
-
add([...clauseBase, "boundField"], `decided amount bound ${clause.boundField} cannot be mutable`, "UDL5008");
|
|
2255
|
-
}
|
|
2256
|
-
const decidedMoves = definition.moves.filter((move) => move.operation === "internal_transfer.post" &&
|
|
2257
|
-
move.bind.amount?.from === "input" &&
|
|
2258
|
-
move.bind.amount.path === clause.field);
|
|
2259
|
-
if (decidedMoves.length !== 1) {
|
|
2260
|
-
add([...clauseBase, "field"], `decided amount field ${clause.field} must fund exactly one internal_transfer.post move`, "UDL4001");
|
|
2261
|
-
}
|
|
2262
|
-
const decidedMove = decidedMoves.length === 1 ? decidedMoves[0] : undefined;
|
|
2263
|
-
if (decidedMove) {
|
|
2264
|
-
const moveIndex = definition.moves.indexOf(decidedMove);
|
|
2265
|
-
const postMode = decidedMove.bind.postMode;
|
|
2266
|
-
if (postMode?.from !== "const" || postMode.value !== "partial_only") {
|
|
2267
|
-
add([...actionBase, "moves", moveIndex, "bind", "postMode"], "decided amount post must use partial_only so the remainder stays reserved", "UDL5008");
|
|
2268
|
-
}
|
|
2269
|
-
const currencyFields = Object.entries(instrument.fields).filter(([, schema]) => isCurrencySchema(schema));
|
|
2270
|
-
const currency = decidedMove.bind.currency;
|
|
2271
|
-
if (currencyFields.length !== 1 ||
|
|
2272
|
-
currency?.from !== "instance" ||
|
|
2273
|
-
currency.path !== `fields.${currencyFields[0]?.[0]}`) {
|
|
2274
|
-
add([...actionBase, "moves", moveIndex, "bind", "currency"], "decided amount post must bind the instrument currency", "UDL5008");
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
const remainderMoves = definition.moves.filter((move) => move.key === "remainder");
|
|
2278
|
-
if (remainderMoves.length !== 1) {
|
|
2279
|
-
add([...actionBase, "moves"], `decided amount action must declare exactly one remainder move; found ${remainderMoves.length}`, "UDL5008");
|
|
2280
|
-
}
|
|
2281
|
-
const remainderMove = remainderMoves.length === 1 ? remainderMoves[0] : undefined;
|
|
2282
|
-
if (remainderMove && remainderMove.operation !== "internal_transfer.void") {
|
|
2283
|
-
add([
|
|
2284
|
-
...actionBase,
|
|
2285
|
-
"moves",
|
|
2286
|
-
definition.moves.indexOf(remainderMove),
|
|
2287
|
-
"operation",
|
|
2288
|
-
], "decided amount remainder move must drain the reservation with internal_transfer.void", "UDL4001");
|
|
2289
|
-
}
|
|
2290
|
-
const remainderDefinition = instrument.actions[clause.remainderAction];
|
|
2291
|
-
if (!remainderDefinition) {
|
|
2292
|
-
add([...clauseBase, "remainderAction"], `decided amount remainder action ${clause.remainderAction} is not declared`, "UDL4001");
|
|
51
|
+
if (first.type === "ref") {
|
|
52
|
+
const targets = [
|
|
53
|
+
...new Set(fields.flatMap((field) => field?.type === "ref" ? targetIds(field.target) : [])),
|
|
54
|
+
];
|
|
55
|
+
return { ...first, target: targets.length === 1 ? targets[0] : targets };
|
|
56
|
+
}
|
|
57
|
+
if (first.type === "account" &&
|
|
58
|
+
fields.some((field) => field?.type !== "account" ||
|
|
59
|
+
field.book !== first.book ||
|
|
60
|
+
field.owner !== first.owner ||
|
|
61
|
+
field.key !== first.key ||
|
|
62
|
+
field.external !== first.external ||
|
|
63
|
+
field.contra !== first.contra))
|
|
2293
64
|
return;
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
const transition = instrument.lifecycle.transitions[action];
|
|
2299
|
-
const remainderTransition = instrument.lifecycle.transitions[clause.remainderAction];
|
|
2300
|
-
if (transition &&
|
|
2301
|
-
remainderTransition &&
|
|
2302
|
-
(transition.from.length !== remainderTransition.from.length ||
|
|
2303
|
-
transition.from.some((state) => !remainderTransition.from.includes(state)))) {
|
|
2304
|
-
add([...clauseBase, "remainderAction"], `decided amount remainder action ${clause.remainderAction} must start from the same lifecycle states as ${action}`, "UDL4001");
|
|
2305
|
-
}
|
|
2306
|
-
const namedDrains = remainderDefinition.moves.filter((move) => move.operation === "internal_transfer.void");
|
|
2307
|
-
if (namedDrains.length !== 1 || remainderDefinition.moves.length !== 1) {
|
|
2308
|
-
add([...actionBase.slice(0, -1), clause.remainderAction, "moves"], `decided amount remainder action ${clause.remainderAction} must declare exactly one reservation drain; found ${namedDrains.length}`, "UDL4001");
|
|
2309
|
-
}
|
|
2310
|
-
const namedDrain = namedDrains.length === 1 && remainderDefinition.moves.length === 1
|
|
2311
|
-
? namedDrains[0]
|
|
2312
|
-
: undefined;
|
|
2313
|
-
if (!remainderMove || !namedDrain)
|
|
65
|
+
if (first.type === "enum" &&
|
|
66
|
+
fields.some((field) => field?.type !== "enum" ||
|
|
67
|
+
field.values.length !== first.values.length ||
|
|
68
|
+
field.values.some((value) => !first.values.includes(value))))
|
|
2314
69
|
return;
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
if (payoutEntries.length > 0 && reconcilingEntries.length !== 1) {
|
|
2333
|
-
add([...base, "actions"], `payout-owning instrument must declare exactly one reconciling action; found ${reconcilingEntries.length}`, "UDL5005");
|
|
2334
|
-
}
|
|
2335
|
-
if (payoutEntries.length > 0) {
|
|
2336
|
-
// The bank's own line is the only evidence that a payout left the estate.
|
|
2337
|
-
// A provider confirmation is our row read back to us, and a credit is
|
|
2338
|
-
// money arriving, so neither closes a payout. The match law stays the
|
|
2339
|
-
// author's call.
|
|
2340
|
-
for (const [action, definition] of reconcilingEntries) {
|
|
2341
|
-
(definition.reconcile ?? []).forEach((reconcile, index) => {
|
|
2342
|
-
if (reconcile.evidence === "statement_line" &&
|
|
2343
|
-
reconcile.direction === "debit") {
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
add([...base, "actions", action, "reconcile", index, "evidence"], `payout-owning instrument ${instrument.id} action ${action} must reconcile against a debit statement_line; found ${reconcile.direction} ${reconcile.evidence}`, "UDL5005");
|
|
2347
|
-
});
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
const soleReconciles = reconcilingEntries.length === 1
|
|
2351
|
-
? reconcilingEntries[0]?.[1].reconcile
|
|
2352
|
-
: undefined;
|
|
2353
|
-
if (soleReconciles) {
|
|
2354
|
-
const expected = new Set(soleReconciles.map((reconcile) => reconcile.counterpartyRef));
|
|
2355
|
-
for (const [action, definition] of payoutEntries) {
|
|
2356
|
-
if (definition.payout && expected.has(definition.payout.capture)) {
|
|
2357
|
-
continue;
|
|
2358
|
-
}
|
|
2359
|
-
add([...base, "actions", action, "payout", "capture"], `action ${action} payout capture ${definition.payout?.capture} is expected by no reconcile`, "UDL5005");
|
|
2360
|
-
}
|
|
2361
|
-
}
|
|
2362
|
-
const reservedRefs = new Set([
|
|
2363
|
-
...Object.values(instrument.actions).flatMap((action) => [
|
|
2364
|
-
...Object.keys(action.captureInput ?? {}),
|
|
2365
|
-
...Object.keys(action.captureEngine ?? {}),
|
|
2366
|
-
]),
|
|
2367
|
-
...Object.values(instrument.actions).flatMap((action) => [...action.steps, ...action.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
|
|
2368
|
-
...Object.values(instrument.actions).flatMap((action) => action.signedSum
|
|
2369
|
-
? [
|
|
2370
|
-
action.signedSum.amountRef,
|
|
2371
|
-
...action.signedSum.sources.map((source) => source.subtotalRef),
|
|
2372
|
-
]
|
|
2373
|
-
: []),
|
|
2374
|
-
...Object.values(instrument.actions).flatMap((action) => action.distribute ? [action.distribute.amountRef] : []),
|
|
2375
|
-
...Object.values(instrument.actions).flatMap((action) => action.remainder
|
|
2376
|
-
? [
|
|
2377
|
-
action.remainder.amountRef,
|
|
2378
|
-
...(action.remainder.accumulateRef
|
|
2379
|
-
? [action.remainder.accumulateRef]
|
|
2380
|
-
: []),
|
|
2381
|
-
]
|
|
2382
|
-
: []),
|
|
2383
|
-
...Object.values(instrument.actions).flatMap((action) => action.allocate ? [action.allocate.capture] : []),
|
|
2384
|
-
...quoteRefKeys(instrument),
|
|
2385
|
-
...(instrument.subject ? ["subject"] : []),
|
|
2386
|
-
]);
|
|
2387
|
-
const moneyWriters = moneyRefWriters(instrument);
|
|
2388
|
-
const payoutWriters = new Map();
|
|
2389
|
-
for (const [action, definition] of Object.entries(instrument.actions)) {
|
|
2390
|
-
if (!definition.payout)
|
|
2391
|
-
continue;
|
|
2392
|
-
const payout = definition.payout;
|
|
2393
|
-
const payoutBase = [...base, "actions", action, "payout"];
|
|
2394
|
-
if (action === "create") {
|
|
2395
|
-
add(payoutBase, "create cannot declare a payout intent", "UDL5005");
|
|
2396
|
-
}
|
|
2397
|
-
if (definition.steps.length > 0 || definition.moves.length > 0) {
|
|
2398
|
-
add(payoutBase, `action ${action} payout intent cannot combine with kernel steps or moves`, "UDL5005");
|
|
2399
|
-
}
|
|
2400
|
-
const prior = payoutWriters.get(payout.capture) ?? [];
|
|
2401
|
-
if (reservedRefs.has(payout.capture) || prior.length > 0) {
|
|
2402
|
-
add([...payoutBase, "capture"], `payout capture ${payout.capture} collides with an existing instrument ref key`, "UDL5005");
|
|
2403
|
-
}
|
|
2404
|
-
payoutWriters.set(payout.capture, [...prior, action]);
|
|
2405
|
-
const amount = payout.amount;
|
|
2406
|
-
const [scope, key] = amount.split(".");
|
|
2407
|
-
const amountWriters = moneyWriters.get(key) ?? [];
|
|
2408
|
-
const amountDeclared = scope === "fields"
|
|
2409
|
-
? isMoneySchema(instrument.fields[key] ?? {})
|
|
2410
|
-
: scope === "refs" && amountWriters.length > 0;
|
|
2411
|
-
if (!amountDeclared) {
|
|
2412
|
-
add([...payoutBase, "amount"], `payout amount ${amount} must name a declared money field or money ref`, "UDL5005");
|
|
2413
|
-
}
|
|
2414
|
-
else if (scope === "refs" &&
|
|
2415
|
-
!amountWriters.includes(action) &&
|
|
2416
|
-
!amountWriters.includes("create") &&
|
|
2417
|
-
!actionGroupDominatesReader(instrument, amountWriters, action)) {
|
|
2418
|
-
add([...payoutBase, "amount"], `payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`, "UDL5005");
|
|
2419
|
-
}
|
|
2420
|
-
const currency = instrument.fields[payout.currencyField];
|
|
2421
|
-
if (!currency || !isCurrencySchema(currency)) {
|
|
2422
|
-
add([...payoutBase, "currencyField"], `payout currencyField ${payout.currencyField} must name a currency field`, "UDL5005");
|
|
2423
|
-
}
|
|
2424
|
-
const source = instrument.fields[payout.sourceAccountField];
|
|
2425
|
-
if (!source || !references.accepts(source, "acct")) {
|
|
2426
|
-
add([...payoutBase, "sourceAccountField"], `payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`, "UDL5005");
|
|
2427
|
-
}
|
|
2428
|
-
const beneficiary = instrument.fields[payout.beneficiaryField];
|
|
2429
|
-
if (!beneficiary || !references.accepts(beneficiary, "ben")) {
|
|
2430
|
-
add([...payoutBase, "beneficiaryField"], `payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`, "UDL5005");
|
|
2431
|
-
}
|
|
2432
|
-
const beneficiaryPartyField = instrument.parties?.beneficiary;
|
|
2433
|
-
if (!beneficiaryPartyField) {
|
|
2434
|
-
add([...payoutBase, "beneficiaryPartyField"], "payout requires parties.beneficiary to bind its destination party", "UDL5005");
|
|
2435
|
-
}
|
|
2436
|
-
else if (payout.beneficiaryPartyField !== beneficiaryPartyField) {
|
|
2437
|
-
add([...payoutBase, "beneficiaryPartyField"], `payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`, "UDL5005");
|
|
2438
|
-
}
|
|
2439
|
-
}
|
|
2440
|
-
validateReconciles(instrument, base, instruments, reservedRefs, payoutWriters, moneyWriters, references, add);
|
|
2441
|
-
}
|
|
2442
|
-
/**
|
|
2443
|
-
* The five reconcile laws. An expectation names money the instrument can
|
|
2444
|
-
* describe but cannot yet see, so every part of it has to be checkable before
|
|
2445
|
-
* an instance exists: the expected fields carry money, one source answers one
|
|
2446
|
-
* counterparty row, the window closes under a sweep that is actually declared,
|
|
2447
|
-
* the forgiveness is bounded by a dial, and the unmatched case has a capped
|
|
2448
|
-
* child to land in.
|
|
2449
|
-
*/
|
|
2450
|
-
function validateReconciles(instrument, base, instruments, reservedRefs, payoutWriters, moneyWriters, references, add) {
|
|
2451
|
-
const captures = new Set();
|
|
2452
|
-
for (const [action, definition] of Object.entries(instrument.actions)) {
|
|
2453
|
-
if (!definition.reconcile)
|
|
2454
|
-
continue;
|
|
2455
|
-
const counterparties = new Set();
|
|
2456
|
-
definition.reconcile.forEach((reconcile, index) => {
|
|
2457
|
-
const path = [...base, "actions", action, "reconcile", index];
|
|
2458
|
-
// Law: the expectation is money this instrument can already describe,
|
|
2459
|
-
// read the same way a payout intent reads its amount.
|
|
2460
|
-
const [amountScope, amountKey] = reconcile.amount.split(".");
|
|
2461
|
-
const amountDeclared = amountScope === "fields"
|
|
2462
|
-
? isMoneySchema(instrument.fields[amountKey] ?? {})
|
|
2463
|
-
: amountScope === "refs" &&
|
|
2464
|
-
(moneyWriters.get(amountKey) ?? []).length > 0;
|
|
2465
|
-
if (!amountDeclared) {
|
|
2466
|
-
add([...path, "amount"], `reconcile amount ${reconcile.amount} must name a declared money field or money ref`, "UDL5005");
|
|
2467
|
-
}
|
|
2468
|
-
const currency = instrument.fields[reconcile.currencyField];
|
|
2469
|
-
if (!currency) {
|
|
2470
|
-
add([...path, "currencyField"], `reconcile expects unknown field ${reconcile.currencyField}`, "UDL5005");
|
|
2471
|
-
}
|
|
2472
|
-
else if (currency.type !== "string" ||
|
|
2473
|
-
!instrument.required.includes(reconcile.currencyField)) {
|
|
2474
|
-
add([...path, "currencyField"], `${instrument.id}.${reconcile.currencyField} must be a required text field to carry the expected currency`, "UDL5005");
|
|
2475
|
-
}
|
|
2476
|
-
// Law: one evidence source answers one counterparty row. Two
|
|
2477
|
-
// expectations over the same row would match the same money twice.
|
|
2478
|
-
if (counterparties.has(reconcile.counterpartyRef)) {
|
|
2479
|
-
add([...path, "counterpartyRef"], `action ${action} expects ${reconcile.counterpartyRef} twice; one counterparty row answers one reconcile`, "UDL5005");
|
|
2480
|
-
}
|
|
2481
|
-
counterparties.add(reconcile.counterpartyRef);
|
|
2482
|
-
const writers = payoutWriters.get(reconcile.counterpartyRef) ?? [];
|
|
2483
|
-
if (reconcile.evidence === "statement_line") {
|
|
2484
|
-
if (writers.length === 0) {
|
|
2485
|
-
add([...path, "counterpartyRef"], `reconcile counterpartyRef ${reconcile.counterpartyRef} is not captured by a payout intent`, "UDL5005");
|
|
2486
|
-
}
|
|
2487
|
-
else if (!actionGroupDominatesReader(instrument, writers, action)) {
|
|
2488
|
-
add(path, `reconcile can read ${reconcile.counterpartyRef} before payout writers ${writers.join(" or ")}`, "UDL5005");
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
// Law: the window is a fixed duration or a stored deadline, and the due
|
|
2492
|
-
// sweep that closes it is declared on the same action. A window nothing
|
|
2493
|
-
// fires is a wait with no end.
|
|
2494
|
-
const due = definition.due;
|
|
2495
|
-
if ("offset" in reconcile.within) {
|
|
2496
|
-
const offset = reconcile.within.offset;
|
|
2497
|
-
if (fixedIsoDurationMs(offset) === null) {
|
|
2498
|
-
add([...path, "within", "offset"], `reconcile window ${offset} is not a fixed ISO-8601 duration`, "UDL5005");
|
|
2499
|
-
}
|
|
2500
|
-
else if (!due || due.offset !== offset) {
|
|
2501
|
-
add([...path, "within", "offset"], `reconcile window ${offset} is closed by no due condition on ${action}`, "UDL5005");
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
else {
|
|
2505
|
-
const deadline = instrument.fields[reconcile.within.field];
|
|
2506
|
-
if (!deadline || !isDateTimeFormat(deadline.format)) {
|
|
2507
|
-
add([...path, "within", "field"], `reconcile window ${reconcile.within.field} must be a stored date-time field`, "UDL5005");
|
|
2508
|
-
}
|
|
2509
|
-
else if (!due ||
|
|
2510
|
-
due.field !== reconcile.within.field ||
|
|
2511
|
-
due.offset !== undefined) {
|
|
2512
|
-
add([...path, "within", "field"], `reconcile window ${reconcile.within.field} is closed by no due condition on ${action}`, "UDL5005");
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
// Law: tolerance never exceeds its dial. The authored number is the
|
|
2516
|
-
// forgiveness; the dial is the ceiling a tenant may not raise past.
|
|
2517
|
-
const match = reconcile.match;
|
|
2518
|
-
if (match.law === "tolerance") {
|
|
2519
|
-
const dial = instrument.dials?.find((candidate) => candidate.key === match.dial);
|
|
2520
|
-
if (!dial || dial.kind !== "reconcile_tolerance") {
|
|
2521
|
-
add([...path, "match", "dial"], `reconcile tolerance names no reconcile_tolerance dial ${match.dial}`, "UDL5005");
|
|
2522
|
-
}
|
|
2523
|
-
else if (match.minorUnits > dial.maxMinorUnits) {
|
|
2524
|
-
add([...path, "match", "minorUnits"], `reconcile tolerance ${match.minorUnits} exceeds dial ${dial.key} ceiling ${dial.maxMinorUnits}`, "UDL5005");
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
// Law: matched or exception. The break lands in a child of this
|
|
2528
|
-
// instrument that points back at it, never in a status nobody reads.
|
|
2529
|
-
const child = instruments.get(reconcile.exception.childInstrumentId);
|
|
2530
|
-
if (!child) {
|
|
2531
|
-
add([...path, "exception", "childInstrumentId"], `reconcile raises unknown exception instrument ${reconcile.exception.childInstrumentId}`, "UDL5007");
|
|
2532
|
-
}
|
|
2533
|
-
else {
|
|
2534
|
-
const exception = reconcile.exception;
|
|
2535
|
-
for (const problem of reconcileExceptionChildProblems(instrument.idPrefix, child, exception, references)) {
|
|
2536
|
-
const field = problem === "UDL5009" || problem === "UDL5010"
|
|
2537
|
-
? "amountField"
|
|
2538
|
-
: problem === "UDL5011" || problem === "UDL5012"
|
|
2539
|
-
? "reasonField"
|
|
2540
|
-
: "refField";
|
|
2541
|
-
add([...path, "exception", field], reconcileExceptionProblemMessage(problem, instrument, child, exception), problem);
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
if (reservedRefs.has(reconcile.capture) ||
|
|
2545
|
-
payoutWriters.has(reconcile.capture) ||
|
|
2546
|
-
captures.has(reconcile.capture)) {
|
|
2547
|
-
add([...path, "capture"], `reconcile capture ${reconcile.capture} collides with an existing instrument ref key`, "UDL5005");
|
|
2548
|
-
}
|
|
2549
|
-
captures.add(reconcile.capture);
|
|
2550
|
-
});
|
|
2551
|
-
if (!instrument.lifecycle.transitions[action]) {
|
|
2552
|
-
add([...base, "actions", action, "reconcile"], "a reconciling action needs a lifecycle transition and cannot run on create", "UDL5005");
|
|
2553
|
-
}
|
|
2554
|
-
const callerFacets = [
|
|
2555
|
-
definition.port ? "port" : undefined,
|
|
2556
|
-
definition.publicAction ? "publicAction" : undefined,
|
|
2557
|
-
definition.input ? "input" : undefined,
|
|
2558
|
-
definition.captureInput ? "captureInput" : undefined,
|
|
2559
|
-
definition.deadline ? "deadline" : undefined,
|
|
2560
|
-
].filter((facet) => facet !== undefined);
|
|
2561
|
-
for (const facet of callerFacets) {
|
|
2562
|
-
add([...base, "actions", action, facet], `a reconciling action is system-only and cannot declare ${facet}`, "UDL5005");
|
|
2563
|
-
}
|
|
2564
|
-
if (definition.steps.length > 0) {
|
|
2565
|
-
add([...base, "actions", action, "steps"], "a reconciling action cannot add kernel steps", "UDL5005");
|
|
2566
|
-
}
|
|
2567
|
-
if (definition.moves.length > 0) {
|
|
2568
|
-
add([...base, "actions", action, "moves"], "a reconciling action cannot move money", "UDL5005");
|
|
2569
|
-
}
|
|
2570
|
-
}
|
|
2571
|
-
}
|
|
2572
|
-
function moneyRefWriters(instrument) {
|
|
2573
|
-
const writers = new Map();
|
|
2574
|
-
for (const [actionName, action] of Object.entries(instrument.actions)) {
|
|
2575
|
-
const refs = [
|
|
2576
|
-
...(action.signedSum
|
|
2577
|
-
? [
|
|
2578
|
-
action.signedSum.amountRef,
|
|
2579
|
-
...action.signedSum.sources.map((source) => source.subtotalRef),
|
|
2580
|
-
]
|
|
2581
|
-
: []),
|
|
2582
|
-
...[...action.steps, ...action.moves].flatMap((step) => Object.entries(step.capture ?? {}).flatMap(([ref, output]) => output === "postedAmount" ? [ref] : [])),
|
|
2583
|
-
];
|
|
2584
|
-
for (const ref of refs) {
|
|
2585
|
-
writers.set(ref, [...(writers.get(ref) ?? []), actionName]);
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
return writers;
|
|
2589
|
-
}
|
|
2590
|
-
/**
|
|
2591
|
-
* Shape rules for one resolved reference gate: derived bindings and the
|
|
2592
|
-
* uniqueness claim are create-only, bind keys must be declared immutable
|
|
2593
|
-
* fields, and every referenced path must name declared target structure. The
|
|
2594
|
-
* exact mirror of the Hyperscale contract registry's rules, so a document that loads
|
|
2595
|
-
* open-grammar clean also loads registry clean.
|
|
2596
|
-
*/
|
|
2597
|
-
function validateGateShape(instrument, action, gate, target, base, add) {
|
|
2598
|
-
if (gate.unique && action !== "create") {
|
|
2599
|
-
add([...base, "unique"], "unique is a create admission gate", "UDL5001");
|
|
2600
|
-
}
|
|
2601
|
-
if (gate.bind && action !== "create") {
|
|
2602
|
-
add([...base, "bind"], "bind derives fields at create only", "UDL5001");
|
|
2603
|
-
}
|
|
2604
|
-
if (gate.bind && Object.keys(gate.bind).length === 0) {
|
|
2605
|
-
add([...base, "bind"], "bind must not be empty", "UDL5001");
|
|
2606
|
-
}
|
|
2607
|
-
if (gate.match && Object.keys(gate.match).length === 0) {
|
|
2608
|
-
add([...base, "match"], "match must not be empty", "UDL5001");
|
|
2609
|
-
}
|
|
2610
|
-
if (gate.optional && instrument.required.includes(gate.field)) {
|
|
2611
|
-
add([...base, "optional"], `optional declares an opt-out on ${gate.field}, which required lists`, "UDL5001");
|
|
2612
|
-
}
|
|
2613
|
-
for (const [key, path] of Object.entries(gate.bind ?? {})) {
|
|
2614
|
-
if (gate.optional && instrument.required.includes(key)) {
|
|
2615
|
-
add([...base, "bind", key], `an optional reference can only bind optional fields; required lists ${key}`, "UDL5001");
|
|
2616
|
-
}
|
|
2617
|
-
if (!Object.hasOwn(instrument.fields, key)) {
|
|
2618
|
-
add([...base, "bind", key], `bind targets unknown field ${key}`, "UDL5001");
|
|
2619
|
-
}
|
|
2620
|
-
if (key === gate.field) {
|
|
2621
|
-
add([...base, "bind", key], "bind cannot target the gate field itself", "UDL5001");
|
|
2622
|
-
}
|
|
2623
|
-
if (instrument.update?.fields.includes(key)) {
|
|
2624
|
-
add([...base, "bind", key], `bind targets ${key}, which update declares mutable`, "UDL5001");
|
|
2625
|
-
}
|
|
2626
|
-
if (path === "instrumentInstanceId") {
|
|
2627
|
-
add([...base, "bind", key], "the gate field already carries the referenced id", "UDL5001");
|
|
2628
|
-
}
|
|
2629
|
-
else if (!referencedPathDeclared(target, path)) {
|
|
2630
|
-
add([...base, "bind", key], `bind reads ${path}, which ${target.id} does not declare`, "UDL5001");
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
for (const [localPath, path] of Object.entries(gate.match ?? {})) {
|
|
2634
|
-
const localOk = action === "create"
|
|
2635
|
-
? localPath.startsWith("fields.") &&
|
|
2636
|
-
Object.hasOwn(instrument.fields, localPath.slice("fields.".length))
|
|
2637
|
-
: localPath === "instrumentInstanceId" ||
|
|
2638
|
-
(localPath.startsWith("fields.") &&
|
|
2639
|
-
Object.hasOwn(instrument.fields, localPath.slice("fields.".length))) ||
|
|
2640
|
-
(localPath.startsWith("refs.") &&
|
|
2641
|
-
declaredRefKeys(instrument).has(localPath.slice("refs.".length)));
|
|
2642
|
-
if (!localOk) {
|
|
2643
|
-
add([...base, "match", localPath], action === "create"
|
|
2644
|
-
? "a create match may only read the instrument's own declared fields"
|
|
2645
|
-
: `match reads unknown local path ${localPath}`, "UDL5001");
|
|
2646
|
-
}
|
|
2647
|
-
if (!referencedPathDeclared(target, path)) {
|
|
2648
|
-
add([...base, "match", localPath], `match reads ${path}, which ${target.id} does not declare`, "UDL5001");
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
if (gate.dateComparison) {
|
|
2652
|
-
const { localPath, referencedPath } = gate.dateComparison;
|
|
2653
|
-
const localFieldName = localPath.startsWith("fields.")
|
|
2654
|
-
? localPath.slice("fields.".length)
|
|
2655
|
-
: undefined;
|
|
2656
|
-
const localField = localFieldName
|
|
2657
|
-
? instrument.fields[localFieldName]
|
|
2658
|
-
: undefined;
|
|
2659
|
-
const isLocalDate = localField?.type === "string" &&
|
|
2660
|
-
typeof localField.format === "string" &&
|
|
2661
|
-
["hyperscale-date-time", "hyperscale-date", "date-time", "date"].includes(localField.format);
|
|
2662
|
-
if (!isLocalDate) {
|
|
2663
|
-
add([...base, "dateComparison", "localPath"], `dateComparison localPath ${localPath} must target a declared date or date-time field`, "UDL5001");
|
|
2664
|
-
}
|
|
2665
|
-
const referencedFieldName = referencedPath.startsWith("fields.")
|
|
2666
|
-
? referencedPath.slice("fields.".length)
|
|
2667
|
-
: undefined;
|
|
2668
|
-
const referencedField = referencedFieldName
|
|
2669
|
-
? target.fields[referencedFieldName]
|
|
70
|
+
if (first.type === "list" &&
|
|
71
|
+
fields.some((field) => field?.type !== "list" ||
|
|
72
|
+
field.item !== first.item ||
|
|
73
|
+
field.target !== first.target))
|
|
74
|
+
return;
|
|
75
|
+
return first;
|
|
76
|
+
}
|
|
77
|
+
export function resolveField(document, instrument, path, input = []) {
|
|
78
|
+
return resolvePath(document, instrument, path, input, new Map());
|
|
79
|
+
}
|
|
80
|
+
function resolvePath(document, instrument, path, input, cache) {
|
|
81
|
+
const parts = path.split(".");
|
|
82
|
+
const root = parts.shift();
|
|
83
|
+
const key = parts.shift();
|
|
84
|
+
if (!key)
|
|
85
|
+
return root === "self"
|
|
86
|
+
? { name: "id", type: "ref", target: instrument.id }
|
|
2670
87
|
: undefined;
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
if (
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
/** Every `refs.<key>` a instrument's instances can legitimately carry. */
|
|
2680
|
-
function declaredRefKeys(instrument) {
|
|
2681
|
-
return new Set([
|
|
2682
|
-
...Object.values(instrument.actions).flatMap((action) => action.payout ? [action.payout.capture] : []),
|
|
2683
|
-
...Object.values(instrument.actions).flatMap((action) => (action.reconcile ?? []).map((reconcile) => reconcile.capture)),
|
|
2684
|
-
...Object.values(instrument.actions).flatMap((action) => [
|
|
2685
|
-
...Object.keys(action.captureInput ?? {}),
|
|
2686
|
-
...Object.keys(action.captureEngine ?? {}),
|
|
2687
|
-
]),
|
|
2688
|
-
...Object.values(instrument.actions).flatMap((action) => [...action.steps, ...action.moves].flatMap((step) => Object.keys(step.capture ?? {}))),
|
|
2689
|
-
...Object.values(instrument.actions).flatMap((action) => action.signedSum
|
|
2690
|
-
? [
|
|
2691
|
-
action.signedSum.amountRef,
|
|
2692
|
-
...action.signedSum.sources.map((source) => source.subtotalRef),
|
|
2693
|
-
]
|
|
2694
|
-
: []),
|
|
2695
|
-
...Object.values(instrument.actions).flatMap((action) => action.allocate ? [action.allocate.capture] : []),
|
|
2696
|
-
...quoteRefKeys(instrument),
|
|
2697
|
-
...(instrument.subject ? ["subject"] : []),
|
|
2698
|
-
]);
|
|
2699
|
-
}
|
|
2700
|
-
/** Whether a bind/match referenced path names declared target structure. */
|
|
2701
|
-
function referencedPathDeclared(target, path) {
|
|
2702
|
-
if (path === "instrumentInstanceId")
|
|
2703
|
-
return true;
|
|
2704
|
-
if (path.startsWith("fields.")) {
|
|
2705
|
-
return Object.hasOwn(target.fields, path.slice("fields.".length));
|
|
2706
|
-
}
|
|
2707
|
-
if (path.startsWith("refs.")) {
|
|
2708
|
-
return declaredRefKeys(target).has(path.slice("refs.".length));
|
|
2709
|
-
}
|
|
2710
|
-
return false;
|
|
2711
|
-
}
|
|
2712
|
-
function validateStep(instrument, action, step, base, add) {
|
|
2713
|
-
if (Object.keys(step.bind).length === 0) {
|
|
2714
|
-
add([...base, "bind"], "a kernel step must bind at least one value", "UDL5008");
|
|
2715
|
-
}
|
|
2716
|
-
if (step.capture && Object.keys(step.capture).length === 0) {
|
|
2717
|
-
add([...base, "capture"], "capture must not be empty", "UDL5008");
|
|
2718
|
-
}
|
|
2719
|
-
const inputFields = recordValue(action.input?.properties);
|
|
2720
|
-
for (const [field, binding] of Object.entries(step.bind)) {
|
|
2721
|
-
if (binding.from === "const")
|
|
2722
|
-
continue;
|
|
2723
|
-
if (binding.from === "input") {
|
|
2724
|
-
const root = binding.path.split(".")[0];
|
|
2725
|
-
if (!Object.hasOwn(inputFields, root)) {
|
|
2726
|
-
add([...base, "bind", field, "path"], `input binding references undeclared action input field ${root}`, "UDL5008");
|
|
2727
|
-
}
|
|
2728
|
-
continue;
|
|
2729
|
-
}
|
|
2730
|
-
if (binding.path.startsWith("fields.")) {
|
|
2731
|
-
const root = binding.path.split(".")[1];
|
|
2732
|
-
if (!Object.hasOwn(instrument.fields, root)) {
|
|
2733
|
-
add([...base, "bind", field, "path"], `instance binding references unknown instrument field ${root}`, "UDL5008");
|
|
2734
|
-
}
|
|
2735
|
-
continue;
|
|
2736
|
-
}
|
|
2737
|
-
if (binding.path !== "instrumentInstanceId" &&
|
|
2738
|
-
binding.path !== "productId" &&
|
|
2739
|
-
!binding.path.startsWith("refs.")) {
|
|
2740
|
-
add([...base, "bind", field, "path"], `instance binding path ${binding.path} must read instrumentInstanceId, productId, fields.*, or refs.*`, "UDL5008");
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
if (step.operation === "account.freeze" ||
|
|
2744
|
-
step.operation === "account.unfreeze") {
|
|
2745
|
-
// Freeze steps act on an account the instance already carries and move no
|
|
2746
|
-
// money: the account must come from instance state, never caller input,
|
|
2747
|
-
// and a monetary leg on a freeze step is a contradiction in terms.
|
|
2748
|
-
const accountId = step.bind.accountId;
|
|
2749
|
-
if (!accountId || accountId.from !== "instance") {
|
|
2750
|
-
add([...base, "bind", "accountId"], `${step.operation} must bind accountId from an instance path`, "UDL5008");
|
|
2751
|
-
}
|
|
2752
|
-
for (const monetary of ["amount", "currency"]) {
|
|
2753
|
-
if (Object.hasOwn(step.bind, monetary)) {
|
|
2754
|
-
add([...base, "bind", monetary], `${step.operation} must not bind ${monetary}`, "UDL5008");
|
|
2755
|
-
}
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
if (step.operation === "internal_transfer.create" ||
|
|
2759
|
-
step.operation === "internal_transfer.reserve") {
|
|
2760
|
-
const source = step.bind.sourceAccountId;
|
|
2761
|
-
const destination = step.bind.destinationAccountId;
|
|
2762
|
-
if (source?.from === "instance" &&
|
|
2763
|
-
destination?.from === "instance" &&
|
|
2764
|
-
source.path === destination.path) {
|
|
2765
|
-
add([...base, "bind", "destinationAccountId"], "transfer source and destination must be different accounts", "UDL4001");
|
|
2766
|
-
}
|
|
88
|
+
if (root === "party") {
|
|
89
|
+
if (!document.parties[key])
|
|
90
|
+
return;
|
|
91
|
+
if (parts.length === 0)
|
|
92
|
+
return { name: key, type: "account", owner: key, book: "cash" };
|
|
93
|
+
if (parts.length === 1 && ["balance", "reserved"].includes(parts[0]))
|
|
94
|
+
return { name: parts[0], type: "money" };
|
|
95
|
+
return;
|
|
2767
96
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
}
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
for (const [
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
if (
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
97
|
+
if (root === "self" && parts.length === 0) {
|
|
98
|
+
if (key === "id")
|
|
99
|
+
return { name: key, type: "ref", target: instrument.id };
|
|
100
|
+
if (key === "now" || key === "createdAt")
|
|
101
|
+
return { name: key, type: "date" };
|
|
102
|
+
if (key === "status")
|
|
103
|
+
return { name: key, type: "enum", values: instrument.lifecycle.states };
|
|
104
|
+
}
|
|
105
|
+
const scope = instrument;
|
|
106
|
+
let field = (root === "self" ? instrument.fields : root === "input" ? input : []).find((f) => f.name === key);
|
|
107
|
+
for (const [index, part] of parts.entries()) {
|
|
108
|
+
if (field?.type === "account" &&
|
|
109
|
+
["balance", "reserved"].includes(part) &&
|
|
110
|
+
index === parts.length - 1)
|
|
111
|
+
return { name: part, type: "money" };
|
|
112
|
+
if (field?.type === "text" &&
|
|
113
|
+
part === "status" &&
|
|
114
|
+
index === parts.length - 1 &&
|
|
115
|
+
Object.values(scope.actions).some((a) => a.moves.some((m) => "capture" in m && m.capture === field?.name)))
|
|
116
|
+
return {
|
|
117
|
+
name: part,
|
|
118
|
+
type: "enum",
|
|
119
|
+
values: ["reserved", "posted", "settled", "reversed", "voided"],
|
|
120
|
+
};
|
|
121
|
+
if (field?.type !== "ref")
|
|
122
|
+
return;
|
|
123
|
+
return commonField(targetIds(field.target).map((id) => {
|
|
124
|
+
const target = document.instruments.find((instrument) => instrument.id === id);
|
|
125
|
+
if (!target)
|
|
126
|
+
return;
|
|
127
|
+
const suffix = "self." + parts.slice(index).join(".");
|
|
128
|
+
const key = `${id}:${suffix}`;
|
|
129
|
+
if (!cache.has(key))
|
|
130
|
+
cache.set(key, resolvePath(document, target, suffix, [], cache));
|
|
131
|
+
return cache.get(key);
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
return field;
|
|
135
|
+
}
|
|
136
|
+
export function validateUdl(value) {
|
|
137
|
+
if (!bounded(value))
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
issues: [
|
|
141
|
+
issue("UDL1004", "$", "document exceeds structural limits or is not finite JSON"),
|
|
2809
142
|
],
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
add([...quoteBase, "anchorField"], "anchorField must be required", "UDL5006");
|
|
2830
|
-
}
|
|
2831
|
-
}
|
|
2832
|
-
if ("offset" in quote.expires) {
|
|
2833
|
-
if (fixedIsoDurationMs(quote.expires.offset) === null) {
|
|
2834
|
-
add([...quoteBase, "expires", "offset"], "the offer's life must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL5006");
|
|
2835
|
-
}
|
|
2836
|
-
}
|
|
2837
|
-
else {
|
|
2838
|
-
const deadline = instrument.fields[quote.expires.field];
|
|
2839
|
-
if (!deadline) {
|
|
2840
|
-
add([...quoteBase, "expires", "field"], `quote references unknown field ${quote.expires.field}`, "UDL5006");
|
|
2841
|
-
}
|
|
2842
|
-
else if (!isDateTimeFormat(deadline.format)) {
|
|
2843
|
-
add([...quoteBase, "expires", "field"], "the offer's deadline field must be a date-time field", "UDL5006");
|
|
2844
|
-
}
|
|
2845
|
-
else if (!instrument.required.includes(quote.expires.field)) {
|
|
2846
|
-
add([...quoteBase, "expires", "field"], "the offer's deadline field must be required", "UDL5006");
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
|
-
addDuplicateIssues(quote.fixes, [...quoteBase, "fixes"], "frozen field", add);
|
|
2850
|
-
const offsets = quote.charges.flatMap((tier) => tier.withinOffset ? [tier.withinOffset] : []);
|
|
2851
|
-
addDuplicateIssues(offsets, [...quoteBase, "charges"], "charge offset", add);
|
|
2852
|
-
if (quote.charges.filter((tier) => tier.withinOffset === undefined).length > 1) {
|
|
2853
|
-
add([...quoteBase, "charges"], "a quote may declare at most one floor tier", "UDL5006");
|
|
2854
|
-
}
|
|
2855
|
-
quote.charges.forEach((tier, index) => {
|
|
2856
|
-
if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
|
|
2857
|
-
add([...quoteBase, "charges", index, "withinOffset"], "withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds", "UDL5006");
|
|
2858
|
-
}
|
|
2859
|
-
});
|
|
2860
|
-
const committing = Object.entries(instrument.actions).filter(([, candidate]) => candidate.commit === actionName);
|
|
2861
|
-
if (committing.length !== 1) {
|
|
2862
|
-
add([...quoteBase], `quoting action ${actionName} must be committed by exactly one action, not ${committing.length}`, "UDL5006");
|
|
143
|
+
};
|
|
144
|
+
const parsed = udlDocumentSchema.safeParse(value);
|
|
145
|
+
if (!parsed.success)
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
issues: parsed.error.issues.map((i) => issue("UDL1003", "$" +
|
|
149
|
+
i.path
|
|
150
|
+
.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`))
|
|
151
|
+
.join(""), i.message)),
|
|
152
|
+
};
|
|
153
|
+
const document = parsed.data;
|
|
154
|
+
const issues = [];
|
|
155
|
+
const add = (path, message, code = "UDL2002") => issues.push(issue(code, path, message));
|
|
156
|
+
const duplicate = (values, path) => {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
for (const v of values) {
|
|
159
|
+
if (seen.has(v))
|
|
160
|
+
add(path, `duplicate ${v}`, "UDL2001");
|
|
161
|
+
seen.add(v);
|
|
2863
162
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
163
|
+
};
|
|
164
|
+
duplicate(document.instruments.map((i) => i.id), "$.instruments");
|
|
165
|
+
const byId = new Map(document.instruments.map((i) => [i.id, i]));
|
|
166
|
+
const calls = new Map();
|
|
167
|
+
for (const [index, inst] of document.instruments.entries()) {
|
|
168
|
+
const base = `$.instruments[${index}]`;
|
|
169
|
+
const field = (p, input = []) => resolveField(document, inst, p, input);
|
|
170
|
+
const expect = (p, type, where, input = []) => {
|
|
171
|
+
const found = field(p, input);
|
|
172
|
+
if (found?.type !== type)
|
|
173
|
+
add(where, `${p} must name a ${type} field`, "UDL5001");
|
|
174
|
+
return found;
|
|
175
|
+
};
|
|
176
|
+
const checkValue = (v, type, where, input = []) => {
|
|
177
|
+
if ("field" in v) {
|
|
178
|
+
expect(v.field, type, where, input);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const valid = type === "money"
|
|
182
|
+
? typeof v.literal === "string" &&
|
|
183
|
+
/^(0|[1-9][0-9]{0,17})$/.test(v.literal)
|
|
184
|
+
: type === "integer" || type === "percent" || type === "duration"
|
|
185
|
+
? typeof v.literal === "number" &&
|
|
186
|
+
Number.isSafeInteger(v.literal) &&
|
|
187
|
+
(type !== "percent" || (v.literal >= 0 && v.literal <= 10000))
|
|
188
|
+
: type === "boolean"
|
|
189
|
+
? typeof v.literal === "boolean"
|
|
190
|
+
: typeof v.literal === "string";
|
|
191
|
+
if (!valid)
|
|
192
|
+
add(where, `literal must have type ${type}`);
|
|
193
|
+
};
|
|
194
|
+
const checkFields = (fields, where) => {
|
|
195
|
+
duplicate(fields.map((f) => f.name), where);
|
|
196
|
+
for (const f of fields) {
|
|
197
|
+
if (f.type === "account" &&
|
|
198
|
+
((f.contra && f.book !== "claim") ||
|
|
199
|
+
(f.external && (f.book !== "cash" || f.owner === "self"))))
|
|
200
|
+
add(where, "contra accounts require the claim book; external accounts require a cash party owner");
|
|
201
|
+
if (["id", "status", "createdAt", "now"].includes(f.name))
|
|
202
|
+
add(where, `${f.name} is a sealed instance field`);
|
|
203
|
+
if (f.type === "ref") {
|
|
204
|
+
duplicate(targetIds(f.target), where);
|
|
205
|
+
for (const id of targetIds(f.target))
|
|
206
|
+
if (!byId.has(id))
|
|
207
|
+
add(where, `unknown reference target ${id}`, "UDL5001");
|
|
208
|
+
}
|
|
209
|
+
if (f.type === "account" &&
|
|
210
|
+
f.owner !== "self" &&
|
|
211
|
+
!document.parties[f.owner])
|
|
212
|
+
add(where, `${f.name} needs self or a declared party as owner`);
|
|
213
|
+
if (f.type === "enum") {
|
|
214
|
+
duplicate(f.values, where);
|
|
215
|
+
if (f.value !== undefined && !f.values.includes(f.value))
|
|
216
|
+
add(where, `${f.name} constant is outside its enum`);
|
|
217
|
+
}
|
|
218
|
+
if (f.type === "list" &&
|
|
219
|
+
(f.item === "ref"
|
|
220
|
+
? !f.target || !byId.has(f.target)
|
|
221
|
+
: f.target !== undefined))
|
|
222
|
+
add(where, `${f.name} needs a target exactly when its items are references`);
|
|
223
|
+
if ((f.type === "integer" || f.type === "money") &&
|
|
224
|
+
f.minimum !== undefined &&
|
|
225
|
+
f.maximum !== undefined &&
|
|
226
|
+
BigInt(f.minimum) > BigInt(f.maximum))
|
|
227
|
+
add(where, `${f.name} minimum exceeds maximum`);
|
|
228
|
+
if ((f.type === "integer" || f.type === "money") &&
|
|
229
|
+
f.value !== undefined &&
|
|
230
|
+
((f.minimum !== undefined && BigInt(f.value) < BigInt(f.minimum)) ||
|
|
231
|
+
(f.maximum !== undefined && BigInt(f.value) > BigInt(f.maximum))))
|
|
232
|
+
add(where, `${f.name} constant violates its bounds`);
|
|
2872
233
|
}
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
234
|
+
};
|
|
235
|
+
checkFields(inst.fields, `${base}.fields`);
|
|
236
|
+
const captures = new Set(Object.values(inst.actions).flatMap((a) => [
|
|
237
|
+
...a.moves.flatMap((m) => "capture" in m && m.capture ? [m.capture] : []),
|
|
238
|
+
]));
|
|
239
|
+
for (const captured of captures) {
|
|
240
|
+
const target = inst.fields.find((f) => f.name === captured);
|
|
241
|
+
if (target?.type !== "text" || target.value !== undefined)
|
|
242
|
+
add(base, `${captured} must be a text field reserved for an executor receipt`);
|
|
243
|
+
}
|
|
244
|
+
const checkSelection = (selection, where, input = []) => {
|
|
245
|
+
const ids = typeof selection.instrument === "string"
|
|
246
|
+
? [selection.instrument]
|
|
247
|
+
: selection.instrument;
|
|
248
|
+
duplicate(ids, where);
|
|
249
|
+
const targets = ids.flatMap((id) => {
|
|
250
|
+
const target = byId.get(id);
|
|
251
|
+
if (!target)
|
|
252
|
+
add(where, `unknown selected instrument ${id}`);
|
|
253
|
+
return target ? [target] : [];
|
|
254
|
+
});
|
|
255
|
+
const compatible = (a, b) => a &&
|
|
256
|
+
b &&
|
|
257
|
+
a.type === b.type &&
|
|
258
|
+
(a.type !== "ref" || (b.type === "ref" && overlap(a.target, b.target)));
|
|
259
|
+
const anchor = field(selection.anchor, input);
|
|
260
|
+
for (const target of targets) {
|
|
261
|
+
if (selection.window &&
|
|
262
|
+
target.fields.find((f) => f.name === selection.window.field)
|
|
263
|
+
?.type !== "date")
|
|
264
|
+
add(where, "selection window requires a date field on every selected instrument");
|
|
265
|
+
const reference = target.fields.find((f) => f.name === selection.reference);
|
|
266
|
+
if (!compatible(reference, anchor))
|
|
267
|
+
add(where, "selection reference and anchor must have the same declared type");
|
|
268
|
+
if (selection.states.some((state) => !target.lifecycle.states.includes(state)))
|
|
269
|
+
add(where, `selection names an undeclared state on ${target.id}`);
|
|
270
|
+
for (const key of selection.order ?? []) {
|
|
271
|
+
const ordered = resolveField(document, target, `self.${key}`);
|
|
272
|
+
if (!ordered || ["account", "ref", "list"].includes(ordered.type))
|
|
273
|
+
add(where, `selection order needs a scalar path: ${key}`);
|
|
274
|
+
}
|
|
275
|
+
for (const [key, value] of Object.entries(selection.where ?? {})) {
|
|
276
|
+
const selected = resolveField(document, target, key.startsWith("self.") ? key : `self.${key}`);
|
|
277
|
+
if (!selected)
|
|
278
|
+
add(where, `unknown selected field ${key}`);
|
|
279
|
+
else if ("field" in value) {
|
|
280
|
+
if (!compatible(selected, field(value.field, input)))
|
|
281
|
+
add(where, `selection filter ${key} has incompatible operands`);
|
|
282
|
+
}
|
|
283
|
+
else
|
|
284
|
+
checkValue(value, selected.type, where, input);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const first = targets[0];
|
|
288
|
+
if (!first)
|
|
289
|
+
return;
|
|
290
|
+
return {
|
|
291
|
+
...first,
|
|
292
|
+
fields: first.fields.flatMap((field) => {
|
|
293
|
+
const shared = commonField(targets.map((target) => target.fields.find((candidate) => candidate.name === field.name)));
|
|
294
|
+
return shared ? [shared] : [];
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
const checkCalculations = (calculations, where, input = []) => {
|
|
299
|
+
duplicate(calculations.map((c) => c.target), where);
|
|
300
|
+
const dependencies = new Map();
|
|
301
|
+
for (const c of calculations) {
|
|
302
|
+
const arithmetic = ["sum", "subtract", "minimum", "divide"].includes(c.op);
|
|
303
|
+
const resultType = c.op === "at"
|
|
304
|
+
? (() => {
|
|
305
|
+
const list = field(c.list, input);
|
|
306
|
+
return list?.type === "list" ? list.item : "text";
|
|
307
|
+
})()
|
|
308
|
+
: (c.op === "aggregate" && c.measure === "count") ||
|
|
309
|
+
(arithmetic && field(`self.${c.target}`)?.type === "integer")
|
|
310
|
+
? "integer"
|
|
311
|
+
: c.op === "shift"
|
|
312
|
+
? "date"
|
|
313
|
+
: "money";
|
|
314
|
+
const result = expect(`self.${c.target}`, resultType, where);
|
|
315
|
+
if (result && "value" in result && result.value !== undefined)
|
|
316
|
+
add(where, `calculation cannot replace constant ${c.target}`);
|
|
317
|
+
const operands = [];
|
|
318
|
+
const money = (v) => {
|
|
319
|
+
operands.push(v);
|
|
320
|
+
checkValue(v, arithmetic ? resultType : "money", where, input);
|
|
321
|
+
};
|
|
322
|
+
if (c.op === "at") {
|
|
323
|
+
expect(c.list, "list", where, input);
|
|
324
|
+
checkValue(c.position, "integer", where, input);
|
|
325
|
+
if ("literal" in c.position && Number(c.position.literal) < 1)
|
|
326
|
+
add(where, "list positions start at one");
|
|
327
|
+
operands.push({ field: c.list }, c.position);
|
|
328
|
+
}
|
|
329
|
+
if (c.op === "aggregate") {
|
|
330
|
+
const selected = checkSelection(c.selection, where, input);
|
|
331
|
+
if (c.measure !== "count" &&
|
|
332
|
+
(!selected ||
|
|
333
|
+
resolveField(document, selected, `self.${c.measure.sum}`)
|
|
334
|
+
?.type !== "money"))
|
|
335
|
+
add(where, "aggregate calculation needs a typed money path");
|
|
336
|
+
}
|
|
337
|
+
if (c.op === "ratio") {
|
|
338
|
+
money(c.amount);
|
|
339
|
+
const type = "field" in c.numerator
|
|
340
|
+
? field(c.numerator.field, input)?.type
|
|
341
|
+
: typeof c.numerator.literal === "number"
|
|
342
|
+
? "integer"
|
|
343
|
+
: "money";
|
|
344
|
+
if (type !== "integer" && type !== "money" && type !== "percent")
|
|
345
|
+
add(where, "ratio weights must share a numeric type");
|
|
346
|
+
else {
|
|
347
|
+
checkValue(c.numerator, type, where, input);
|
|
348
|
+
checkValue(c.denominator, type, where, input);
|
|
349
|
+
}
|
|
350
|
+
if ("literal" in c.denominator &&
|
|
351
|
+
BigInt(c.denominator.literal.toString()) <= 0n)
|
|
352
|
+
add(where, "ratio denominator must be positive");
|
|
353
|
+
operands.push(c.numerator, c.denominator);
|
|
354
|
+
}
|
|
355
|
+
if (c.op === "sum" || c.op === "minimum")
|
|
356
|
+
c.values.forEach(money);
|
|
357
|
+
if (c.op === "subtract") {
|
|
358
|
+
money(c.base);
|
|
359
|
+
c.subtract.forEach(money);
|
|
360
|
+
}
|
|
361
|
+
if (c.op === "rate") {
|
|
362
|
+
money(c.base);
|
|
363
|
+
checkValue(c.bps, "percent", where, input);
|
|
364
|
+
operands.push(c.bps);
|
|
365
|
+
}
|
|
366
|
+
if (c.op === "multiply" || c.op === "divide") {
|
|
367
|
+
money(c.amount);
|
|
368
|
+
const count = c.op === "multiply" ? c.units : c.divisor;
|
|
369
|
+
checkValue(count, "integer", where, input);
|
|
370
|
+
operands.push(count);
|
|
371
|
+
if ("literal" in count &&
|
|
372
|
+
(c.op === "divide"
|
|
373
|
+
? Number(count.literal) <= 0
|
|
374
|
+
: Number(count.literal) < 0))
|
|
375
|
+
add(where, "money multiplier must be nonnegative and divisor must be positive");
|
|
376
|
+
}
|
|
377
|
+
if (c.op === "shift") {
|
|
378
|
+
checkValue(c.date, "date", where, input);
|
|
379
|
+
checkValue(c.milliseconds, "duration", where, input);
|
|
380
|
+
operands.push(c.date, c.milliseconds);
|
|
381
|
+
}
|
|
382
|
+
dependencies.set(c.target, operands.flatMap((v) => "field" in v && v.field.startsWith("self.")
|
|
383
|
+
? [v.field.slice(5)]
|
|
384
|
+
: []));
|
|
385
|
+
}
|
|
386
|
+
const active = new Set();
|
|
387
|
+
const visited = new Set();
|
|
388
|
+
const visit = (key) => {
|
|
389
|
+
if (active.has(key)) {
|
|
390
|
+
add(where, `calculation cycle at ${key}`);
|
|
391
|
+
return;
|
|
2880
392
|
}
|
|
2881
|
-
if (
|
|
2882
|
-
|
|
393
|
+
if (visited.has(key))
|
|
394
|
+
return;
|
|
395
|
+
active.add(key);
|
|
396
|
+
visited.add(key);
|
|
397
|
+
for (const dep of dependencies.get(key) ?? [])
|
|
398
|
+
visit(dep);
|
|
399
|
+
active.delete(key);
|
|
400
|
+
};
|
|
401
|
+
for (const key of dependencies.keys())
|
|
402
|
+
visit(key);
|
|
403
|
+
};
|
|
404
|
+
checkCalculations(inst.calculate, `${base}.calculate`);
|
|
405
|
+
const states = new Set(inst.lifecycle.states);
|
|
406
|
+
duplicate(inst.lifecycle.states, `${base}.lifecycle.states`);
|
|
407
|
+
if (!states.has(inst.lifecycle.initial) || !inst.actions.create)
|
|
408
|
+
add(base, "declare create and a declared initial state", "UDL3001");
|
|
409
|
+
duplicate(inst.actionOrder, `${base}.actionOrder`);
|
|
410
|
+
if (inst.actionOrder.length !== Object.keys(inst.actions).length ||
|
|
411
|
+
inst.actionOrder.some((a) => !inst.actions[a]))
|
|
412
|
+
add(base, "actionOrder must list every action once");
|
|
413
|
+
const reachable = new Set([inst.lifecycle.initial]);
|
|
414
|
+
for (const [action, edge] of Object.entries(inst.lifecycle.transitions)) {
|
|
415
|
+
if (!inst.actions[action] ||
|
|
416
|
+
action === "create" ||
|
|
417
|
+
!states.has(edge.to) ||
|
|
418
|
+
edge.from.some((s) => !states.has(s)))
|
|
419
|
+
add(base, `invalid transition ${action}`, "UDL3001");
|
|
420
|
+
}
|
|
421
|
+
for (let pass = 0; pass < states.size; pass++)
|
|
422
|
+
for (const edge of Object.values(inst.lifecycle.transitions))
|
|
423
|
+
if (edge.from.some((s) => reachable.has(s)))
|
|
424
|
+
reachable.add(edge.to);
|
|
425
|
+
for (const state of states)
|
|
426
|
+
if (!reachable.has(state))
|
|
427
|
+
add(base, `unreachable state ${state}`, "UDL3001");
|
|
428
|
+
const checkRequirements = (requirements, where, input = []) => {
|
|
429
|
+
for (const req of requirements ?? []) {
|
|
430
|
+
if (req.kind === "compare") {
|
|
431
|
+
const left = "field" in req.left ? field(req.left.field, input) : undefined;
|
|
432
|
+
const right = "field" in req.right ? field(req.right.field, input) : undefined;
|
|
433
|
+
if (("field" in req.left && !left) ||
|
|
434
|
+
("field" in req.right && !right))
|
|
435
|
+
add(where, "comparison refers to an undeclared field");
|
|
436
|
+
if (left &&
|
|
437
|
+
right &&
|
|
438
|
+
(left.type !== right.type ||
|
|
439
|
+
(left.type === "ref" &&
|
|
440
|
+
right.type === "ref" &&
|
|
441
|
+
!overlap(left.target, right.target))))
|
|
442
|
+
add(where, "comparison operands have different types");
|
|
443
|
+
if (left && "literal" in req.right) {
|
|
444
|
+
checkValue(req.right, left.type, where, input);
|
|
445
|
+
if (left.type === "enum" &&
|
|
446
|
+
!left.values.includes(String(req.right.literal)))
|
|
447
|
+
add(where, "comparison names an undeclared enum value");
|
|
448
|
+
}
|
|
449
|
+
if (right && "literal" in req.left) {
|
|
450
|
+
checkValue(req.left, right.type, where, input);
|
|
451
|
+
if (right.type === "enum" &&
|
|
452
|
+
!right.values.includes(String(req.left.literal)))
|
|
453
|
+
add(where, "comparison names an undeclared enum value");
|
|
454
|
+
}
|
|
2883
455
|
}
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
456
|
+
else if (req.kind === "state") {
|
|
457
|
+
const target = expect(req.reference, "ref", where, input);
|
|
458
|
+
if (target?.type === "ref" &&
|
|
459
|
+
req.states.some((s) => targetIds(target.target).some((id) => !byId.get(id)?.lifecycle.states.includes(s))))
|
|
460
|
+
add(where, "reference gate names an undeclared state");
|
|
461
|
+
}
|
|
462
|
+
else if (req.kind === "unique") {
|
|
463
|
+
for (const p of req.fields)
|
|
464
|
+
if (!field(p, input))
|
|
465
|
+
add(where, `unknown identity field ${p}`);
|
|
466
|
+
}
|
|
467
|
+
else if (req.kind === "approval") {
|
|
468
|
+
const target = expect(req.target, "ref", where, input);
|
|
469
|
+
if (!document.parties[req.party])
|
|
470
|
+
add(where, `unknown approving party ${req.party}`);
|
|
471
|
+
if (req.action &&
|
|
472
|
+
target?.type === "ref" &&
|
|
473
|
+
targetIds(target.target).some((id) => !byId.get(id)?.actions[req.action]))
|
|
474
|
+
add(where, `unknown approved action ${req.action}`);
|
|
475
|
+
}
|
|
476
|
+
else if (req.kind === "evidence") {
|
|
477
|
+
const subject = field(req.subject, input);
|
|
478
|
+
if (!subject || !["account", "text"].includes(subject.type))
|
|
479
|
+
add(where, "evidence subject must be an account or text subject id");
|
|
480
|
+
}
|
|
481
|
+
else if (req.kind === "hours") {
|
|
482
|
+
expect(req.at, "date", where, input);
|
|
483
|
+
try {
|
|
484
|
+
new Intl.DateTimeFormat("en", { timeZone: req.timezone });
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
add(where, "hours requires an IANA timezone");
|
|
488
|
+
}
|
|
489
|
+
if (req.start === req.end)
|
|
490
|
+
add(where, "hours interval admits no time");
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const selected = checkSelection(req.selection, where, input);
|
|
494
|
+
if (req.kind === "aggregate") {
|
|
495
|
+
const measure = req.measure;
|
|
496
|
+
if (typeof measure !== "string" &&
|
|
497
|
+
(selected &&
|
|
498
|
+
resolveField(document, selected, `self.${measure.sum}`)
|
|
499
|
+
?.type) !== "money")
|
|
500
|
+
add(where, "aggregate sum requires a money field");
|
|
501
|
+
checkValue(req.value, req.measure === "count" ? "integer" : "money", where, input);
|
|
2900
502
|
}
|
|
2901
503
|
}
|
|
2902
504
|
}
|
|
2903
|
-
}
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
505
|
+
};
|
|
506
|
+
checkRequirements(inst.invariants, `${base}.invariants`);
|
|
507
|
+
for (const [actionName, action] of Object.entries(inst.actions)) {
|
|
508
|
+
const where = `${base}.actions.${actionName}`;
|
|
509
|
+
if (actionName !== "create" && !inst.lifecycle.transitions[actionName])
|
|
510
|
+
add(where, "action needs a lifecycle transition", "UDL3001");
|
|
511
|
+
checkFields(action.input, `${where}.input`);
|
|
512
|
+
for (const input of action.input) {
|
|
513
|
+
if (input.type === "account")
|
|
514
|
+
add(where, "account bindings cannot be caller inputs");
|
|
515
|
+
const declared = inst.fields.find((f) => f.name === input.name);
|
|
516
|
+
const calculated = [
|
|
517
|
+
...inst.calculate,
|
|
518
|
+
...Object.values(inst.actions).flatMap((a) => a.calculate ?? []),
|
|
519
|
+
].some((c) => c.target === input.name);
|
|
520
|
+
if (actionName === "create" &&
|
|
521
|
+
(calculated ||
|
|
522
|
+
(declared && "value" in declared && declared.value !== undefined)))
|
|
523
|
+
add(where, `${input.name} is fixed by the contract and cannot be an input`);
|
|
524
|
+
if (captures.has(input.name))
|
|
525
|
+
add(where, `${input.name} is an executor-owned receipt and cannot be an input`);
|
|
526
|
+
}
|
|
527
|
+
checkCalculations(action.calculate ?? [], `${where}.calculate`, action.input);
|
|
528
|
+
checkRequirements(action.requires, `${where}.requires`, action.input);
|
|
529
|
+
if (typeof action.actor === "object" &&
|
|
530
|
+
"party" in action.actor &&
|
|
531
|
+
!document.parties[action.actor.party])
|
|
532
|
+
add(where, "actor names an undeclared party");
|
|
533
|
+
if (typeof action.actor === "object" &&
|
|
534
|
+
"parent" in action.actor &&
|
|
535
|
+
!byId.has(action.actor.parent))
|
|
536
|
+
add(where, "actor names an undeclared parent");
|
|
537
|
+
if (action.actor === "clock" && !action.due)
|
|
538
|
+
add(where, "clock action needs a due instant");
|
|
539
|
+
for (const clock of [action.due, action.deadline])
|
|
540
|
+
if (clock)
|
|
541
|
+
expect(clock.at, "date", where, action.input);
|
|
542
|
+
for (const move of action.moves) {
|
|
543
|
+
if ("amount" in move) {
|
|
544
|
+
checkValue(move.amount, "money", where, action.input);
|
|
545
|
+
const from = expect(move.from, "account", where, action.input);
|
|
546
|
+
const to = expect(move.to, "account", where, action.input);
|
|
547
|
+
if (from?.type === "account" &&
|
|
548
|
+
to?.type === "account" &&
|
|
549
|
+
from.book !== to.book)
|
|
550
|
+
add(where, "moves cannot cross account books", "UDL4001");
|
|
551
|
+
const samePartyAccount = from?.type === "account" &&
|
|
552
|
+
to?.type === "account" &&
|
|
553
|
+
from.owner !== "self" &&
|
|
554
|
+
from.owner === to.owner &&
|
|
555
|
+
from.book === to.book &&
|
|
556
|
+
(from.key ?? "balance") === (to.key ?? "balance");
|
|
557
|
+
if (move.from === move.to || samePartyAccount)
|
|
558
|
+
add(where, "a transfer needs distinct accounts", "UDL4001");
|
|
559
|
+
}
|
|
560
|
+
else
|
|
561
|
+
expect(move.transfer, "text", where, action.input);
|
|
562
|
+
}
|
|
563
|
+
duplicate(action.moves.flatMap((m) => "capture" in m && m.capture ? [m.capture] : []), `${where}.captures`);
|
|
564
|
+
duplicate(action.moves.map((m) => m.key), `${where}.moves`);
|
|
565
|
+
for (const [key, v] of Object.entries(action.set ?? {})) {
|
|
566
|
+
const target = inst.fields.find((f) => f.name === key);
|
|
567
|
+
if (!target ||
|
|
568
|
+
captures.has(key) ||
|
|
569
|
+
("value" in target && target.value !== undefined) ||
|
|
570
|
+
target.type === "account" ||
|
|
571
|
+
target.type === "ref" ||
|
|
572
|
+
inst.calculate.some((c) => c.target === key))
|
|
573
|
+
add(where, `cannot write immutable field ${key}`);
|
|
574
|
+
else
|
|
575
|
+
checkValue(v, target.type, where, action.input);
|
|
576
|
+
}
|
|
577
|
+
const checkArguments = (targetId, actionName, values) => {
|
|
578
|
+
const target = targetId
|
|
579
|
+
? byId.get(targetId)?.actions[actionName]
|
|
580
|
+
: undefined;
|
|
581
|
+
if (!target)
|
|
582
|
+
return;
|
|
583
|
+
for (const required of target.input)
|
|
584
|
+
if (!required.optional &&
|
|
585
|
+
!("value" in required && required.value !== undefined) &&
|
|
586
|
+
!(required.name in values))
|
|
587
|
+
add(where, `missing target input ${required.name}`);
|
|
588
|
+
for (const [name, value] of Object.entries(values)) {
|
|
589
|
+
const declared = target.input.find((f) => f.name === name);
|
|
590
|
+
if (!declared) {
|
|
591
|
+
add(where, `unknown target input ${name}`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
checkValue(value, declared.type, where, action.input);
|
|
595
|
+
if (declared.type === "ref" && "field" in value) {
|
|
596
|
+
const supplied = field(value.field, action.input);
|
|
597
|
+
if (supplied?.type !== "ref" ||
|
|
598
|
+
!targetIds(supplied.target).every((id) => targetIds(declared.target).includes(id)))
|
|
599
|
+
add(where, `target input ${name} has the wrong reference type`);
|
|
600
|
+
}
|
|
2970
601
|
}
|
|
602
|
+
};
|
|
603
|
+
if (action.approval) {
|
|
604
|
+
const reference = expect(action.approval.target, "ref", where, action.input);
|
|
605
|
+
if (reference?.type === "ref" &&
|
|
606
|
+
targetIds(reference.target).some((id) => !byId.get(id)?.actions[action.approval.action]))
|
|
607
|
+
add(where, "approval target action does not exist");
|
|
608
|
+
if (reference?.type === "ref")
|
|
609
|
+
for (const id of targetIds(reference.target))
|
|
610
|
+
checkArguments(id, action.approval.action, action.approval.input);
|
|
611
|
+
expect(action.approval.expires, "date", where, action.input);
|
|
612
|
+
if (!document.parties[action.approval.party])
|
|
613
|
+
add(where, "approval needs a declared party");
|
|
614
|
+
}
|
|
615
|
+
const targets = [];
|
|
616
|
+
for (const call of action.invoke ?? []) {
|
|
617
|
+
if ("selection" in call)
|
|
618
|
+
checkSelection(call.selection, where, action.input);
|
|
619
|
+
const target = "reference" in call ? field(call.reference, action.input) : undefined;
|
|
620
|
+
const selected = "instrument" in call
|
|
621
|
+
? call.instrument
|
|
622
|
+
: "selection" in call
|
|
623
|
+
? call.selection.instrument
|
|
624
|
+
: target?.type === "ref"
|
|
625
|
+
? target.target
|
|
626
|
+
: undefined;
|
|
627
|
+
const ids = Array.isArray(selected)
|
|
628
|
+
? selected
|
|
629
|
+
: selected
|
|
630
|
+
? [selected]
|
|
631
|
+
: [];
|
|
632
|
+
if (!ids.length)
|
|
633
|
+
add(where, "invocation needs a declared target");
|
|
634
|
+
for (const targetId of ids) {
|
|
635
|
+
checkArguments(targetId, call.action, call.input);
|
|
636
|
+
if (call.action === "create" && !("instrument" in call))
|
|
637
|
+
add(where, "invocation references an existing instance and cannot create it again");
|
|
638
|
+
if (!byId.get(targetId)?.actions[call.action])
|
|
639
|
+
add(where, "invocation target must name a declared action");
|
|
640
|
+
}
|
|
641
|
+
targets.push({
|
|
642
|
+
targets: ids.map((id) => `${id}.${call.action}`),
|
|
643
|
+
count: "selection" in call ? call.selection.limit : 1,
|
|
644
|
+
});
|
|
2971
645
|
}
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
...base,
|
|
2996
|
-
"aggregateInvariants",
|
|
2997
|
-
aggregateIndex,
|
|
2998
|
-
];
|
|
2999
|
-
const sum = "childField" in aggregate ? aggregate : undefined;
|
|
3000
|
-
const parentField = instrument.fields[aggregate.parentField];
|
|
3001
|
-
const child = instruments.get(aggregate.childInstrumentId);
|
|
3002
|
-
const childField = sum && child ? child.fields[sum.childField] : undefined;
|
|
3003
|
-
const isIntegerSum = sum !== undefined &&
|
|
3004
|
-
parentField?.type === "integer" &&
|
|
3005
|
-
instrument.required.includes(aggregate.parentField) &&
|
|
3006
|
-
childField?.type === "integer";
|
|
3007
|
-
if (!parentField) {
|
|
3008
|
-
add([...aggregateBase, "parentField"], `aggregate references unknown parent field ${aggregate.parentField}`, "UDL5004");
|
|
3009
|
-
}
|
|
3010
|
-
else if (sum && !isMoneySchema(parentField) && !isIntegerSum) {
|
|
3011
|
-
add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} must be a money field`, "UDL5004");
|
|
3012
|
-
}
|
|
3013
|
-
else if (!sum &&
|
|
3014
|
-
(parentField.type !== "integer" ||
|
|
3015
|
-
!instrument.required.includes(aggregate.parentField))) {
|
|
3016
|
-
add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} must be a required integer field to cap a count`, "UDL5004");
|
|
3017
|
-
}
|
|
3018
|
-
else if (instrument.update?.fields.includes(aggregate.parentField)) {
|
|
3019
|
-
add([...aggregateBase, "parentField"], `${instrument.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`, "UDL5004");
|
|
3020
|
-
}
|
|
3021
|
-
if (!child) {
|
|
3022
|
-
add([...aggregateBase, "childInstrumentId"], `aggregate references unknown child instrument ${aggregate.childInstrumentId}`, "UDL5004");
|
|
3023
|
-
return;
|
|
3024
|
-
}
|
|
3025
|
-
if (sum) {
|
|
3026
|
-
if (!childField) {
|
|
3027
|
-
add([...aggregateBase, "childField"], `aggregate references unknown child field ${sum.childField}`, "UDL5004");
|
|
3028
|
-
}
|
|
3029
|
-
else if (!isMoneySchema(childField) && !isIntegerSum) {
|
|
3030
|
-
add([...aggregateBase, "childField"], `${child.id}.${sum.childField} must be a money field`, "UDL5004");
|
|
3031
|
-
}
|
|
3032
|
-
else if (child.update?.fields.includes(sum.childField)) {
|
|
3033
|
-
add([...aggregateBase, "childField"], `${child.id}.${sum.childField} cannot be updateable while it contributes to an aggregate`, "UDL5004");
|
|
3034
|
-
}
|
|
3035
|
-
}
|
|
3036
|
-
const window = "count" in aggregate ? aggregate.window : undefined;
|
|
3037
|
-
if (window) {
|
|
3038
|
-
const windowField = child.fields[window.field];
|
|
3039
|
-
if (!windowField) {
|
|
3040
|
-
add([...aggregateBase, "window", "field"], `aggregate window references unknown child field ${window.field}`, "UDL5004");
|
|
3041
|
-
}
|
|
3042
|
-
else if (!isDateTimeFormat(windowField.format) ||
|
|
3043
|
-
!child.required.includes(window.field)) {
|
|
3044
|
-
add([...aggregateBase, "window", "field"], `${child.id}.${window.field} must be a required date-time field to window a count`, "UDL5004");
|
|
3045
|
-
}
|
|
3046
|
-
else if (child.update?.fields.includes(window.field)) {
|
|
3047
|
-
add([...aggregateBase, "window", "field"], `${child.id}.${window.field} cannot be updateable while it windows an aggregate`, "UDL5004");
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
const refField = child.fields[aggregate.childRefField];
|
|
3051
|
-
if (!refField) {
|
|
3052
|
-
add([...aggregateBase, "childRefField"], `aggregate references unknown child ref field ${aggregate.childRefField}`, "UDL5004");
|
|
3053
|
-
}
|
|
3054
|
-
else if (!references.accepts(refField, instrument.idPrefix)) {
|
|
3055
|
-
add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} must reference ${instrument.id}`, "UDL5004");
|
|
3056
|
-
}
|
|
3057
|
-
else if (child.update?.fields.includes(aggregate.childRefField)) {
|
|
3058
|
-
add([...aggregateBase, "childRefField"], `${child.id}.${aggregate.childRefField} cannot be updateable while it links an aggregate`, "UDL5004");
|
|
3059
|
-
}
|
|
3060
|
-
addDuplicateIssues(aggregate.childStatuses, [...aggregateBase, "childStatuses"], "child status", add);
|
|
3061
|
-
aggregate.childStatuses.forEach((status, statusIndex) => {
|
|
3062
|
-
if (!child.lifecycle.states.includes(status)) {
|
|
3063
|
-
add([...aggregateBase, "childStatuses", statusIndex], `aggregate consumes unknown ${child.id} status ${status}`, "UDL5004");
|
|
3064
|
-
}
|
|
3065
|
-
});
|
|
3066
|
-
});
|
|
3067
|
-
}
|
|
3068
|
-
function exampleInputSchema(instrument, action, definition) {
|
|
3069
|
-
if (action !== "create") {
|
|
3070
|
-
return (definition.input ?? {
|
|
3071
|
-
additionalProperties: false,
|
|
3072
|
-
properties: {},
|
|
3073
|
-
type: "object",
|
|
3074
|
-
});
|
|
3075
|
-
}
|
|
3076
|
-
// Derived and machine-computed fields leave the authorable create surface:
|
|
3077
|
-
// the runtime writes them from referenced instances or lifecycle events.
|
|
3078
|
-
const boundKeys = new Set([
|
|
3079
|
-
...(definition.requiresRefs ?? []).flatMap((gate) => Object.keys(gate.bind ?? {})),
|
|
3080
|
-
...Object.values(instrument.actions).flatMap((candidate) => candidate.setsAt ? [candidate.setsAt.field] : []),
|
|
3081
|
-
...(instrument.derivedAmounts ?? []).map((amount) => amount.field),
|
|
3082
|
-
...(instrument.feeRules ?? []).flatMap((fee) => fee.rule.kind === "exact" ? [] : [fee.amountField]),
|
|
3083
|
-
]);
|
|
3084
|
-
const properties = Object.fromEntries(Object.entries(instrument.fields).filter(([key]) => !boundKeys.has(key)));
|
|
3085
|
-
const required = instrument.required.filter((key) => !boundKeys.has(key));
|
|
3086
|
-
if (instrument.subject) {
|
|
3087
|
-
properties.subject = { minLength: 1, type: "string" };
|
|
3088
|
-
required.push("subject");
|
|
3089
|
-
}
|
|
3090
|
-
return {
|
|
3091
|
-
additionalProperties: false,
|
|
3092
|
-
properties,
|
|
3093
|
-
required,
|
|
3094
|
-
type: "object",
|
|
3095
|
-
};
|
|
3096
|
-
}
|
|
3097
|
-
function exampleInputForUdlValidation(instrument, input) {
|
|
3098
|
-
const camelId = instrument.id.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
3099
|
-
const envelopeKeys = new Set([
|
|
3100
|
-
"actorAccountId",
|
|
3101
|
-
"tenantId",
|
|
3102
|
-
"productId",
|
|
3103
|
-
`${camelId}Id`,
|
|
3104
|
-
]);
|
|
3105
|
-
return Object.fromEntries(Object.entries(input).filter(([key]) => !envelopeKeys.has(key)));
|
|
3106
|
-
}
|
|
3107
|
-
/** The measure half of an aggregate's identity: summed field, or count(+window). */
|
|
3108
|
-
function aggregateMeasureKey(aggregate) {
|
|
3109
|
-
return "childField" in aggregate
|
|
3110
|
-
? aggregate.childField
|
|
3111
|
-
: `count${aggregate.window ? `[${aggregate.window.field} per ${aggregate.window.days}d]` : ""}`;
|
|
3112
|
-
}
|
|
3113
|
-
function isMoneySchema(schema) {
|
|
3114
|
-
return (schema.pattern === positiveMoneyPattern ||
|
|
3115
|
-
schema.pattern === nonNegativeMoneyPattern);
|
|
3116
|
-
}
|
|
3117
|
-
function isCurrencySchema(schema) {
|
|
3118
|
-
return schema.pattern === currencyPattern;
|
|
3119
|
-
}
|
|
3120
|
-
/** Whether a reconcile exception child owns a reference back to its parent. */
|
|
3121
|
-
export function reconcileExceptionChildProblems(parentIdPrefix, child, exception, references) {
|
|
3122
|
-
const problems = [];
|
|
3123
|
-
const refSchema = child.fields[exception.refField];
|
|
3124
|
-
if (refSchema === undefined || !references.accepts(refSchema, parentIdPrefix))
|
|
3125
|
-
problems.push("UDL5007");
|
|
3126
|
-
const amountSchema = child.fields[exception.amountField];
|
|
3127
|
-
if (amountSchema === undefined ||
|
|
3128
|
-
!child.required.includes(exception.amountField))
|
|
3129
|
-
problems.push("UDL5009");
|
|
3130
|
-
else if (!isMoneySchema(amountSchema))
|
|
3131
|
-
problems.push("UDL5010");
|
|
3132
|
-
const reasonSchema = child.fields[exception.reasonField];
|
|
3133
|
-
if (reasonSchema === undefined ||
|
|
3134
|
-
!child.required.includes(exception.reasonField))
|
|
3135
|
-
problems.push("UDL5011");
|
|
3136
|
-
else if (reasonSchema.type !== "string" ||
|
|
3137
|
-
isMoneySchema(reasonSchema) ||
|
|
3138
|
-
"pattern" in reasonSchema ||
|
|
3139
|
-
"format" in reasonSchema ||
|
|
3140
|
-
"enum" in reasonSchema)
|
|
3141
|
-
problems.push("UDL5012");
|
|
3142
|
-
return problems;
|
|
3143
|
-
}
|
|
3144
|
-
export function isReconcileExceptionChild(parentIdPrefix, child, exception, references) {
|
|
3145
|
-
return (reconcileExceptionChildProblems(parentIdPrefix, child, exception, references).length === 0);
|
|
3146
|
-
}
|
|
3147
|
-
function reconcileExceptionProblemMessage(code, parent, child, exception) {
|
|
3148
|
-
switch (code) {
|
|
3149
|
-
case "UDL5007":
|
|
3150
|
-
return `${child.id}.${exception.refField} must reference ${parent.id} to carry its breaks`;
|
|
3151
|
-
case "UDL5009":
|
|
3152
|
-
return `${child.id}.${exception.amountField} must be a required child money field`;
|
|
3153
|
-
case "UDL5010":
|
|
3154
|
-
return `${child.id}.${exception.amountField} must be a money field`;
|
|
3155
|
-
case "UDL5011":
|
|
3156
|
-
return `${child.id}.${exception.reasonField} must be a required child text field`;
|
|
3157
|
-
case "UDL5012":
|
|
3158
|
-
return `${child.id}.${exception.reasonField} must be a text field`;
|
|
3159
|
-
default:
|
|
3160
|
-
return "reconcile exception child is invalid";
|
|
3161
|
-
}
|
|
3162
|
-
}
|
|
3163
|
-
/**
|
|
3164
|
-
* One sample id per prefix, built here and nowhere else. The probe below asks
|
|
3165
|
-
* a schema two questions and the answers are only comparable while both
|
|
3166
|
-
* samples differ in the prefix alone, so the environment segment and the body
|
|
3167
|
-
* are written once. The value is disposable: no document ever carries it, and
|
|
3168
|
-
* a host's real id grammar stays the host's business.
|
|
3169
|
-
*/
|
|
3170
|
-
/** Classify the sealed ID pattern without compiling document-authored regexes. */
|
|
3171
|
-
export function openReferenceShapeBudget() {
|
|
3172
|
-
const answers = new WeakMap();
|
|
3173
|
-
let probes = 0;
|
|
3174
|
-
let exhausted = false;
|
|
3175
|
-
return {
|
|
3176
|
-
accepts(schema, prefix) {
|
|
3177
|
-
const seen = answers.get(schema);
|
|
3178
|
-
const cached = seen?.get(prefix);
|
|
3179
|
-
if (cached !== undefined)
|
|
3180
|
-
return cached;
|
|
3181
|
-
if (probes >= UDL_LIMITS.maxSchemaProbes) {
|
|
3182
|
-
exhausted = true;
|
|
3183
|
-
return false;
|
|
3184
|
-
}
|
|
3185
|
-
probes += 1;
|
|
3186
|
-
const answer = referencePatternPrefix(schema) === prefix;
|
|
3187
|
-
if (seen)
|
|
3188
|
-
seen.set(prefix, answer);
|
|
3189
|
-
else
|
|
3190
|
-
answers.set(schema, new Map([[prefix, answer]]));
|
|
3191
|
-
return answer;
|
|
3192
|
-
},
|
|
3193
|
-
get exhausted() {
|
|
3194
|
-
return exhausted;
|
|
3195
|
-
},
|
|
646
|
+
calls.set(`${inst.id}.${actionName}`, targets);
|
|
647
|
+
}
|
|
648
|
+
if (!issues.some((i) => i.path.startsWith(base)))
|
|
649
|
+
issues.push(...analyzeInstrumentFinance(inst, document).map((i) => issue("UDL4001", base + i.path, i.message)));
|
|
650
|
+
}
|
|
651
|
+
const active = new Set();
|
|
652
|
+
const depths = new Map();
|
|
653
|
+
const visit = (key) => {
|
|
654
|
+
if (active.has(key)) {
|
|
655
|
+
add("$.instruments", `invocation cycle at ${key}`, "UDL2010");
|
|
656
|
+
return 4097;
|
|
657
|
+
}
|
|
658
|
+
const known = depths.get(key);
|
|
659
|
+
if (known !== undefined)
|
|
660
|
+
return known;
|
|
661
|
+
active.add(key);
|
|
662
|
+
const size = 1 +
|
|
663
|
+
(calls.get(key) ?? []).reduce((n, edge) => n + edge.count * Math.max(0, ...edge.targets.map(visit)), 0);
|
|
664
|
+
active.delete(key);
|
|
665
|
+
depths.set(key, size);
|
|
666
|
+
if (size > 4096)
|
|
667
|
+
add("$.instruments", `invocation ${key} exceeds 4096 actions`, "UDL2010");
|
|
668
|
+
return size;
|
|
3196
669
|
};
|
|
670
|
+
for (const key of calls.keys())
|
|
671
|
+
visit(key);
|
|
672
|
+
return issues.length ? { ok: false, issues } : { ok: true, value: document };
|
|
3197
673
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
};
|
|
3204
|
-
function sealedFormatErrors(instance, schema, instanceLocation = "", schemaLocation = "") {
|
|
3205
|
-
const errors = [];
|
|
3206
|
-
const format = typeof schema.format === "string"
|
|
3207
|
-
? sealedFormatValidators[schema.format]
|
|
3208
|
-
: undefined;
|
|
3209
|
-
if (format && typeof instance === "string" && !format(instance)) {
|
|
3210
|
-
errors.push({
|
|
3211
|
-
error: `String does not match format "${schema.format}".`,
|
|
3212
|
-
instanceLocation,
|
|
3213
|
-
keyword: "format",
|
|
3214
|
-
keywordLocation: `${schemaLocation}/format`,
|
|
3215
|
-
});
|
|
3216
|
-
}
|
|
3217
|
-
if (schema.properties &&
|
|
3218
|
-
instance !== null &&
|
|
3219
|
-
typeof instance === "object" &&
|
|
3220
|
-
!Array.isArray(instance)) {
|
|
3221
|
-
for (const [key, childSchema] of Object.entries(schema.properties)) {
|
|
3222
|
-
if (childSchema === false ||
|
|
3223
|
-
childSchema === true ||
|
|
3224
|
-
!Object.hasOwn(instance, key)) {
|
|
3225
|
-
continue;
|
|
3226
|
-
}
|
|
3227
|
-
errors.push(...sealedFormatErrors(instance[key], childSchema, `${instanceLocation}/${jsonPointerSegment(key)}`, `${schemaLocation}/properties/${jsonPointerSegment(key)}`));
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
if (schema.additionalProperties &&
|
|
3231
|
-
typeof schema.additionalProperties === "object" &&
|
|
3232
|
-
!Array.isArray(schema.additionalProperties) &&
|
|
3233
|
-
instance !== null &&
|
|
3234
|
-
typeof instance === "object" &&
|
|
3235
|
-
!Array.isArray(instance)) {
|
|
3236
|
-
const declared = new Set(Object.keys(schema.properties ?? {}));
|
|
3237
|
-
for (const [key, value] of Object.entries(instance)) {
|
|
3238
|
-
if (declared.has(key))
|
|
3239
|
-
continue;
|
|
3240
|
-
errors.push(...sealedFormatErrors(value, schema.additionalProperties, `${instanceLocation}/${jsonPointerSegment(key)}`, `${schemaLocation}/additionalProperties`));
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
const itemSchema = schema.items;
|
|
3244
|
-
if (itemSchema !== undefined &&
|
|
3245
|
-
typeof itemSchema === "object" &&
|
|
3246
|
-
!Array.isArray(itemSchema) &&
|
|
3247
|
-
Array.isArray(instance)) {
|
|
3248
|
-
instance.forEach((value, index) => {
|
|
3249
|
-
errors.push(...sealedFormatErrors(value, itemSchema, `${instanceLocation}/${index}`, `${schemaLocation}/items`));
|
|
3250
|
-
});
|
|
3251
|
-
}
|
|
3252
|
-
return errors;
|
|
3253
|
-
}
|
|
3254
|
-
function isDateTimeFormat(format) {
|
|
3255
|
-
return format === sealedDateTimeFormat;
|
|
3256
|
-
}
|
|
3257
|
-
function jsonPointerSegment(value) {
|
|
3258
|
-
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
3259
|
-
}
|
|
3260
|
-
function recordValue(value) {
|
|
3261
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
3262
|
-
? value
|
|
3263
|
-
: {};
|
|
3264
|
-
}
|
|
3265
|
-
function addDuplicateIssues(values, path, label, add) {
|
|
3266
|
-
const firstIndex = new Map();
|
|
3267
|
-
values.forEach((value, index) => {
|
|
3268
|
-
const previous = firstIndex.get(value);
|
|
3269
|
-
if (previous === undefined)
|
|
3270
|
-
firstIndex.set(value, index);
|
|
3271
|
-
else
|
|
3272
|
-
add([...path, index], `duplicate ${label} ${value}; first declared at index ${previous}`, "UDL2001");
|
|
3273
|
-
});
|
|
3274
|
-
}
|
|
3275
|
-
function jsonPath(path) {
|
|
3276
|
-
let result = "$";
|
|
3277
|
-
for (const segment of path) {
|
|
3278
|
-
if (typeof segment === "number")
|
|
3279
|
-
result += `[${segment}]`;
|
|
3280
|
-
else if (typeof segment === "string" &&
|
|
3281
|
-
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)) {
|
|
3282
|
-
result += `.${segment}`;
|
|
3283
|
-
}
|
|
3284
|
-
else
|
|
3285
|
-
result += `[${JSON.stringify(String(segment))}]`;
|
|
3286
|
-
}
|
|
3287
|
-
return result;
|
|
3288
|
-
}
|
|
3289
|
-
function sortObject(value) {
|
|
3290
|
-
if (Array.isArray(value))
|
|
3291
|
-
return value.map(sortObject);
|
|
3292
|
-
if (value !== null && typeof value === "object")
|
|
3293
|
-
return Object.fromEntries(Object.entries(value)
|
|
3294
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
3295
|
-
.map(([key, item]) => [key, sortObject(item)]));
|
|
3296
|
-
return value;
|
|
674
|
+
export function assertValidUdl(value) {
|
|
675
|
+
const result = validateUdl(value);
|
|
676
|
+
if (!result.ok)
|
|
677
|
+
throw new UdlError(result.issues);
|
|
678
|
+
return result.value;
|
|
3297
679
|
}
|
|
3298
680
|
//# sourceMappingURL=validation.js.map
|