@hyperscale0/udl 2.0.4 → 2.2.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 +23 -0
- package/README.md +15 -1
- package/conformance/invalid/call-binds-results.expected.json +10 -0
- package/conformance/invalid/call-binds-results.udl +314 -0
- package/conformance/invalid/call-unknown-action.expected.json +10 -0
- package/conformance/invalid/call-unknown-action.udl +314 -0
- package/conformance/invalid/leaf-effect-mismatch.expected.json +10 -0
- package/conformance/invalid/leaf-effect-mismatch.udl +314 -0
- package/conformance/invalid/piece-plan-without-partition.expected.json +10 -0
- package/conformance/invalid/piece-plan-without-partition.udl +305 -0
- package/conformance/invalid/private-action-independent-approval.expected.json +10 -0
- package/conformance/invalid/private-action-independent-approval.udl +314 -0
- package/conformance/invalid/unfund-order-not-reversed.expected.json +10 -0
- package/conformance/invalid/unfund-order-not-reversed.udl +314 -0
- package/conformance/valid/piece-plan-calls.expected.json +6 -0
- package/conformance/valid/piece-plan-calls.udl +314 -0
- package/dist/diagnostics.d.ts +36 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +36 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/effects.d.ts +26 -3
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +962 -8
- package/dist/effects.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/schema.d.ts +594 -11
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +111 -1
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +289 -4
- package/dist/validation.js.map +1 -1
- package/docs/assets/brand/manifest.json +30 -0
- package/docs/assets/brand/udl-horizontal-white.svg +1 -0
- package/docs/assets/brand/udl-horizontal.svg +1 -0
- package/docs/assets/brand/udl-stacked-white.svg +1 -0
- package/docs/assets/brand/udl-stacked.svg +1 -0
- package/docs/assets/udl.svg +7 -14
- package/docs/llms-full.txt +199 -32
- package/docs/llms.txt +1 -1
- package/docs/reference/clauses.md +162 -1
- package/docs/reference/cli.md +1 -1
- package/docs/reference/diagnostics.md +38 -32
- package/package.json +2 -2
- package/spec/udl.schema.json +321 -1
- package/src/diagnostics.ts +36 -0
- package/src/effects.ts +1672 -9
- package/src/index.ts +15 -0
- package/src/limits.ts +2 -0
- package/src/schema.ts +149 -2
- package/src/validation.ts +470 -5
package/src/effects.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { issue, type UdlIssue } from "./diagnostics.js";
|
|
2
|
+
import { UDL_LIMITS } from "./limits.js";
|
|
3
|
+
import {
|
|
4
|
+
udlClauseVocabulary,
|
|
5
|
+
udlEffectKinds,
|
|
6
|
+
type UdlEffectKind,
|
|
7
|
+
type UdlBinding,
|
|
8
|
+
type UdlCall,
|
|
9
|
+
type UdlInstrument,
|
|
10
|
+
type UdlMove,
|
|
11
|
+
type UdlPiece,
|
|
12
|
+
type UdlPrivateAction,
|
|
13
|
+
type UdlPrivateParameterKind,
|
|
14
|
+
type UdlStep,
|
|
15
|
+
} from "./schema.js";
|
|
9
16
|
|
|
10
|
-
export type UdlEffectKind
|
|
17
|
+
export { udlEffectKinds, type UdlEffectKind } from "./schema.js";
|
|
11
18
|
|
|
12
19
|
type EffectDescriptor = {
|
|
13
20
|
readonly kind: UdlEffectKind;
|
|
@@ -161,3 +168,1659 @@ function recordValue(
|
|
|
161
168
|
? (value as Readonly<Record<string, unknown>>)
|
|
162
169
|
: undefined;
|
|
163
170
|
}
|
|
171
|
+
|
|
172
|
+
export interface ResolvedActionPlanLeaf {
|
|
173
|
+
readonly effects: readonly {
|
|
174
|
+
readonly kind: UdlEffectKind;
|
|
175
|
+
readonly signature: string;
|
|
176
|
+
}[];
|
|
177
|
+
readonly evidence: string;
|
|
178
|
+
readonly originPath: readonly string[];
|
|
179
|
+
readonly step:
|
|
180
|
+
| UdlMove
|
|
181
|
+
| (Omit<UdlStep, "operation"> & {
|
|
182
|
+
readonly operation: UdlStep["operation"] | "payout.create";
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ResolvedActionPlan {
|
|
187
|
+
readonly action: string;
|
|
188
|
+
readonly effects: DerivedUdlEffects;
|
|
189
|
+
readonly leaves: readonly ResolvedActionPlanLeaf[];
|
|
190
|
+
readonly pieceId?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface ResolvedActionPlansResult {
|
|
194
|
+
readonly issues: readonly UdlIssue[];
|
|
195
|
+
readonly plans: readonly ResolvedActionPlan[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function deriveActionEffectsFromPlan(
|
|
199
|
+
leaves: readonly ResolvedActionPlanLeaf[],
|
|
200
|
+
): DerivedUdlEffects {
|
|
201
|
+
const effects: Partial<Record<UdlEffectKind, EffectRow[]>> = {};
|
|
202
|
+
for (const leaf of leaves) {
|
|
203
|
+
for (const eff of leaf.effects) {
|
|
204
|
+
const kind = eff.kind;
|
|
205
|
+
(effects[kind] ??= []).push({
|
|
206
|
+
signature: eff.signature,
|
|
207
|
+
source: leaf.originPath.join("."),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return effects;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function combineDerivedEffects(
|
|
215
|
+
direct: DerivedUdlEffects,
|
|
216
|
+
leaves: DerivedUdlEffects,
|
|
217
|
+
): DerivedUdlEffects {
|
|
218
|
+
const result: Partial<Record<UdlEffectKind, EffectRow[]>> = {};
|
|
219
|
+
for (const kind of udlEffectKinds) {
|
|
220
|
+
const directRows = direct[kind] ?? [];
|
|
221
|
+
const leafRows = leaves[kind] ?? [];
|
|
222
|
+
if (directRows.length > 0 || leafRows.length > 0) {
|
|
223
|
+
result[kind] = [...directRows, ...leafRows];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function encodeOriginPathKey(originPath: readonly string[]): string {
|
|
230
|
+
const parts = originPath.map((seg) => `${seg.length}_${seg}`);
|
|
231
|
+
return `k_${parts.join("_")}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function expectedLeafEffects(
|
|
235
|
+
step: UdlStep | UdlMove,
|
|
236
|
+
): readonly { readonly kind: UdlEffectKind; readonly signature: string }[] {
|
|
237
|
+
if (step.operation === "internal_transfer.reserve") {
|
|
238
|
+
const moveClass = movementClass(step);
|
|
239
|
+
return [
|
|
240
|
+
{ kind: "moves", signature: `moves.${moveClass}` },
|
|
241
|
+
{ kind: "holds", signature: "holds.reserve" },
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
if (step.operation.startsWith("internal_transfer.")) {
|
|
245
|
+
const moveClass = movementClass(step);
|
|
246
|
+
return [{ kind: "moves", signature: `moves.${moveClass}` }];
|
|
247
|
+
}
|
|
248
|
+
if (step.operation === "account.escrow.provision") {
|
|
249
|
+
return [{ kind: "holds", signature: "holds.escrow" }];
|
|
250
|
+
}
|
|
251
|
+
if (step.operation === "account.freeze") {
|
|
252
|
+
return [{ kind: "holds", signature: "holds.freeze" }];
|
|
253
|
+
}
|
|
254
|
+
if (step.operation === "account.unfreeze") {
|
|
255
|
+
return [{ kind: "holds", signature: "holds.unfreeze" }];
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const accountPattern = "^acct_(sandbox|live)_[a-z0-9]{8,64}$";
|
|
261
|
+
const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
|
|
262
|
+
const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
|
|
263
|
+
const currencyPattern = "^[A-Z]{3}$";
|
|
264
|
+
|
|
265
|
+
function isMoneySchema(schema: unknown): boolean {
|
|
266
|
+
if (!schema || typeof schema !== "object") return false;
|
|
267
|
+
const s = schema as Record<string, unknown>;
|
|
268
|
+
return (
|
|
269
|
+
s.type === "string" &&
|
|
270
|
+
(s.pattern === positiveMoneyPattern ||
|
|
271
|
+
s.pattern === nonNegativeMoneyPattern)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isAccountSchema(schema: unknown): boolean {
|
|
276
|
+
if (!schema || typeof schema !== "object") return false;
|
|
277
|
+
const s = schema as Record<string, unknown>;
|
|
278
|
+
return s.type === "string" && s.pattern === accountPattern;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isStringSchema(schema: unknown): boolean {
|
|
282
|
+
if (!schema || typeof schema !== "object") return false;
|
|
283
|
+
const s = schema as Record<string, unknown>;
|
|
284
|
+
return s.type === "string";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isDeniedCrossTenantSegment(segment: string): boolean {
|
|
288
|
+
return (
|
|
289
|
+
segment === "tenant" ||
|
|
290
|
+
segment === "tenantId" ||
|
|
291
|
+
segment === "org" ||
|
|
292
|
+
segment === "environment"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function checkCrossTenant(rawBind: string): boolean {
|
|
297
|
+
if (rawBind.startsWith("/")) return true;
|
|
298
|
+
const segments = rawBind.split(/[./]/);
|
|
299
|
+
return segments.some(isDeniedCrossTenantSegment);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function instrumentConcreteCurrency(
|
|
303
|
+
instrument: UdlInstrument,
|
|
304
|
+
): string | undefined {
|
|
305
|
+
const mutableFields = new Set(instrument.update?.fields ?? []);
|
|
306
|
+
const allUpdatedFields = new Set(
|
|
307
|
+
Object.values(instrument.actions).flatMap((a) => a.updates ?? []),
|
|
308
|
+
);
|
|
309
|
+
const currencyFields = Object.entries(instrument.fields).filter(
|
|
310
|
+
([, schema]) =>
|
|
311
|
+
typeof schema === "object" &&
|
|
312
|
+
schema !== null &&
|
|
313
|
+
(schema as Record<string, unknown>).pattern === currencyPattern,
|
|
314
|
+
);
|
|
315
|
+
if (currencyFields.length === 1) {
|
|
316
|
+
const [fieldName, fieldDefObj] = currencyFields[0]!;
|
|
317
|
+
if (mutableFields.has(fieldName) || allUpdatedFields.has(fieldName)) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
const fieldDef = fieldDefObj as Record<string, unknown>;
|
|
321
|
+
if (
|
|
322
|
+
typeof fieldDef.const === "string" &&
|
|
323
|
+
/^[A-Z]{3}$/.test(fieldDef.const)
|
|
324
|
+
) {
|
|
325
|
+
return fieldDef.const;
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
Array.isArray(fieldDef.enum) &&
|
|
329
|
+
fieldDef.enum.length === 1 &&
|
|
330
|
+
typeof fieldDef.enum[0] === "string" &&
|
|
331
|
+
/^[A-Z]{3}$/.test(fieldDef.enum[0])
|
|
332
|
+
) {
|
|
333
|
+
return fieldDef.enum[0];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function pieceCurrency(
|
|
340
|
+
piece: UdlPiece,
|
|
341
|
+
instrument: UdlInstrument,
|
|
342
|
+
): string | undefined {
|
|
343
|
+
const amountSchema = instrument.fields[piece.amount] as
|
|
344
|
+
| Record<string, unknown>
|
|
345
|
+
| undefined;
|
|
346
|
+
if (
|
|
347
|
+
amountSchema &&
|
|
348
|
+
typeof amountSchema["x-hyperscale-currency"] === "string" &&
|
|
349
|
+
/^[A-Z]{3}$/.test(amountSchema["x-hyperscale-currency"])
|
|
350
|
+
) {
|
|
351
|
+
return amountSchema["x-hyperscale-currency"];
|
|
352
|
+
}
|
|
353
|
+
return instrumentConcreteCurrency(instrument);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function resolveUdlActionPlans(
|
|
357
|
+
instrument: UdlInstrument,
|
|
358
|
+
): ResolvedActionPlansResult {
|
|
359
|
+
const issues: UdlIssue[] = [];
|
|
360
|
+
const plans: ResolvedActionPlan[] = [];
|
|
361
|
+
|
|
362
|
+
if (instrument.piecePlan) {
|
|
363
|
+
for (const piece of instrument.piecePlan.pieces) {
|
|
364
|
+
if (pieceCurrency(piece, instrument) === undefined) {
|
|
365
|
+
issues.push(
|
|
366
|
+
issue(
|
|
367
|
+
"UDL4002",
|
|
368
|
+
`$.piecePlan.pieces.${piece.id}`,
|
|
369
|
+
`piece ${piece.id} requires a valid concrete ISO currency from amount tag or immutable instrument currency declaration`,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const libraryMap = instrument.actionLibrary ?? {};
|
|
377
|
+
|
|
378
|
+
function hasLibrary(lib: string): boolean {
|
|
379
|
+
if (!instrument.actionLibrary) return false;
|
|
380
|
+
return Object.hasOwn(instrument.actionLibrary, lib);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getAction(lib: string, act: string): UdlPrivateAction | undefined {
|
|
384
|
+
if (!instrument.actionLibrary) return undefined;
|
|
385
|
+
if (!Object.hasOwn(instrument.actionLibrary, lib)) return undefined;
|
|
386
|
+
const mod = instrument.actionLibrary[lib];
|
|
387
|
+
if (
|
|
388
|
+
!mod ||
|
|
389
|
+
typeof mod !== "object" ||
|
|
390
|
+
!mod.actions ||
|
|
391
|
+
typeof mod.actions !== "object"
|
|
392
|
+
)
|
|
393
|
+
return undefined;
|
|
394
|
+
if (!Object.hasOwn(mod.actions, act)) return undefined;
|
|
395
|
+
return mod.actions[act];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check all private actions for structure, complete order, collisions, and authority
|
|
399
|
+
for (const [libKey, libModule] of Object.entries(libraryMap)) {
|
|
400
|
+
const libPath = `$.actionLibrary.${libKey}`;
|
|
401
|
+
const actionKeys = Object.keys(libModule.actions);
|
|
402
|
+
const orderSet = new Set(libModule.actionOrder);
|
|
403
|
+
|
|
404
|
+
if (new Set(libModule.actionOrder).size !== libModule.actionOrder.length) {
|
|
405
|
+
issues.push(
|
|
406
|
+
issue(
|
|
407
|
+
"UDL2010",
|
|
408
|
+
`${libPath}.actionOrder`,
|
|
409
|
+
`actionOrder in library ${libKey} contains duplicate action names`,
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
for (const actionKey of actionKeys) {
|
|
414
|
+
if (!orderSet.has(actionKey)) {
|
|
415
|
+
issues.push(
|
|
416
|
+
issue(
|
|
417
|
+
"UDL2010",
|
|
418
|
+
`${libPath}.actionOrder`,
|
|
419
|
+
`action ${actionKey} is declared in library ${libKey} but missing from actionOrder`,
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const orderKey of libModule.actionOrder) {
|
|
425
|
+
if (!Object.hasOwn(libModule.actions, orderKey)) {
|
|
426
|
+
issues.push(
|
|
427
|
+
issue(
|
|
428
|
+
"UDL2010",
|
|
429
|
+
`${libPath}.actionOrder`,
|
|
430
|
+
`actionOrder in library ${libKey} references unknown action ${orderKey}`,
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const [actKey, privAction] of Object.entries(libModule.actions)) {
|
|
437
|
+
const actPath = `${libPath}.actions.${actKey}`;
|
|
438
|
+
if (privAction.approval === "independent") {
|
|
439
|
+
issues.push(
|
|
440
|
+
issue(
|
|
441
|
+
"UDL2012",
|
|
442
|
+
`${actPath}.approval`,
|
|
443
|
+
`private action ${libKey}.${actKey} cannot declare independent approval`,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (privAction.recovery === "external") {
|
|
448
|
+
issues.push(
|
|
449
|
+
issue(
|
|
450
|
+
"UDL2012",
|
|
451
|
+
`${actPath}.recovery`,
|
|
452
|
+
`private action ${libKey}.${actKey} cannot declare external recovery`,
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const leafIds = privAction.leaves.map((l) => l.id);
|
|
458
|
+
const callIds = privAction.calls.map((c) => c.id);
|
|
459
|
+
const leafIdSet = new Set(leafIds);
|
|
460
|
+
const callIdSet = new Set(callIds);
|
|
461
|
+
|
|
462
|
+
if (leafIdSet.size !== leafIds.length) {
|
|
463
|
+
issues.push(
|
|
464
|
+
issue(
|
|
465
|
+
"UDL2010",
|
|
466
|
+
`${actPath}.leaves`,
|
|
467
|
+
`duplicate leaf id in private action ${libKey}.${actKey}`,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (callIdSet.size !== callIds.length) {
|
|
472
|
+
issues.push(
|
|
473
|
+
issue(
|
|
474
|
+
"UDL2010",
|
|
475
|
+
`${actPath}.calls`,
|
|
476
|
+
`duplicate call id in private action ${libKey}.${actKey}`,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const id of leafIds) {
|
|
482
|
+
if (callIdSet.has(id)) {
|
|
483
|
+
issues.push(
|
|
484
|
+
issue(
|
|
485
|
+
"UDL2010",
|
|
486
|
+
`${actPath}.order`,
|
|
487
|
+
`collision between leaf id and call id ${id} in private action ${libKey}.${actKey}`,
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const allIds = new Set([...leafIds, ...callIds]);
|
|
494
|
+
const orderIds = privAction.order;
|
|
495
|
+
if (
|
|
496
|
+
orderIds.length !== allIds.size ||
|
|
497
|
+
!orderIds.every((id) => allIds.has(id)) ||
|
|
498
|
+
new Set(orderIds).size !== orderIds.length
|
|
499
|
+
) {
|
|
500
|
+
issues.push(
|
|
501
|
+
issue(
|
|
502
|
+
"UDL2010",
|
|
503
|
+
`${actPath}.order`,
|
|
504
|
+
`order in private action ${libKey}.${actKey} must be an exact permutation of leaf and call ids`,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
for (const call of privAction.calls) {
|
|
510
|
+
const [targetLib, targetAct] = call.action.split(".");
|
|
511
|
+
if (!targetLib || !targetAct || !hasLibrary(targetLib)) {
|
|
512
|
+
issues.push(
|
|
513
|
+
issue(
|
|
514
|
+
"UDL2010",
|
|
515
|
+
`${actPath}.calls.${call.id}.action`,
|
|
516
|
+
`call ${call.id} references unknown library ${targetLib ?? ""}`,
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
} else {
|
|
520
|
+
const targetActionDef = getAction(targetLib, targetAct);
|
|
521
|
+
if (!targetActionDef) {
|
|
522
|
+
issues.push(
|
|
523
|
+
issue(
|
|
524
|
+
"UDL2010",
|
|
525
|
+
`${actPath}.calls.${call.id}.action`,
|
|
526
|
+
`call ${call.id} references unknown action ${targetAct} in library ${targetLib}`,
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
} else {
|
|
530
|
+
for (const key of Object.keys(call.bind)) {
|
|
531
|
+
if (!Object.hasOwn(targetActionDef.parameters, key)) {
|
|
532
|
+
issues.push(
|
|
533
|
+
issue(
|
|
534
|
+
"UDL2011",
|
|
535
|
+
`${actPath}.calls.${call.id}.bind.${key}`,
|
|
536
|
+
`unexpected parameter ${key} in call to ${call.action}`,
|
|
537
|
+
),
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const paramName of Object.keys(targetActionDef.parameters)) {
|
|
542
|
+
if (!Object.hasOwn(call.bind, paramName)) {
|
|
543
|
+
issues.push(
|
|
544
|
+
issue(
|
|
545
|
+
"UDL2011",
|
|
546
|
+
`${actPath}.calls.${call.id}.bind.${paramName}`,
|
|
547
|
+
`missing binding for parameter ${paramName} in call to ${call.action}`,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Cycle detection in private actions
|
|
559
|
+
const actionVisited = new Set<string>();
|
|
560
|
+
const recursionStack = new Set<string>();
|
|
561
|
+
|
|
562
|
+
function checkCycle(currentActionRef: string, path: string[]): boolean {
|
|
563
|
+
const [lib, act] = currentActionRef.split(".");
|
|
564
|
+
if (path.length > UDL_LIMITS.maxDepth) {
|
|
565
|
+
issues.push(
|
|
566
|
+
issue(
|
|
567
|
+
"UDL2010",
|
|
568
|
+
`$.actionLibrary.${lib ?? ""}.actions.${act ?? ""}`,
|
|
569
|
+
`action call depth exceeds maximum depth of ${UDL_LIMITS.maxDepth}`,
|
|
570
|
+
),
|
|
571
|
+
);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
actionVisited.add(currentActionRef);
|
|
575
|
+
recursionStack.add(currentActionRef);
|
|
576
|
+
|
|
577
|
+
const actionDef = lib && act ? getAction(lib, act) : undefined;
|
|
578
|
+
if (actionDef) {
|
|
579
|
+
for (const call of actionDef.calls) {
|
|
580
|
+
const [targetLib, targetAct] = call.action.split(".");
|
|
581
|
+
if (!targetLib || !targetAct || !getAction(targetLib, targetAct)) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (recursionStack.has(call.action)) {
|
|
585
|
+
issues.push(
|
|
586
|
+
issue(
|
|
587
|
+
"UDL2010",
|
|
588
|
+
`$.actionLibrary.${lib}.actions.${act}`,
|
|
589
|
+
`action call cycle detected: ${[...path, call.action].join(" -> ")}`,
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
if (!actionVisited.has(call.action)) {
|
|
595
|
+
if (checkCycle(call.action, [...path, call.action])) return true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
recursionStack.delete(currentActionRef);
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const [libKey, libModule] of Object.entries(libraryMap)) {
|
|
604
|
+
for (const actKey of Object.keys(libModule.actions)) {
|
|
605
|
+
const ref = `${libKey}.${actKey}`;
|
|
606
|
+
if (!actionVisited.has(ref)) {
|
|
607
|
+
checkCycle(ref, [ref]);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
type ParamValue =
|
|
613
|
+
| { readonly kind: "instance" }
|
|
614
|
+
| { readonly kind: "piece"; readonly piece: UdlPiece }
|
|
615
|
+
| { readonly kind: "account"; readonly path: string }
|
|
616
|
+
| {
|
|
617
|
+
readonly kind: "money";
|
|
618
|
+
readonly currency?: string | undefined;
|
|
619
|
+
readonly path: string;
|
|
620
|
+
}
|
|
621
|
+
| {
|
|
622
|
+
readonly kind: "text";
|
|
623
|
+
readonly value?: string | undefined;
|
|
624
|
+
readonly path?: string | undefined;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
function resolveBinding(
|
|
628
|
+
rawBind: string,
|
|
629
|
+
scope: ReadonlyMap<string, ParamValue>,
|
|
630
|
+
leafPath: string,
|
|
631
|
+
):
|
|
632
|
+
| {
|
|
633
|
+
readonly binding: UdlBinding;
|
|
634
|
+
readonly currency?: string | undefined;
|
|
635
|
+
readonly kind: UdlPrivateParameterKind;
|
|
636
|
+
readonly path?: string | undefined;
|
|
637
|
+
readonly piece?: UdlPiece | undefined;
|
|
638
|
+
readonly value?: string | undefined;
|
|
639
|
+
}
|
|
640
|
+
| undefined {
|
|
641
|
+
if (rawBind.includes("$results")) {
|
|
642
|
+
issues.push(
|
|
643
|
+
issue(
|
|
644
|
+
"UDL2011",
|
|
645
|
+
leafPath,
|
|
646
|
+
`results is not an implicit binding scope: ${rawBind}`,
|
|
647
|
+
),
|
|
648
|
+
);
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
if (checkCrossTenant(rawBind)) {
|
|
652
|
+
issues.push(
|
|
653
|
+
issue(
|
|
654
|
+
"UDL2012",
|
|
655
|
+
leafPath,
|
|
656
|
+
`cross-tenant field paths are not allowed: ${rawBind}`,
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (rawBind === "$instance") {
|
|
663
|
+
return {
|
|
664
|
+
binding: { from: "instance", path: "instrumentInstanceId" },
|
|
665
|
+
kind: "instance",
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (rawBind === "$piece") {
|
|
670
|
+
const pieceVal = scope.get("piece");
|
|
671
|
+
if (!pieceVal || pieceVal.kind !== "piece") {
|
|
672
|
+
issues.push(
|
|
673
|
+
issue(
|
|
674
|
+
"UDL2011",
|
|
675
|
+
leafPath,
|
|
676
|
+
`$piece reference is not available in caller scope`,
|
|
677
|
+
),
|
|
678
|
+
);
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
binding: { from: "const", value: pieceVal.piece.id },
|
|
683
|
+
kind: "piece",
|
|
684
|
+
piece: pieceVal.piece,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (rawBind.startsWith("$fields.")) {
|
|
689
|
+
const parts = rawBind.slice("$fields.".length).split(".");
|
|
690
|
+
if (parts.length !== 1) {
|
|
691
|
+
issues.push(
|
|
692
|
+
issue(
|
|
693
|
+
"UDL2011",
|
|
694
|
+
leafPath,
|
|
695
|
+
`invalid member access on field: ${rawBind}`,
|
|
696
|
+
),
|
|
697
|
+
);
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
const fieldName = parts[0]!;
|
|
701
|
+
const fieldSchema = instrument.fields[fieldName];
|
|
702
|
+
if (!fieldSchema) {
|
|
703
|
+
issues.push(
|
|
704
|
+
issue(
|
|
705
|
+
"UDL2011",
|
|
706
|
+
leafPath,
|
|
707
|
+
`referenced field ${fieldName} is not declared on instrument`,
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
if (isMoneySchema(fieldSchema)) {
|
|
713
|
+
const cur =
|
|
714
|
+
((fieldSchema as Record<string, unknown>)["x-hyperscale-currency"] as
|
|
715
|
+
| string
|
|
716
|
+
| undefined) ?? instrumentConcreteCurrency(instrument);
|
|
717
|
+
return {
|
|
718
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
719
|
+
currency: cur,
|
|
720
|
+
kind: "money",
|
|
721
|
+
path: `fields.${fieldName}`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (isAccountSchema(fieldSchema)) {
|
|
725
|
+
return {
|
|
726
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
727
|
+
kind: "account",
|
|
728
|
+
path: `fields.${fieldName}`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (isStringSchema(fieldSchema)) {
|
|
732
|
+
return {
|
|
733
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
734
|
+
kind: "text",
|
|
735
|
+
path: `fields.${fieldName}`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
issues.push(
|
|
739
|
+
issue(
|
|
740
|
+
"UDL2011",
|
|
741
|
+
leafPath,
|
|
742
|
+
`field ${fieldName} is not an account, money, or text field`,
|
|
743
|
+
),
|
|
744
|
+
);
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (rawBind.startsWith("$")) {
|
|
749
|
+
const parts = rawBind.slice(1).split(".");
|
|
750
|
+
const paramName = parts[0]!;
|
|
751
|
+
const val = scope.get(paramName);
|
|
752
|
+
if (!val) {
|
|
753
|
+
issues.push(
|
|
754
|
+
issue(
|
|
755
|
+
"UDL2011",
|
|
756
|
+
leafPath,
|
|
757
|
+
`unbound parameter $${paramName} in binding ${rawBind}`,
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
return undefined;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (val.kind === "piece") {
|
|
764
|
+
if (parts.length === 1) {
|
|
765
|
+
return {
|
|
766
|
+
binding: { from: "const", value: val.piece.id },
|
|
767
|
+
kind: "piece",
|
|
768
|
+
piece: val.piece,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
if (parts.length !== 2) {
|
|
772
|
+
issues.push(
|
|
773
|
+
issue(
|
|
774
|
+
"UDL2011",
|
|
775
|
+
leafPath,
|
|
776
|
+
`invalid trailing member access on piece reference: ${rawBind}`,
|
|
777
|
+
),
|
|
778
|
+
);
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
const member = parts[1]!;
|
|
782
|
+
if (member === "id") {
|
|
783
|
+
return {
|
|
784
|
+
binding: { from: "const", value: val.piece.id },
|
|
785
|
+
kind: "text",
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (member === "amount") {
|
|
789
|
+
const cur = pieceCurrency(val.piece, instrument);
|
|
790
|
+
if (cur === undefined) {
|
|
791
|
+
issues.push(
|
|
792
|
+
issue(
|
|
793
|
+
"UDL4002",
|
|
794
|
+
leafPath,
|
|
795
|
+
`piece ${val.piece.id} amount field lacks a concrete currency`,
|
|
796
|
+
),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
binding: { from: "instance", path: `fields.${val.piece.amount}` },
|
|
801
|
+
currency: cur ?? "UNKNOWN",
|
|
802
|
+
kind: "money",
|
|
803
|
+
path: `fields.${val.piece.amount}`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (member === "release_to") {
|
|
807
|
+
return {
|
|
808
|
+
binding: {
|
|
809
|
+
from: "instance",
|
|
810
|
+
path: `fields.${val.piece.release_to}`,
|
|
811
|
+
},
|
|
812
|
+
kind: "account",
|
|
813
|
+
path: `fields.${val.piece.release_to}`,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (member === "refund_to") {
|
|
817
|
+
return {
|
|
818
|
+
binding: {
|
|
819
|
+
from: "instance",
|
|
820
|
+
path: `fields.${val.piece.refund_to}`,
|
|
821
|
+
},
|
|
822
|
+
kind: "account",
|
|
823
|
+
path: `fields.${val.piece.refund_to}`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
if (member === "currency") {
|
|
827
|
+
const cur = pieceCurrency(val.piece, instrument);
|
|
828
|
+
if (cur === undefined) {
|
|
829
|
+
issues.push(
|
|
830
|
+
issue(
|
|
831
|
+
"UDL4002",
|
|
832
|
+
leafPath,
|
|
833
|
+
`piece ${val.piece.id} currency cannot be resolved to a concrete currency`,
|
|
834
|
+
),
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
binding: { from: "const", value: cur ?? "UNKNOWN" },
|
|
839
|
+
kind: "text",
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
issues.push(
|
|
843
|
+
issue(
|
|
844
|
+
"UDL2011",
|
|
845
|
+
leafPath,
|
|
846
|
+
`unknown piece member ${member} in ${rawBind}`,
|
|
847
|
+
),
|
|
848
|
+
);
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (val.kind === "instance") {
|
|
853
|
+
if (parts.length === 1) {
|
|
854
|
+
return {
|
|
855
|
+
binding: { from: "instance", path: "instrumentInstanceId" },
|
|
856
|
+
kind: "instance",
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (parts[1] === "fields") {
|
|
860
|
+
if (parts.length !== 3) {
|
|
861
|
+
issues.push(
|
|
862
|
+
issue(
|
|
863
|
+
"UDL2011",
|
|
864
|
+
leafPath,
|
|
865
|
+
`invalid trailing member access on instance field: ${rawBind}`,
|
|
866
|
+
),
|
|
867
|
+
);
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
const fieldName = parts[2]!;
|
|
871
|
+
const fieldSchema = instrument.fields[fieldName];
|
|
872
|
+
if (!fieldSchema) {
|
|
873
|
+
issues.push(
|
|
874
|
+
issue(
|
|
875
|
+
"UDL2011",
|
|
876
|
+
leafPath,
|
|
877
|
+
`referenced field ${fieldName} is not declared on instrument`,
|
|
878
|
+
),
|
|
879
|
+
);
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
if (isMoneySchema(fieldSchema)) {
|
|
883
|
+
const cur =
|
|
884
|
+
((fieldSchema as Record<string, unknown>)[
|
|
885
|
+
"x-hyperscale-currency"
|
|
886
|
+
] as string | undefined) ??
|
|
887
|
+
instrumentConcreteCurrency(instrument);
|
|
888
|
+
return {
|
|
889
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
890
|
+
currency: cur,
|
|
891
|
+
kind: "money",
|
|
892
|
+
path: `fields.${fieldName}`,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (isAccountSchema(fieldSchema)) {
|
|
896
|
+
return {
|
|
897
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
898
|
+
kind: "account",
|
|
899
|
+
path: `fields.${fieldName}`,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
if (isStringSchema(fieldSchema)) {
|
|
903
|
+
return {
|
|
904
|
+
binding: { from: "instance", path: `fields.${fieldName}` },
|
|
905
|
+
kind: "text",
|
|
906
|
+
path: `fields.${fieldName}`,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
issues.push(
|
|
910
|
+
issue(
|
|
911
|
+
"UDL2011",
|
|
912
|
+
leafPath,
|
|
913
|
+
`field ${fieldName} is not an account, money, or text field`,
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
return undefined;
|
|
917
|
+
}
|
|
918
|
+
if (
|
|
919
|
+
parts.length === 2 &&
|
|
920
|
+
(parts[1] === "instrumentInstanceId" || parts[1] === "productId")
|
|
921
|
+
) {
|
|
922
|
+
return {
|
|
923
|
+
binding: { from: "instance", path: parts[1] },
|
|
924
|
+
kind: "text",
|
|
925
|
+
path: parts[1],
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
issues.push(
|
|
929
|
+
issue(
|
|
930
|
+
"UDL2011",
|
|
931
|
+
leafPath,
|
|
932
|
+
`invalid or trailing member access on instance reference: ${rawBind}`,
|
|
933
|
+
),
|
|
934
|
+
);
|
|
935
|
+
return undefined;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Money, Account, Text parameter references do not allow member access
|
|
939
|
+
if (parts.length > 1) {
|
|
940
|
+
issues.push(
|
|
941
|
+
issue(
|
|
942
|
+
"UDL2011",
|
|
943
|
+
leafPath,
|
|
944
|
+
`member access is not allowed on ${val.kind} parameter: ${rawBind}`,
|
|
945
|
+
),
|
|
946
|
+
);
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (val.kind === "account") {
|
|
951
|
+
return {
|
|
952
|
+
binding: { from: "instance", path: val.path },
|
|
953
|
+
kind: "account",
|
|
954
|
+
path: val.path,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (val.kind === "money") {
|
|
959
|
+
return {
|
|
960
|
+
binding: { from: "instance", path: val.path },
|
|
961
|
+
currency: val.currency,
|
|
962
|
+
kind: "money",
|
|
963
|
+
path: val.path,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (val.kind === "text") {
|
|
968
|
+
return {
|
|
969
|
+
binding: val.path
|
|
970
|
+
? { from: "instance", path: val.path }
|
|
971
|
+
: { from: "const", value: val.value ?? "" },
|
|
972
|
+
kind: "text",
|
|
973
|
+
path: val.path,
|
|
974
|
+
value: val.value,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
issues.push(
|
|
980
|
+
issue(
|
|
981
|
+
"UDL2011",
|
|
982
|
+
leafPath,
|
|
983
|
+
`invalid or unsupported binding reference: ${rawBind}`,
|
|
984
|
+
),
|
|
985
|
+
);
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function resolveCallParameters(
|
|
990
|
+
call: UdlCall,
|
|
991
|
+
targetActionDef: UdlPrivateAction,
|
|
992
|
+
scope: ReadonlyMap<string, ParamValue>,
|
|
993
|
+
callPath: string,
|
|
994
|
+
issuesList: UdlIssue[],
|
|
995
|
+
piece?: UdlPiece,
|
|
996
|
+
): Map<string, ParamValue> {
|
|
997
|
+
const callScope = new Map<string, ParamValue>();
|
|
998
|
+
|
|
999
|
+
// Check extra keys
|
|
1000
|
+
for (const key of Object.keys(call.bind)) {
|
|
1001
|
+
if (!Object.hasOwn(targetActionDef.parameters, key)) {
|
|
1002
|
+
issuesList.push(
|
|
1003
|
+
issue(
|
|
1004
|
+
"UDL2011",
|
|
1005
|
+
`${callPath}.bind.${key}`,
|
|
1006
|
+
`unexpected parameter ${key} in call to ${call.action}`,
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Check missing keys & resolve bindings
|
|
1013
|
+
for (const [pName, pDef] of Object.entries(targetActionDef.parameters)) {
|
|
1014
|
+
if (!Object.hasOwn(call.bind, pName)) {
|
|
1015
|
+
issuesList.push(
|
|
1016
|
+
issue(
|
|
1017
|
+
"UDL2011",
|
|
1018
|
+
`${callPath}.bind.${pName}`,
|
|
1019
|
+
`missing binding for parameter ${pName} in call to ${call.action}`,
|
|
1020
|
+
),
|
|
1021
|
+
);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const raw = call.bind[pName]!;
|
|
1025
|
+
const resolved = resolveBinding(raw, scope, `${callPath}.bind.${pName}`);
|
|
1026
|
+
if (resolved) {
|
|
1027
|
+
if (pDef.kind !== resolved.kind) {
|
|
1028
|
+
issuesList.push(
|
|
1029
|
+
issue(
|
|
1030
|
+
"UDL2011",
|
|
1031
|
+
`${callPath}.bind.${pName}`,
|
|
1032
|
+
`parameter ${pName} expected kind ${pDef.kind} but received ${resolved.kind}`,
|
|
1033
|
+
),
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
if (
|
|
1037
|
+
pDef.kind === "money" &&
|
|
1038
|
+
pDef.currency &&
|
|
1039
|
+
resolved.currency &&
|
|
1040
|
+
pDef.currency !== resolved.currency
|
|
1041
|
+
) {
|
|
1042
|
+
issuesList.push(
|
|
1043
|
+
issue(
|
|
1044
|
+
"UDL2011",
|
|
1045
|
+
`${callPath}.bind.${pName}`,
|
|
1046
|
+
`currency mismatch for money parameter ${pName}: expected ${pDef.currency} but received ${resolved.currency}`,
|
|
1047
|
+
),
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
if (resolved.kind === "piece") {
|
|
1051
|
+
callScope.set(pName, {
|
|
1052
|
+
kind: "piece",
|
|
1053
|
+
piece: resolved.piece ?? piece!,
|
|
1054
|
+
});
|
|
1055
|
+
} else if (resolved.kind === "instance") {
|
|
1056
|
+
callScope.set(pName, { kind: "instance" });
|
|
1057
|
+
} else if (resolved.kind === "account") {
|
|
1058
|
+
callScope.set(pName, { kind: "account", path: resolved.path ?? "" });
|
|
1059
|
+
} else if (resolved.kind === "money") {
|
|
1060
|
+
callScope.set(pName, {
|
|
1061
|
+
currency: resolved.currency ?? pDef.currency,
|
|
1062
|
+
kind: "money",
|
|
1063
|
+
path: resolved.path ?? "",
|
|
1064
|
+
});
|
|
1065
|
+
} else {
|
|
1066
|
+
callScope.set(pName, {
|
|
1067
|
+
kind: "text",
|
|
1068
|
+
path: resolved.path,
|
|
1069
|
+
value: resolved.value,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return callScope;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Counts private-action expansions for one public action plan. Reset before
|
|
1079
|
+
// each plan so the bound is per plan, not per document.
|
|
1080
|
+
let totalCumulativeCalls = 0;
|
|
1081
|
+
|
|
1082
|
+
function expandPrivateAction(
|
|
1083
|
+
targetLibKey: string,
|
|
1084
|
+
targetActionKey: string,
|
|
1085
|
+
callArgs: ReadonlyMap<string, ParamValue>,
|
|
1086
|
+
originPrefix: readonly string[],
|
|
1087
|
+
callChain: readonly string[],
|
|
1088
|
+
callerPrincipal: "api_key" | "user_session",
|
|
1089
|
+
capturedDestinations: Set<string>,
|
|
1090
|
+
consumedSources: Set<string>,
|
|
1091
|
+
expandedLeaves: ResolvedActionPlanLeaf[],
|
|
1092
|
+
): void {
|
|
1093
|
+
totalCumulativeCalls += 1;
|
|
1094
|
+
if (totalCumulativeCalls > UDL_LIMITS.maxActionExpansion) {
|
|
1095
|
+
issues.push(
|
|
1096
|
+
issue(
|
|
1097
|
+
"UDL2010",
|
|
1098
|
+
`$.actions.${originPrefix[0]}`,
|
|
1099
|
+
`action exceeds cumulative call bound of ${UDL_LIMITS.maxActionExpansion}`,
|
|
1100
|
+
),
|
|
1101
|
+
);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (callChain.length > UDL_LIMITS.maxDepth) {
|
|
1106
|
+
issues.push(
|
|
1107
|
+
issue(
|
|
1108
|
+
"UDL2010",
|
|
1109
|
+
`$.actions.${originPrefix[0]}`,
|
|
1110
|
+
`action recursion depth exceeded ${UDL_LIMITS.maxDepth}`,
|
|
1111
|
+
),
|
|
1112
|
+
);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const actionRef = `${targetLibKey}.${targetActionKey}`;
|
|
1117
|
+
if (callChain.includes(actionRef)) {
|
|
1118
|
+
issues.push(
|
|
1119
|
+
issue(
|
|
1120
|
+
"UDL2010",
|
|
1121
|
+
`$.actions.${originPrefix[0]}`,
|
|
1122
|
+
`action graph cycle detected in call to ${actionRef}`,
|
|
1123
|
+
),
|
|
1124
|
+
);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const privAction = getAction(targetLibKey, targetActionKey);
|
|
1129
|
+
if (!privAction) {
|
|
1130
|
+
issues.push(
|
|
1131
|
+
issue(
|
|
1132
|
+
"UDL2010",
|
|
1133
|
+
`$.actions.${originPrefix[0]}`,
|
|
1134
|
+
`call references unknown action ${targetActionKey} in library ${targetLibKey}`,
|
|
1135
|
+
),
|
|
1136
|
+
);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (privAction.principal !== callerPrincipal) {
|
|
1141
|
+
issues.push(
|
|
1142
|
+
issue(
|
|
1143
|
+
"UDL2012",
|
|
1144
|
+
`$.actionLibrary.${targetLibKey}.actions.${targetActionKey}.principal`,
|
|
1145
|
+
`principal mismatch: caller requires ${callerPrincipal} but ${actionRef} requires ${privAction.principal}`,
|
|
1146
|
+
),
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const leavesMap = new Map(privAction.leaves.map((l) => [l.id, l]));
|
|
1151
|
+
const callsMap = new Map(privAction.calls.map((c) => [c.id, c]));
|
|
1152
|
+
|
|
1153
|
+
for (const itemId of privAction.order) {
|
|
1154
|
+
if (expandedLeaves.length >= UDL_LIMITS.maxActionLeaves) {
|
|
1155
|
+
issues.push(
|
|
1156
|
+
issue(
|
|
1157
|
+
"UDL2010",
|
|
1158
|
+
`$.actions.${originPrefix[0]}`,
|
|
1159
|
+
`action exceeds cumulative expanded leaf bound of ${UDL_LIMITS.maxActionLeaves}`,
|
|
1160
|
+
),
|
|
1161
|
+
);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (leavesMap.has(itemId)) {
|
|
1166
|
+
const leaf = leavesMap.get(itemId)!;
|
|
1167
|
+
const leafPath = `$.actionLibrary.${targetLibKey}.actions.${targetActionKey}.leaves.${leaf.id}`;
|
|
1168
|
+
const resolvedBinds: Record<string, UdlBinding> = {};
|
|
1169
|
+
|
|
1170
|
+
for (const [key, bindStr] of Object.entries(leaf.bind)) {
|
|
1171
|
+
const resolved = resolveBinding(
|
|
1172
|
+
bindStr,
|
|
1173
|
+
callArgs,
|
|
1174
|
+
`${leafPath}.bind.${key}`,
|
|
1175
|
+
);
|
|
1176
|
+
if (resolved) {
|
|
1177
|
+
resolvedBinds[key] = resolved.binding;
|
|
1178
|
+
if (key === "amount" && resolved.kind !== "money") {
|
|
1179
|
+
issues.push(
|
|
1180
|
+
issue(
|
|
1181
|
+
"UDL2011",
|
|
1182
|
+
`${leafPath}.bind.${key}`,
|
|
1183
|
+
`amount operand must resolve to money`,
|
|
1184
|
+
),
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
if (
|
|
1188
|
+
(key === "sourceAccountId" ||
|
|
1189
|
+
key === "destinationAccountId" ||
|
|
1190
|
+
key === "accountId") &&
|
|
1191
|
+
resolved.kind !== "account"
|
|
1192
|
+
) {
|
|
1193
|
+
issues.push(
|
|
1194
|
+
issue(
|
|
1195
|
+
"UDL2011",
|
|
1196
|
+
`${leafPath}.bind.${key}`,
|
|
1197
|
+
`${key} operand must resolve to account`,
|
|
1198
|
+
),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Check leaf evidence
|
|
1205
|
+
if (!leaf.evidence || leaf.evidence.trim().length === 0) {
|
|
1206
|
+
issues.push(
|
|
1207
|
+
issue(
|
|
1208
|
+
"UDL2013",
|
|
1209
|
+
`${leafPath}.evidence`,
|
|
1210
|
+
`leaf evidence is mandatory and cannot be blank`,
|
|
1211
|
+
),
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Check leaf captures: destination ref is the map key
|
|
1216
|
+
if (leaf.capture) {
|
|
1217
|
+
for (const capKey of Object.keys(leaf.capture)) {
|
|
1218
|
+
if (capturedDestinations.has(capKey)) {
|
|
1219
|
+
issues.push(
|
|
1220
|
+
issue(
|
|
1221
|
+
"UDL2011",
|
|
1222
|
+
`${leafPath}.capture.${capKey}`,
|
|
1223
|
+
`duplicate capture destination ${capKey} across expanded leaves`,
|
|
1224
|
+
),
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
capturedDestinations.add(capKey);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const originPath = [...originPrefix, leaf.id];
|
|
1232
|
+
let step: UdlStep | UdlMove;
|
|
1233
|
+
|
|
1234
|
+
if (leaf.operation.startsWith("internal_transfer.")) {
|
|
1235
|
+
step = {
|
|
1236
|
+
bind: resolvedBinds,
|
|
1237
|
+
...(leaf.capture ? { capture: leaf.capture } : {}),
|
|
1238
|
+
key: encodeOriginPathKey(originPath),
|
|
1239
|
+
operation: leaf.operation as UdlMove["operation"],
|
|
1240
|
+
};
|
|
1241
|
+
} else {
|
|
1242
|
+
step = {
|
|
1243
|
+
bind: resolvedBinds,
|
|
1244
|
+
...(leaf.capture ? { capture: leaf.capture } : {}),
|
|
1245
|
+
operation: leaf.operation as UdlStep["operation"],
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Check repeated consumption of money amount or hold identity
|
|
1250
|
+
const amountSrc = boundPath(step, "amount");
|
|
1251
|
+
if (amountSrc) {
|
|
1252
|
+
if (consumedSources.has(amountSrc)) {
|
|
1253
|
+
issues.push(
|
|
1254
|
+
issue(
|
|
1255
|
+
"UDL2011",
|
|
1256
|
+
`${leafPath}.bind.amount`,
|
|
1257
|
+
`repeated consumption of money source ${amountSrc} in one expanded action`,
|
|
1258
|
+
),
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
consumedSources.add(amountSrc);
|
|
1262
|
+
}
|
|
1263
|
+
const holdSrc = boundPath(step, "holdId");
|
|
1264
|
+
if (holdSrc) {
|
|
1265
|
+
const holdKey = `hold:${holdSrc}`;
|
|
1266
|
+
if (consumedSources.has(holdKey)) {
|
|
1267
|
+
issues.push(
|
|
1268
|
+
issue(
|
|
1269
|
+
"UDL2011",
|
|
1270
|
+
`${leafPath}.bind.holdId`,
|
|
1271
|
+
`repeated consumption of hold ${holdSrc} in one expanded action`,
|
|
1272
|
+
),
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
consumedSources.add(holdKey);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const expectedEffects = expectedLeafEffects(step);
|
|
1279
|
+
const actualCounts = new Map<string, number>();
|
|
1280
|
+
for (const eff of leaf.effects) {
|
|
1281
|
+
const key = `${eff.kind}:${eff.signature}`;
|
|
1282
|
+
actualCounts.set(key, (actualCounts.get(key) ?? 0) + 1);
|
|
1283
|
+
}
|
|
1284
|
+
const expectedCounts = new Map<string, number>();
|
|
1285
|
+
for (const eff of expectedEffects) {
|
|
1286
|
+
const key = `${eff.kind}:${eff.signature}`;
|
|
1287
|
+
expectedCounts.set(key, (expectedCounts.get(key) ?? 0) + 1);
|
|
1288
|
+
}
|
|
1289
|
+
let effectsMatch = actualCounts.size === expectedCounts.size;
|
|
1290
|
+
if (effectsMatch) {
|
|
1291
|
+
for (const [k, count] of actualCounts.entries()) {
|
|
1292
|
+
if (expectedCounts.get(k) !== count) {
|
|
1293
|
+
effectsMatch = false;
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (!effectsMatch) {
|
|
1299
|
+
issues.push(
|
|
1300
|
+
issue(
|
|
1301
|
+
"UDL2013",
|
|
1302
|
+
`${leafPath}.effects`,
|
|
1303
|
+
`declared leaf effects do not match expected effects for operation ${leaf.operation}`,
|
|
1304
|
+
),
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
expandedLeaves.push({
|
|
1309
|
+
effects: leaf.effects,
|
|
1310
|
+
evidence: leaf.evidence,
|
|
1311
|
+
originPath,
|
|
1312
|
+
step,
|
|
1313
|
+
});
|
|
1314
|
+
} else if (callsMap.has(itemId)) {
|
|
1315
|
+
const nextCall = callsMap.get(itemId)!;
|
|
1316
|
+
const [nextLib, nextAct] = nextCall.action.split(".");
|
|
1317
|
+
if (!nextLib || !nextAct || !hasLibrary(nextLib)) {
|
|
1318
|
+
issues.push(
|
|
1319
|
+
issue(
|
|
1320
|
+
"UDL2010",
|
|
1321
|
+
`$.actionLibrary.${targetLibKey}.actions.${targetActionKey}.calls.${nextCall.id}.action`,
|
|
1322
|
+
`call ${nextCall.id} references unknown library ${nextLib ?? ""}`,
|
|
1323
|
+
),
|
|
1324
|
+
);
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const nextActionDef = getAction(nextLib, nextAct);
|
|
1328
|
+
if (!nextActionDef) {
|
|
1329
|
+
issues.push(
|
|
1330
|
+
issue(
|
|
1331
|
+
"UDL2010",
|
|
1332
|
+
`$.actionLibrary.${targetLibKey}.actions.${targetActionKey}.calls.${nextCall.id}.action`,
|
|
1333
|
+
`call ${nextCall.id} references unknown action ${nextAct} in library ${nextLib}`,
|
|
1334
|
+
),
|
|
1335
|
+
);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const nextScope = resolveCallParameters(
|
|
1340
|
+
nextCall,
|
|
1341
|
+
nextActionDef,
|
|
1342
|
+
callArgs,
|
|
1343
|
+
`$.actionLibrary.${targetLibKey}.actions.${targetActionKey}.calls.${nextCall.id}`,
|
|
1344
|
+
issues,
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
expandPrivateAction(
|
|
1348
|
+
nextLib,
|
|
1349
|
+
nextAct,
|
|
1350
|
+
nextScope,
|
|
1351
|
+
[...originPrefix, nextCall.id],
|
|
1352
|
+
[...callChain, actionRef],
|
|
1353
|
+
privAction.principal,
|
|
1354
|
+
capturedDestinations,
|
|
1355
|
+
consumedSources,
|
|
1356
|
+
expandedLeaves,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Iterate over public actions in actionOrder
|
|
1363
|
+
for (const actionKey of instrument.actionOrder) {
|
|
1364
|
+
const action = instrument.actions[actionKey];
|
|
1365
|
+
if (!action) continue;
|
|
1366
|
+
|
|
1367
|
+
const actionPath = `$.actions.${actionKey}`;
|
|
1368
|
+
const publicPrincipal = action.principal ?? "api_key";
|
|
1369
|
+
|
|
1370
|
+
// Reject mixed direct steps/moves and calls
|
|
1371
|
+
if (
|
|
1372
|
+
action.calls &&
|
|
1373
|
+
action.calls.length > 0 &&
|
|
1374
|
+
(action.steps.length > 0 || action.moves.length > 0)
|
|
1375
|
+
) {
|
|
1376
|
+
issues.push(
|
|
1377
|
+
issue(
|
|
1378
|
+
"UDL2010",
|
|
1379
|
+
`${actionPath}.calls`,
|
|
1380
|
+
`action ${actionKey} cannot mix calls with direct steps or moves`,
|
|
1381
|
+
),
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
if (action.calls) {
|
|
1386
|
+
const publicCallIds = action.calls.map((c) => c.id);
|
|
1387
|
+
if (new Set(publicCallIds).size !== publicCallIds.length) {
|
|
1388
|
+
issues.push(
|
|
1389
|
+
issue(
|
|
1390
|
+
"UDL2010",
|
|
1391
|
+
`${actionPath}.calls`,
|
|
1392
|
+
`duplicate call id in action ${actionKey}`,
|
|
1393
|
+
),
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (action.pieceStage) {
|
|
1399
|
+
if (!action.calls || action.calls.length === 0) {
|
|
1400
|
+
issues.push(
|
|
1401
|
+
issue(
|
|
1402
|
+
"UDL5013",
|
|
1403
|
+
`${actionPath}.calls`,
|
|
1404
|
+
`pieceStage action ${actionKey} must move money through calls, never through direct moves or steps`,
|
|
1405
|
+
),
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (actionKey === "create") {
|
|
1410
|
+
issues.push(
|
|
1411
|
+
issue(
|
|
1412
|
+
"UDL5013",
|
|
1413
|
+
`${actionPath}.pieceStage`,
|
|
1414
|
+
`create action cannot declare pieceStage`,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (
|
|
1420
|
+
!instrument.piecePlan ||
|
|
1421
|
+
instrument.piecePlan.id !== action.pieceStage.plan
|
|
1422
|
+
) {
|
|
1423
|
+
issues.push(
|
|
1424
|
+
issue(
|
|
1425
|
+
"UDL5013",
|
|
1426
|
+
`${actionPath}.pieceStage.plan`,
|
|
1427
|
+
`pieceStage references unknown plan ${action.pieceStage.plan}`,
|
|
1428
|
+
),
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const stage = action.pieceStage.stage;
|
|
1433
|
+
const stageOrder: readonly string[] =
|
|
1434
|
+
stage === "fund"
|
|
1435
|
+
? (instrument.piecePlan?.fund_order ?? [])
|
|
1436
|
+
: stage === "release"
|
|
1437
|
+
? (instrument.piecePlan?.release_order ?? [])
|
|
1438
|
+
: stage === "refund"
|
|
1439
|
+
? (instrument.piecePlan?.refund_order ?? [])
|
|
1440
|
+
: (instrument.piecePlan?.unfund_order ?? []);
|
|
1441
|
+
|
|
1442
|
+
if (stageOrder.length === 0) {
|
|
1443
|
+
issues.push(
|
|
1444
|
+
issue(
|
|
1445
|
+
"UDL5013",
|
|
1446
|
+
`${actionPath}.pieceStage.stage`,
|
|
1447
|
+
`piece stage ${stage} is empty`,
|
|
1448
|
+
),
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Check input must be present and exact
|
|
1453
|
+
if (!action.input) {
|
|
1454
|
+
issues.push(
|
|
1455
|
+
issue(
|
|
1456
|
+
"UDL5013",
|
|
1457
|
+
`${actionPath}.input`,
|
|
1458
|
+
`pieceStage action input is required and must declare exact pieceId enum matching stage order`,
|
|
1459
|
+
),
|
|
1460
|
+
);
|
|
1461
|
+
} else {
|
|
1462
|
+
const inp = action.input as Record<string, unknown>;
|
|
1463
|
+
const props = (inp.properties ?? {}) as Record<string, unknown>;
|
|
1464
|
+
const pieceIdProp = (props.pieceId ?? {}) as Record<string, unknown>;
|
|
1465
|
+
const enumVals = Array.isArray(pieceIdProp.enum)
|
|
1466
|
+
? pieceIdProp.enum
|
|
1467
|
+
: [];
|
|
1468
|
+
|
|
1469
|
+
if (
|
|
1470
|
+
inp.type !== "object" ||
|
|
1471
|
+
inp.additionalProperties !== false ||
|
|
1472
|
+
!Array.isArray(inp.required) ||
|
|
1473
|
+
inp.required.length !== 1 ||
|
|
1474
|
+
inp.required[0] !== "pieceId" ||
|
|
1475
|
+
Object.keys(props).length !== 1 ||
|
|
1476
|
+
pieceIdProp.type !== "string" ||
|
|
1477
|
+
enumVals.length !== stageOrder.length ||
|
|
1478
|
+
!enumVals.every((id, idx) => id === stageOrder[idx])
|
|
1479
|
+
) {
|
|
1480
|
+
issues.push(
|
|
1481
|
+
issue(
|
|
1482
|
+
"UDL5013",
|
|
1483
|
+
`${actionPath}.input`,
|
|
1484
|
+
`pieceStage action input must declare exact pieceId enum matching stage order`,
|
|
1485
|
+
),
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const variantMultiplicities: Map<string, number>[] = [];
|
|
1491
|
+
|
|
1492
|
+
for (const pieceId of stageOrder) {
|
|
1493
|
+
const piece = instrument.piecePlan?.pieces.find(
|
|
1494
|
+
(p) => p.id === pieceId,
|
|
1495
|
+
);
|
|
1496
|
+
if (!piece) {
|
|
1497
|
+
issues.push(
|
|
1498
|
+
issue(
|
|
1499
|
+
"UDL5013",
|
|
1500
|
+
`${actionPath}.pieceStage`,
|
|
1501
|
+
`piece id ${pieceId} in stage order is not declared in piecePlan`,
|
|
1502
|
+
),
|
|
1503
|
+
);
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const expandedLeaves: ResolvedActionPlanLeaf[] = [];
|
|
1508
|
+
const capturedDestinations = new Set<string>();
|
|
1509
|
+
const consumedSources = new Set<string>();
|
|
1510
|
+
totalCumulativeCalls = 0;
|
|
1511
|
+
|
|
1512
|
+
const scope = new Map<string, ParamValue>([
|
|
1513
|
+
["piece", { kind: "piece", piece }],
|
|
1514
|
+
["instance", { kind: "instance" }],
|
|
1515
|
+
]);
|
|
1516
|
+
|
|
1517
|
+
for (const call of action.calls ?? []) {
|
|
1518
|
+
const [lib, act] = call.action.split(".");
|
|
1519
|
+
if (!lib || !act || !hasLibrary(lib)) {
|
|
1520
|
+
issues.push(
|
|
1521
|
+
issue(
|
|
1522
|
+
"UDL2010",
|
|
1523
|
+
`${actionPath}.calls.${call.id}.action`,
|
|
1524
|
+
`call ${call.id} references unknown library ${lib ?? ""}`,
|
|
1525
|
+
),
|
|
1526
|
+
);
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
const targetActionDef = getAction(lib, act);
|
|
1530
|
+
if (!targetActionDef) {
|
|
1531
|
+
issues.push(
|
|
1532
|
+
issue(
|
|
1533
|
+
"UDL2010",
|
|
1534
|
+
`${actionPath}.calls.${call.id}.action`,
|
|
1535
|
+
`call ${call.id} references unknown action ${act} in library ${lib}`,
|
|
1536
|
+
),
|
|
1537
|
+
);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const callScope = resolveCallParameters(
|
|
1542
|
+
call,
|
|
1543
|
+
targetActionDef,
|
|
1544
|
+
scope,
|
|
1545
|
+
`${actionPath}.calls.${call.id}`,
|
|
1546
|
+
issues,
|
|
1547
|
+
piece,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
expandPrivateAction(
|
|
1551
|
+
lib,
|
|
1552
|
+
act,
|
|
1553
|
+
callScope,
|
|
1554
|
+
[actionKey, call.id],
|
|
1555
|
+
[],
|
|
1556
|
+
publicPrincipal,
|
|
1557
|
+
capturedDestinations,
|
|
1558
|
+
consumedSources,
|
|
1559
|
+
expandedLeaves,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Refuse input amount/destination binding in leaves and validate piece bindings
|
|
1564
|
+
for (const leaf of expandedLeaves) {
|
|
1565
|
+
const binds = leaf.step.bind ?? {};
|
|
1566
|
+
for (const [k, b] of Object.entries(binds)) {
|
|
1567
|
+
if (
|
|
1568
|
+
(k === "amount" ||
|
|
1569
|
+
k === "sourceAccountId" ||
|
|
1570
|
+
k === "destinationAccountId") &&
|
|
1571
|
+
typeof b === "object" &&
|
|
1572
|
+
b !== null &&
|
|
1573
|
+
(b as { from?: string }).from === "input"
|
|
1574
|
+
) {
|
|
1575
|
+
issues.push(
|
|
1576
|
+
issue(
|
|
1577
|
+
"UDL5013",
|
|
1578
|
+
`${actionPath}.pieceStage`,
|
|
1579
|
+
`pieceStage leaves cannot bind amount or destination to input: ${k}`,
|
|
1580
|
+
),
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (leaf.step.operation.startsWith("internal_transfer.")) {
|
|
1586
|
+
const amt = binds.amount;
|
|
1587
|
+
if (
|
|
1588
|
+
!amt ||
|
|
1589
|
+
typeof amt !== "object" ||
|
|
1590
|
+
(amt as { from?: string }).from !== "instance" ||
|
|
1591
|
+
(amt as { path?: string }).path !== `fields.${piece.amount}`
|
|
1592
|
+
) {
|
|
1593
|
+
issues.push(
|
|
1594
|
+
issue(
|
|
1595
|
+
"UDL5013",
|
|
1596
|
+
`${actionPath}.pieceStage`,
|
|
1597
|
+
`pieceStage transfer amount must resolve to selected piece.amount (${piece.amount})`,
|
|
1598
|
+
),
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (stage === "release") {
|
|
1603
|
+
const dest = binds.destinationAccountId;
|
|
1604
|
+
if (
|
|
1605
|
+
!dest ||
|
|
1606
|
+
typeof dest !== "object" ||
|
|
1607
|
+
(dest as { from?: string }).from !== "instance" ||
|
|
1608
|
+
(dest as { path?: string }).path !==
|
|
1609
|
+
`fields.${piece.release_to}`
|
|
1610
|
+
) {
|
|
1611
|
+
issues.push(
|
|
1612
|
+
issue(
|
|
1613
|
+
"UDL5013",
|
|
1614
|
+
`${actionPath}.pieceStage`,
|
|
1615
|
+
`pieceStage release destination must resolve to selected piece.release_to (${piece.release_to})`,
|
|
1616
|
+
),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
} else if (stage === "refund") {
|
|
1620
|
+
const dest = binds.destinationAccountId;
|
|
1621
|
+
if (
|
|
1622
|
+
!dest ||
|
|
1623
|
+
typeof dest !== "object" ||
|
|
1624
|
+
(dest as { from?: string }).from !== "instance" ||
|
|
1625
|
+
(dest as { path?: string }).path !== `fields.${piece.refund_to}`
|
|
1626
|
+
) {
|
|
1627
|
+
issues.push(
|
|
1628
|
+
issue(
|
|
1629
|
+
"UDL5013",
|
|
1630
|
+
`${actionPath}.pieceStage`,
|
|
1631
|
+
`pieceStage refund destination must resolve to selected piece.refund_to (${piece.refund_to})`,
|
|
1632
|
+
),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const counts = new Map<string, number>();
|
|
1640
|
+
for (const leaf of expandedLeaves) {
|
|
1641
|
+
for (const eff of leaf.effects) {
|
|
1642
|
+
counts.set(eff.signature, (counts.get(eff.signature) ?? 0) + 1);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
variantMultiplicities.push(counts);
|
|
1646
|
+
|
|
1647
|
+
const directEffects = deriveUdlActionEffects(
|
|
1648
|
+
action as Readonly<Record<string, unknown>>,
|
|
1649
|
+
udlClauseVocabulary,
|
|
1650
|
+
);
|
|
1651
|
+
const leafEffects =
|
|
1652
|
+
action.calls && action.calls.length > 0
|
|
1653
|
+
? deriveActionEffectsFromPlan(expandedLeaves)
|
|
1654
|
+
: {};
|
|
1655
|
+
const effects = combineDerivedEffects(directEffects, leafEffects);
|
|
1656
|
+
|
|
1657
|
+
plans.push({
|
|
1658
|
+
action: actionKey,
|
|
1659
|
+
effects,
|
|
1660
|
+
leaves: expandedLeaves,
|
|
1661
|
+
pieceId,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Verify identical effect signature multiplicities across piece variants
|
|
1666
|
+
if (variantMultiplicities.length > 1) {
|
|
1667
|
+
const baseCounts = variantMultiplicities[0]!;
|
|
1668
|
+
for (let i = 1; i < variantMultiplicities.length; i++) {
|
|
1669
|
+
const comp = variantMultiplicities[i]!;
|
|
1670
|
+
let identical = baseCounts.size === comp.size;
|
|
1671
|
+
if (identical) {
|
|
1672
|
+
for (const [sig, count] of baseCounts.entries()) {
|
|
1673
|
+
if (comp.get(sig) !== count) {
|
|
1674
|
+
identical = false;
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (!identical) {
|
|
1680
|
+
issues.push(
|
|
1681
|
+
issue(
|
|
1682
|
+
"UDL2013",
|
|
1683
|
+
`${actionPath}.pieceStage`,
|
|
1684
|
+
`piece variants of action ${actionKey} have differing effect signature multiplicities`,
|
|
1685
|
+
),
|
|
1686
|
+
);
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
} else if (action.calls && action.calls.length > 0) {
|
|
1692
|
+
const expandedLeaves: ResolvedActionPlanLeaf[] = [];
|
|
1693
|
+
const capturedDestinations = new Set<string>();
|
|
1694
|
+
const consumedSources = new Set<string>();
|
|
1695
|
+
totalCumulativeCalls = 0;
|
|
1696
|
+
const scope = new Map<string, ParamValue>([
|
|
1697
|
+
["instance", { kind: "instance" }],
|
|
1698
|
+
]);
|
|
1699
|
+
|
|
1700
|
+
for (const call of action.calls) {
|
|
1701
|
+
const [lib, act] = call.action.split(".");
|
|
1702
|
+
if (!lib || !act || !hasLibrary(lib)) {
|
|
1703
|
+
issues.push(
|
|
1704
|
+
issue(
|
|
1705
|
+
"UDL2010",
|
|
1706
|
+
`${actionPath}.calls.${call.id}.action`,
|
|
1707
|
+
`call ${call.id} references unknown library ${lib ?? ""}`,
|
|
1708
|
+
),
|
|
1709
|
+
);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
const targetActionDef = getAction(lib, act);
|
|
1713
|
+
if (!targetActionDef) {
|
|
1714
|
+
issues.push(
|
|
1715
|
+
issue(
|
|
1716
|
+
"UDL2010",
|
|
1717
|
+
`${actionPath}.calls.${call.id}.action`,
|
|
1718
|
+
`call ${call.id} references unknown action ${act} in library ${lib}`,
|
|
1719
|
+
),
|
|
1720
|
+
);
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const callScope = resolveCallParameters(
|
|
1725
|
+
call,
|
|
1726
|
+
targetActionDef,
|
|
1727
|
+
scope,
|
|
1728
|
+
`${actionPath}.calls.${call.id}`,
|
|
1729
|
+
issues,
|
|
1730
|
+
);
|
|
1731
|
+
|
|
1732
|
+
expandPrivateAction(
|
|
1733
|
+
lib,
|
|
1734
|
+
act,
|
|
1735
|
+
callScope,
|
|
1736
|
+
[actionKey, call.id],
|
|
1737
|
+
[],
|
|
1738
|
+
publicPrincipal,
|
|
1739
|
+
capturedDestinations,
|
|
1740
|
+
consumedSources,
|
|
1741
|
+
expandedLeaves,
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const directEffects = deriveUdlActionEffects(
|
|
1746
|
+
action as Readonly<Record<string, unknown>>,
|
|
1747
|
+
udlClauseVocabulary,
|
|
1748
|
+
);
|
|
1749
|
+
const leafEffects = deriveActionEffectsFromPlan(expandedLeaves);
|
|
1750
|
+
const effects = combineDerivedEffects(directEffects, leafEffects);
|
|
1751
|
+
|
|
1752
|
+
plans.push({
|
|
1753
|
+
action: actionKey,
|
|
1754
|
+
effects,
|
|
1755
|
+
leaves: expandedLeaves,
|
|
1756
|
+
});
|
|
1757
|
+
} else {
|
|
1758
|
+
// Ordinary actions already are flat. Preserve the executor's order and
|
|
1759
|
+
// authored step bytes; origin paths belong to the plan, not the ABI.
|
|
1760
|
+
const effects = deriveUdlActionEffects(
|
|
1761
|
+
action as Readonly<Record<string, unknown>>,
|
|
1762
|
+
udlClauseVocabulary,
|
|
1763
|
+
);
|
|
1764
|
+
const leafEffects = (source: string): ResolvedActionPlanLeaf["effects"] =>
|
|
1765
|
+
Object.entries(effects).flatMap(([kind, rows]) =>
|
|
1766
|
+
rows
|
|
1767
|
+
.filter((row) => row.source === source)
|
|
1768
|
+
.map((row) => ({
|
|
1769
|
+
kind: kind as UdlEffectKind,
|
|
1770
|
+
signature: row.signature,
|
|
1771
|
+
})),
|
|
1772
|
+
);
|
|
1773
|
+
const leaves: ResolvedActionPlanLeaf[] = action.steps.map(
|
|
1774
|
+
(step, index) => ({
|
|
1775
|
+
step,
|
|
1776
|
+
originPath: [actionKey, "steps", String(index)],
|
|
1777
|
+
effects: leafEffects(`steps[${index}]`),
|
|
1778
|
+
evidence: "parent_receipt",
|
|
1779
|
+
}),
|
|
1780
|
+
);
|
|
1781
|
+
if (action.payout) {
|
|
1782
|
+
const intent = action.payout;
|
|
1783
|
+
leaves.push({
|
|
1784
|
+
step: {
|
|
1785
|
+
operation: "payout.create",
|
|
1786
|
+
bind: {
|
|
1787
|
+
amount: { from: "instance", path: intent.amount },
|
|
1788
|
+
beneficiaryId: {
|
|
1789
|
+
from: "instance",
|
|
1790
|
+
path: `fields.${intent.beneficiaryField}`,
|
|
1791
|
+
},
|
|
1792
|
+
currency: {
|
|
1793
|
+
from: "instance",
|
|
1794
|
+
path: `fields.${intent.currencyField}`,
|
|
1795
|
+
},
|
|
1796
|
+
sourceAccountId: {
|
|
1797
|
+
from: "instance",
|
|
1798
|
+
path: `fields.${intent.sourceAccountField}`,
|
|
1799
|
+
},
|
|
1800
|
+
speed: { from: "const", value: intent.speed },
|
|
1801
|
+
},
|
|
1802
|
+
capture: { [intent.capture]: "payoutId" },
|
|
1803
|
+
},
|
|
1804
|
+
originPath: [actionKey, "payout"],
|
|
1805
|
+
effects: leafEffects("payout"),
|
|
1806
|
+
evidence: "parent_receipt",
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
leaves.push(
|
|
1810
|
+
...action.moves.map((step, index) => ({
|
|
1811
|
+
step,
|
|
1812
|
+
originPath: [actionKey, "moves", String(index)],
|
|
1813
|
+
effects: leafEffects(`moves[${index}]`),
|
|
1814
|
+
evidence: "parent_receipt",
|
|
1815
|
+
})),
|
|
1816
|
+
);
|
|
1817
|
+
plans.push({
|
|
1818
|
+
action: actionKey,
|
|
1819
|
+
leaves,
|
|
1820
|
+
effects,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return { issues, plans };
|
|
1826
|
+
}
|