@hyperscale0/udl 3.2.0 → 4.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/src/validation.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  udlDocumentSchema,
3
+ subjectPartyRoles,
4
+ sameObjectField,
3
5
  MAX_ACTION_EXPANSION,
4
6
  type UdlDocument,
5
7
  type UdlField,
@@ -7,11 +9,63 @@ import {
7
9
  type UdlValue,
8
10
  type UdlCalculation,
9
11
  type UdlSelection,
12
+ type UdlAction,
13
+ type UdlActionSubject,
14
+ type UdlSubjectRequirement,
15
+ type UdlObjectField,
16
+ type UdlObjectKind,
10
17
  } from "./schema.js";
11
18
  import { issue, type UdlIssue } from "./diagnostics.js";
12
19
  import { validateReportDefinition } from "./reporting-validation.js";
13
20
  import { analyzeInstrumentFinance } from "./finance.js";
14
21
 
22
+ export const RESERVED_OBJECT_NAMES = [
23
+ "objectId",
24
+ "externalId",
25
+ "revision",
26
+ "kind",
27
+ "tenantId",
28
+ "productId",
29
+ ] as const;
30
+ const reservedObjectNamesSet = new Set<string>(RESERVED_OBJECT_NAMES);
31
+ function hasParty(
32
+ document: UdlDocument,
33
+ instrument: UdlInstrument | undefined,
34
+ name: string,
35
+ ): boolean {
36
+ if (document.parties[name]) return true;
37
+ if (!instrument?.subject) return false;
38
+ return (
39
+ document.objects
40
+ .find((kind) => kind.id === instrument.subject)
41
+ ?.attachments.some(
42
+ (attachment) =>
43
+ (attachment.instrument === instrument.id ||
44
+ instrument.id.startsWith(`${attachment.instrument}_`)) &&
45
+ Object.values(attachment.parties).some((binding) =>
46
+ "role" in binding ? binding.role === name : binding.party === name,
47
+ ),
48
+ ) ?? false
49
+ );
50
+ }
51
+
52
+ function moneyParty(
53
+ document: UdlDocument,
54
+ instrument: UdlInstrument | undefined,
55
+ name: string,
56
+ ): boolean {
57
+ return (
58
+ hasParty(document, instrument, name) &&
59
+ (document.parties[name]?.kind === "business" ||
60
+ (!document.parties[name] && !!instrument?.subject) ||
61
+ (!instrument?.subject && document.parties[name]?.kind === "person"))
62
+ );
63
+ }
64
+ function approvalParty(document: UdlDocument, name: string): boolean {
65
+ const party = document.parties[name];
66
+ return party?.kind === "staff" && !!party.role;
67
+ }
68
+
15
69
  export type UdlValidationResult =
16
70
  | { ok: true; value: UdlDocument }
17
71
  | { ok: false; issues: readonly UdlIssue[] };
@@ -58,7 +112,9 @@ const overlap = (a: string | string[], b: string | string[]) =>
58
112
  targetIds(a).some((id) => targetIds(b).includes(id));
59
113
 
60
114
  /** Union paths retain only fields with compatible types on every member. */
61
- function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
115
+ function commonField(
116
+ fields: (UdlField | UdlObjectField | undefined)[],
117
+ ): UdlField | undefined {
62
118
  const first = fields[0];
63
119
  if (
64
120
  !first ||
@@ -71,6 +127,16 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
71
127
  )
72
128
  return;
73
129
  if (first.type === "ref") {
130
+ if (
131
+ fields.some(
132
+ (field) =>
133
+ field?.type !== "ref" ||
134
+ field.targetKind !== first.targetKind ||
135
+ JSON.stringify(field.targetFamily) !==
136
+ JSON.stringify(first.targetFamily),
137
+ )
138
+ )
139
+ return;
74
140
  const targets = [
75
141
  ...new Set(
76
142
  fields.flatMap((field) =>
@@ -78,7 +144,12 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
78
144
  ),
79
145
  ),
80
146
  ];
81
- return { ...first, target: targets.length === 1 ? targets[0]! : targets };
147
+ return {
148
+ ...first,
149
+ targetKind: first.targetKind,
150
+ target: targets.length === 1 ? targets[0]! : targets,
151
+ ...(first.targetFamily ? { targetFamily: first.targetFamily } : {}),
152
+ } as UdlField;
82
153
  }
83
154
  if (
84
155
  first.type === "account" &&
@@ -88,7 +159,6 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
88
159
  field.book !== first.book ||
89
160
  field.owner !== first.owner ||
90
161
  field.key !== first.key ||
91
- field.external !== first.external ||
92
162
  field.contra !== first.contra,
93
163
  )
94
164
  )
@@ -109,11 +179,12 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
109
179
  (field) =>
110
180
  field?.type !== "list" ||
111
181
  field.item !== first.item ||
182
+ field.targetKind !== first.targetKind ||
112
183
  field.target !== first.target,
113
184
  )
114
185
  )
115
186
  return;
116
- return first;
187
+ return first as UdlField;
117
188
  }
118
189
 
119
190
  export function resolveField(
@@ -121,8 +192,71 @@ export function resolveField(
121
192
  instrument: UdlInstrument,
122
193
  path: string,
123
194
  input: readonly UdlField[] = [],
195
+ action?: UdlAction | UdlActionSubject,
196
+ ): UdlField | undefined {
197
+ return resolvePath(document, instrument, path, input, new Map(), action);
198
+ }
199
+
200
+ /** A subject expression names a requirement; objectField names its stored metadata. */
201
+ export function resolveSubjectRequirement(
202
+ instrument: UdlInstrument,
203
+ name: string,
204
+ action?: UdlAction | UdlActionSubject,
205
+ ): UdlSubjectRequirement | undefined {
206
+ const requirements = action
207
+ ? "requirements" in action
208
+ ? action.requirements
209
+ : action.subject?.requirements
210
+ : Object.values(instrument.actions).flatMap(
211
+ (item) => item.subject?.requirements ?? [],
212
+ );
213
+ return requirements?.find((requirement) => requirement.field.name === name);
214
+ }
215
+
216
+ function resolveReferencePath(
217
+ document: UdlDocument,
218
+ field: Extract<UdlField, { type: "ref" }>,
219
+ suffix: string,
220
+ cache: Map<string, UdlField | undefined>,
221
+ ): UdlField | undefined {
222
+ return commonField(
223
+ targetIds(field.target).map((id) => {
224
+ const key = `${field.targetKind}:${id}:${suffix}`;
225
+ if (!cache.has(key)) {
226
+ const object =
227
+ field.targetKind === "object" &&
228
+ document.objects.find((item) => item.id === id);
229
+ const instrument =
230
+ field.targetKind === "instrument" &&
231
+ document.instruments.find((item) => item.id === id);
232
+ cache.set(
233
+ key,
234
+ object
235
+ ? resolveObjectPath(document, object, suffix, cache)
236
+ : instrument
237
+ ? resolvePath(document, instrument, `self.${suffix}`, [], cache)
238
+ : undefined,
239
+ );
240
+ }
241
+ return cache.get(key);
242
+ }),
243
+ );
244
+ }
245
+
246
+ function resolveObjectPath(
247
+ document: UdlDocument,
248
+ object: UdlObjectKind,
249
+ path: string,
250
+ cache: Map<string, UdlField | undefined>,
124
251
  ): UdlField | undefined {
125
- return resolvePath(document, instrument, path, input, new Map());
252
+ const parts = path.split(".");
253
+ const key = parts.shift();
254
+ if (!key) return;
255
+ const field = object.fields.find((f) => f.name === key);
256
+ if (!field) return;
257
+ if (parts.length === 0) return field as UdlField;
258
+ if (field.type !== "ref") return;
259
+ return resolveReferencePath(document, field, parts.join("."), cache);
126
260
  }
127
261
 
128
262
  function resolvePath(
@@ -131,30 +265,73 @@ function resolvePath(
131
265
  path: string,
132
266
  input: readonly UdlField[],
133
267
  cache: Map<string, UdlField | undefined>,
268
+ action?: UdlAction | UdlActionSubject,
134
269
  ): UdlField | undefined {
135
270
  const parts = path.split(".");
136
271
  const root = parts.shift();
137
272
  const key = parts.shift();
138
273
  if (!key)
139
274
  return root === "self"
140
- ? { name: "id", type: "ref", target: instrument.id }
275
+ ? {
276
+ name: "id",
277
+ type: "ref",
278
+ targetKind: "instrument",
279
+ target: instrument.id,
280
+ }
141
281
  : undefined;
142
282
  if (root === "party") {
143
- if (!document.parties[key]) return;
283
+ if (!moneyParty(document, instrument, key)) return;
144
284
  if (parts.length === 0)
145
285
  return { name: key, type: "account", owner: key, book: "cash" };
146
286
  if (parts.length === 1 && ["balance", "reserved"].includes(parts[0]!))
147
287
  return { name: parts[0]!, type: "money" };
148
288
  return;
149
289
  }
290
+ if (root === "self" && key === "subject" && instrument.subject) {
291
+ if (parts.length === 0)
292
+ return {
293
+ name: "subject",
294
+ type: "ref",
295
+ targetKind: "object",
296
+ target: instrument.subject,
297
+ };
298
+ const target = document.objects.find((o) => o.id === instrument.subject);
299
+ if (!target) return;
300
+ const suffix = parts.join(".");
301
+ const cacheKey = `obj:${instrument.subject}:${suffix}`;
302
+ if (!cache.has(cacheKey))
303
+ cache.set(cacheKey, resolveObjectPath(document, target, suffix, cache));
304
+ return cache.get(cacheKey);
305
+ }
150
306
  if (root === "self" && parts.length === 0) {
151
- if (key === "id") return { name: key, type: "ref", target: instrument.id };
307
+ if (key === "id")
308
+ return {
309
+ name: key,
310
+ type: "ref",
311
+ targetKind: "instrument",
312
+ target: instrument.id,
313
+ };
152
314
  if (key === "now" || key === "createdAt")
153
315
  return { name: key, type: "date" };
154
316
  if (key === "productRevision") return { name: key, type: "text" };
155
317
  if (key === "status")
156
318
  return { name: key, type: "enum", values: instrument.lifecycle.states };
157
319
  }
320
+ if (root === "subject") {
321
+ const requirement = resolveSubjectRequirement(instrument, key, action);
322
+ if (!requirement || !instrument.subject) return;
323
+ const object = document.objects.find(
324
+ (item) => item.id === instrument.subject,
325
+ );
326
+ if (!object) return;
327
+ return resolveObjectPath(
328
+ document,
329
+ object,
330
+ [requirement.objectField ?? requirement.field.name, ...parts].join("."),
331
+ cache,
332
+ );
333
+ }
334
+
158
335
  const scope = instrument;
159
336
  let field = (
160
337
  root === "self" ? instrument.fields : root === "input" ? input : []
@@ -180,18 +357,11 @@ function resolvePath(
180
357
  values: ["reserved", "posted", "settled", "reversed", "voided"],
181
358
  };
182
359
  if (field?.type !== "ref") return;
183
- return commonField(
184
- targetIds(field.target).map((id) => {
185
- const target = document.instruments.find(
186
- (instrument) => instrument.id === id,
187
- );
188
- if (!target) return;
189
- const suffix = "self." + parts.slice(index).join(".");
190
- const key = `${id}:${suffix}`;
191
- if (!cache.has(key))
192
- cache.set(key, resolvePath(document, target, suffix, [], cache));
193
- return cache.get(key);
194
- }),
360
+ return resolveReferencePath(
361
+ document,
362
+ field,
363
+ parts.slice(index).join("."),
364
+ cache,
195
365
  );
196
366
  }
197
367
  return field;
@@ -238,10 +408,237 @@ export function validateUdl(value: unknown): UdlValidationResult {
238
408
  seen.add(v);
239
409
  }
240
410
  };
411
+ if (document.objects.length === 0 && document.instruments.length === 0)
412
+ add("$", "document requires at least one object or instrument", "UDL2002");
413
+
414
+ duplicate(
415
+ document.objects.map((o) => o.id),
416
+ "$.objects",
417
+ );
241
418
  duplicate(
242
419
  document.instruments.map((i) => i.id),
243
420
  "$.instruments",
244
421
  );
422
+
423
+ for (const name of subjectPartyRoles)
424
+ if (document.parties[name])
425
+ add(
426
+ `$.parties.${name}`,
427
+ `${name} is a reserved subject role`,
428
+ "party_name_reserved",
429
+ );
430
+
431
+ const byObjectId = new Map(document.objects.map((o) => [o.id, o]));
432
+ const byId = new Map(document.instruments.map((i) => [i.id, i]));
433
+
434
+ const checkFields = (
435
+ fields: readonly (UdlField | UdlObjectField)[],
436
+ where: string,
437
+ isObject = false,
438
+ instrument?: UdlInstrument,
439
+ ) => {
440
+ duplicate(
441
+ fields.map((f) => f.name),
442
+ where,
443
+ );
444
+ for (const f of fields) {
445
+ if (isObject) {
446
+ if (reservedObjectNamesSet.has(f.name))
447
+ add(where, `${f.name} is a reserved object name`);
448
+ } else {
449
+ if (f.type === "account" && f.contra && f.book !== "claim")
450
+ add(where, "contra accounts require the claim book");
451
+ if (
452
+ ["id", "status", "createdAt", "now", "productRevision"].includes(
453
+ f.name,
454
+ )
455
+ )
456
+ add(where, `${f.name} is a sealed instance field`);
457
+ if (
458
+ f.type === "account" &&
459
+ f.owner !== "self" &&
460
+ !moneyParty(document, instrument, f.owner)
461
+ )
462
+ add(
463
+ where,
464
+ `${f.name} needs an eligible money party as owner`,
465
+ hasParty(document, instrument, f.owner)
466
+ ? "party_kind_mismatch"
467
+ : "subject_party_unbound",
468
+ );
469
+ }
470
+ if (f.type === "text") {
471
+ if ((f.minLength ?? 1) > (f.maxLength ?? 2048))
472
+ add(where, "text minimum exceeds maximum");
473
+ if (f.pattern) {
474
+ try {
475
+ new RegExp(f.pattern);
476
+ } catch {
477
+ add(where, "invalid text character class");
478
+ }
479
+ }
480
+ }
481
+ if (f.type === "ref") {
482
+ duplicate(targetIds(f.target), where);
483
+ for (const id of targetIds(f.target)) {
484
+ const exists =
485
+ f.targetKind === "object" ? byObjectId.has(id) : byId.has(id);
486
+ if (!exists) add(where, `unknown reference target ${id}`, "UDL5001");
487
+ }
488
+ if (f.targetFamily) {
489
+ if (f.targetKind !== "instrument") {
490
+ add(
491
+ where,
492
+ "targetFamily requires an instrument reference target",
493
+ "UDL5001",
494
+ );
495
+ } else {
496
+ for (const id of targetIds(f.target)) {
497
+ const targetInst = byId.get(id);
498
+ if (
499
+ targetInst &&
500
+ (!targetInst.family ||
501
+ targetInst.family.module !== f.targetFamily.module ||
502
+ targetInst.family.exportPath !== f.targetFamily.exportPath ||
503
+ targetInst.family.revision !== f.targetFamily.revision)
504
+ ) {
505
+ add(
506
+ where,
507
+ `targetFamily does not match target instrument ${id} family`,
508
+ "UDL5001",
509
+ );
510
+ }
511
+ }
512
+ }
513
+ }
514
+ }
515
+ if (f.type === "enum") {
516
+ duplicate(f.values, where);
517
+ if (f.value !== undefined && !f.values.includes(f.value))
518
+ add(where, `${f.name} constant is outside its enum`);
519
+ }
520
+ if (f.type === "list") {
521
+ const validRefList =
522
+ f.item === "ref" &&
523
+ f.targetKind &&
524
+ (f.targetKind === "object"
525
+ ? byObjectId.has(f.target ?? "")
526
+ : byId.has(f.target ?? ""));
527
+ const nonRefTarget =
528
+ f.item !== "ref" &&
529
+ (f.targetKind !== undefined || f.target !== undefined);
530
+ if (!validRefList && (f.item === "ref" || nonRefTarget))
531
+ add(
532
+ where,
533
+ `${f.name} needs a target exactly when its items are references`,
534
+ );
535
+ if (f.targetFamily) {
536
+ if (f.item !== "ref" || f.targetKind !== "instrument") {
537
+ add(
538
+ where,
539
+ "targetFamily requires an instrument reference target",
540
+ "UDL5001",
541
+ );
542
+ } else if (f.target) {
543
+ const targetInst = byId.get(f.target);
544
+ if (
545
+ targetInst &&
546
+ (!targetInst.family ||
547
+ targetInst.family.module !== f.targetFamily.module ||
548
+ targetInst.family.exportPath !== f.targetFamily.exportPath ||
549
+ targetInst.family.revision !== f.targetFamily.revision)
550
+ ) {
551
+ add(
552
+ where,
553
+ `targetFamily does not match target instrument ${f.target} family`,
554
+ "UDL5001",
555
+ );
556
+ }
557
+ }
558
+ }
559
+ }
560
+ if (
561
+ (f.type === "integer" || f.type === "money") &&
562
+ f.minimum !== undefined &&
563
+ f.maximum !== undefined &&
564
+ BigInt(f.minimum) > BigInt(f.maximum)
565
+ )
566
+ add(where, `${f.name} minimum exceeds maximum`);
567
+ if (
568
+ (f.type === "integer" || f.type === "money") &&
569
+ f.value !== undefined &&
570
+ ((f.minimum !== undefined && BigInt(f.value) < BigInt(f.minimum)) ||
571
+ (f.maximum !== undefined && BigInt(f.value) > BigInt(f.maximum)))
572
+ )
573
+ add(where, `${f.name} constant violates its bounds`);
574
+ }
575
+ };
576
+
577
+ for (const [index, obj] of document.objects.entries()) {
578
+ const base = `$.objects[${index}]`;
579
+ if (reservedObjectNamesSet.has(obj.id))
580
+ add(base, `${obj.id} is a reserved object name`);
581
+ duplicate(
582
+ obj.attachments.map((attachment) => attachment.name),
583
+ `${base}.attachments`,
584
+ );
585
+ duplicate(
586
+ obj.attachments.map((attachment) => attachment.instrument),
587
+ `${base}.attachments`,
588
+ );
589
+ for (const [attachmentIndex, attachment] of obj.attachments.entries()) {
590
+ for (const [parameter, binding] of Object.entries(attachment.parties))
591
+ if ("party" in binding && !document.parties[binding.party])
592
+ add(
593
+ `${base}.attachments[${attachmentIndex}].parties.${parameter}`,
594
+ `unknown declared party ${binding.party}`,
595
+ "subject_party_unbound",
596
+ );
597
+ if (byId.get(attachment.instrument)?.subject !== obj.id)
598
+ add(
599
+ `${base}.attachments`,
600
+ `${attachment.instrument} must attach to ${obj.id}`,
601
+ );
602
+ }
603
+ duplicate(obj.authoredFields, `${base}.authoredFields`);
604
+ duplicate(obj.columns, `${base}.columns`);
605
+ checkFields(obj.fields, `${base}.fields`, true);
606
+ for (let i = 0; i < obj.authoredFields.length; i++) {
607
+ const af = obj.authoredFields[i]!;
608
+ if (reservedObjectNamesSet.has(af))
609
+ add(`${base}.authoredFields`, `${af} is a reserved object name`);
610
+ if (!obj.fields.some((f) => f.name === af))
611
+ add(
612
+ `${base}.authoredFields`,
613
+ `authored field ${af} is not in declared fields`,
614
+ "subject_field_unknown",
615
+ );
616
+ if (obj.fields[i]?.name !== af)
617
+ add(
618
+ base,
619
+ `authored field ${af} must precede attached requirements in fields`,
620
+ );
621
+ }
622
+ for (const col of obj.columns) {
623
+ if (reservedObjectNamesSet.has(col))
624
+ add(`${base}.columns`, `${col} is a reserved object name`);
625
+ if (!obj.fields.some((f) => f.name === col))
626
+ add(
627
+ `${base}.columns`,
628
+ `column ${col} is not a declared field`,
629
+ "subject_field_unknown",
630
+ );
631
+ }
632
+ const pubActions = document.instruments
633
+ .filter((i) => i.subject === obj.id)
634
+ .flatMap((i) =>
635
+ Object.values(i.actions).flatMap((a) =>
636
+ a.publicAction ? [a.publicAction] : [],
637
+ ),
638
+ );
639
+ duplicate(pubActions, `$.objects.${obj.id}.publicActions`);
640
+ }
641
+
245
642
  const reportIds = new Set<string>();
246
643
  for (const [index, instrument] of document.instruments.entries()) {
247
644
  for (const report of instrument.reports ?? []) {
@@ -259,19 +656,34 @@ export function validateUdl(value: unknown): UdlValidationResult {
259
656
  }
260
657
  }
261
658
  }
262
- const byId = new Map(document.instruments.map((i) => [i.id, i]));
659
+
263
660
  const calls = new Map<string, { targets: string[]; count: number }[]>();
264
661
  for (const [index, inst] of document.instruments.entries()) {
265
662
  const base = `$.instruments[${index}]`;
266
- const field = (p: string, input: readonly UdlField[] = []) =>
267
- resolveField(document, inst, p, input);
663
+ if (inst.subject && !byObjectId.has(inst.subject))
664
+ add(base, `unknown subject object ${inst.subject}`);
665
+ const field = (
666
+ p: string,
667
+ input: readonly UdlField[] = [],
668
+ action?: UdlAction | UdlActionSubject,
669
+ ) => resolveField(document, inst, p, input, action);
268
670
  const expect = (
269
671
  p: string,
270
672
  type: UdlField["type"],
271
673
  where: string,
272
674
  input: readonly UdlField[] = [],
675
+ action?: UdlAction | UdlActionSubject,
273
676
  ) => {
274
- const found = field(p, input);
677
+ const party = p.startsWith("party.") ? p.split(".")[1]! : undefined;
678
+ if (party && !moneyParty(document, inst, party))
679
+ add(
680
+ where,
681
+ `${party} cannot supply a money account`,
682
+ hasParty(document, inst, party)
683
+ ? "party_kind_mismatch"
684
+ : "subject_party_unbound",
685
+ );
686
+ const found = field(p, input, action);
275
687
  if (found?.type !== type)
276
688
  add(where, `${p} must name a ${type} field`, "UDL5001");
277
689
  return found;
@@ -281,9 +693,10 @@ export function validateUdl(value: unknown): UdlValidationResult {
281
693
  type: UdlField["type"],
282
694
  where: string,
283
695
  input: readonly UdlField[] = [],
696
+ action?: UdlAction | UdlActionSubject,
284
697
  ) => {
285
698
  if ("field" in v) {
286
- expect(v.field, type, where, input);
699
+ expect(v.field, type, where, input, action);
287
700
  return;
288
701
  }
289
702
  const valid =
@@ -299,82 +712,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
299
712
  : typeof v.literal === "string";
300
713
  if (!valid) add(where, `literal must have type ${type}`);
301
714
  };
302
- const checkFields = (fields: readonly UdlField[], where: string) => {
303
- duplicate(
304
- fields.map((f) => f.name),
305
- where,
306
- );
307
- for (const f of fields) {
308
- if (
309
- f.type === "account" &&
310
- ((f.contra && f.book !== "claim") ||
311
- (f.external && (f.book !== "cash" || f.owner === "self")))
312
- )
313
- add(
314
- where,
315
- "contra accounts require the claim book; external accounts require a cash party owner",
316
- );
317
- if (
318
- ["id", "status", "createdAt", "now", "productRevision"].includes(
319
- f.name,
320
- )
321
- )
322
- add(where, `${f.name} is a sealed instance field`);
323
- if (f.type === "text") {
324
- if ((f.minLength ?? 1) > (f.maxLength ?? 2048))
325
- add(where, "text minimum exceeds maximum");
326
- if (f.pattern) {
327
- try {
328
- new RegExp(f.pattern);
329
- } catch {
330
- add(where, "invalid text character class");
331
- }
332
- }
333
- }
334
- if (f.type === "ref") {
335
- duplicate(targetIds(f.target), where);
336
- for (const id of targetIds(f.target))
337
- if (!byId.has(id))
338
- add(where, `unknown reference target ${id}`, "UDL5001");
339
- }
340
- if (
341
- f.type === "account" &&
342
- f.owner !== "self" &&
343
- !document.parties[f.owner]
344
- )
345
- add(where, `${f.name} needs self or a declared party as owner`);
346
- if (f.type === "enum") {
347
- duplicate(f.values, where);
348
- if (f.value !== undefined && !f.values.includes(f.value))
349
- add(where, `${f.name} constant is outside its enum`);
350
- }
351
- if (
352
- f.type === "list" &&
353
- (f.item === "ref"
354
- ? !f.target || !byId.has(f.target)
355
- : f.target !== undefined)
356
- )
357
- add(
358
- where,
359
- `${f.name} needs a target exactly when its items are references`,
360
- );
361
- if (
362
- (f.type === "integer" || f.type === "money") &&
363
- f.minimum !== undefined &&
364
- f.maximum !== undefined &&
365
- BigInt(f.minimum) > BigInt(f.maximum)
366
- )
367
- add(where, `${f.name} minimum exceeds maximum`);
368
- if (
369
- (f.type === "integer" || f.type === "money") &&
370
- f.value !== undefined &&
371
- ((f.minimum !== undefined && BigInt(f.value) < BigInt(f.minimum)) ||
372
- (f.maximum !== undefined && BigInt(f.value) > BigInt(f.maximum)))
373
- )
374
- add(where, `${f.name} constant violates its bounds`);
375
- }
376
- };
377
- checkFields(inst.fields, `${base}.fields`);
715
+ checkFields(inst.fields, `${base}.fields`, false, inst);
378
716
  const captures = new Set(
379
717
  Object.values(inst.actions).flatMap((a) => [
380
718
  ...a.moves.flatMap((m) =>
@@ -409,9 +747,24 @@ export function validateUdl(value: unknown): UdlValidationResult {
409
747
  a &&
410
748
  b &&
411
749
  a.type === b.type &&
412
- (a.type !== "ref" || (b.type === "ref" && overlap(a.target, b.target)));
750
+ (a.type !== "ref" ||
751
+ (b.type === "ref" &&
752
+ a.targetKind === b.targetKind &&
753
+ overlap(a.target, b.target)));
413
754
  const anchor = field(selection.anchor, input);
414
755
  for (const target of targets) {
756
+ if (
757
+ selection.family &&
758
+ (!target.family ||
759
+ target.family.module !== selection.family.module ||
760
+ target.family.exportPath !== selection.family.exportPath ||
761
+ target.family.revision !== selection.family.revision)
762
+ )
763
+ add(
764
+ where,
765
+ `selection family does not match target instrument ${target.id} family`,
766
+ "UDL5001",
767
+ );
415
768
  if (
416
769
  selection.window &&
417
770
  target.fields.find((f) => f.name === selection.window!.field)
@@ -471,6 +824,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
471
824
  calculations: readonly UdlCalculation[],
472
825
  where: string,
473
826
  input: readonly UdlField[] = [],
827
+ act?: UdlAction,
474
828
  ) => {
475
829
  duplicate(
476
830
  calculations.map((c) => c.target),
@@ -484,7 +838,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
484
838
  const resultType =
485
839
  c.op === "at"
486
840
  ? (() => {
487
- const list = field(c.list, input);
841
+ const list = field(c.list, input, act);
488
842
  return list?.type === "list" ? list.item : "text";
489
843
  })()
490
844
  : (c.op === "aggregate" && c.measure === "count") ||
@@ -499,11 +853,11 @@ export function validateUdl(value: unknown): UdlValidationResult {
499
853
  const operands: UdlValue[] = [];
500
854
  const money = (v: UdlValue) => {
501
855
  operands.push(v);
502
- checkValue(v, arithmetic ? resultType : "money", where, input);
856
+ checkValue(v, arithmetic ? resultType : "money", where, input, act);
503
857
  };
504
858
  if (c.op === "at") {
505
- expect(c.list, "list", where, input);
506
- checkValue(c.position, "integer", where, input);
859
+ expect(c.list, "list", where, input, act);
860
+ checkValue(c.position, "integer", where, input, act);
507
861
  if ("literal" in c.position && Number(c.position.literal) < 1)
508
862
  add(where, "list positions start at one");
509
863
  operands.push({ field: c.list }, c.position);
@@ -522,15 +876,15 @@ export function validateUdl(value: unknown): UdlValidationResult {
522
876
  money(c.amount);
523
877
  const type =
524
878
  "field" in c.numerator
525
- ? field(c.numerator.field, input)?.type
879
+ ? field(c.numerator.field, input, act)?.type
526
880
  : typeof c.numerator.literal === "number"
527
881
  ? "integer"
528
882
  : "money";
529
883
  if (type !== "integer" && type !== "money" && type !== "percent")
530
884
  add(where, "ratio weights must share a numeric type");
531
885
  else {
532
- checkValue(c.numerator, type, where, input);
533
- checkValue(c.denominator, type, where, input);
886
+ checkValue(c.numerator, type, where, input, act);
887
+ checkValue(c.denominator, type, where, input, act);
534
888
  }
535
889
  if (
536
890
  "literal" in c.denominator &&
@@ -546,13 +900,13 @@ export function validateUdl(value: unknown): UdlValidationResult {
546
900
  }
547
901
  if (c.op === "rate") {
548
902
  money(c.base);
549
- checkValue(c.bps, "percent", where, input);
903
+ checkValue(c.bps, "percent", where, input, act);
550
904
  operands.push(c.bps);
551
905
  }
552
906
  if (c.op === "multiply" || c.op === "divide") {
553
907
  money(c.amount);
554
908
  const count = c.op === "multiply" ? c.units : c.divisor;
555
- checkValue(count, "integer", where, input);
909
+ checkValue(count, "integer", where, input, act);
556
910
  operands.push(count);
557
911
  if (
558
912
  "literal" in count &&
@@ -566,8 +920,8 @@ export function validateUdl(value: unknown): UdlValidationResult {
566
920
  );
567
921
  }
568
922
  if (c.op === "shift") {
569
- checkValue(c.date, "date", where, input);
570
- checkValue(c.milliseconds, "duration", where, input);
923
+ checkValue(c.date, "date", where, input, act);
924
+ checkValue(c.milliseconds, "duration", where, input, act);
571
925
  operands.push(c.date, c.milliseconds);
572
926
  }
573
927
  dependencies.set(
@@ -628,13 +982,16 @@ export function validateUdl(value: unknown): UdlValidationResult {
628
982
  requirements: typeof inst.invariants,
629
983
  where: string,
630
984
  input: readonly UdlField[] = [],
985
+ act?: UdlAction,
631
986
  ) => {
632
987
  for (const req of requirements ?? []) {
633
988
  if (req.kind === "compare") {
634
989
  const left =
635
- "field" in req.left ? field(req.left.field, input) : undefined;
990
+ "field" in req.left ? field(req.left.field, input, act) : undefined;
636
991
  const right =
637
- "field" in req.right ? field(req.right.field, input) : undefined;
992
+ "field" in req.right
993
+ ? field(req.right.field, input, act)
994
+ : undefined;
638
995
  if (
639
996
  ("field" in req.left && !left) ||
640
997
  ("field" in req.right && !right)
@@ -646,11 +1003,12 @@ export function validateUdl(value: unknown): UdlValidationResult {
646
1003
  (left.type !== right.type ||
647
1004
  (left.type === "ref" &&
648
1005
  right.type === "ref" &&
649
- !overlap(left.target, right.target)))
1006
+ (left.targetKind !== right.targetKind ||
1007
+ !overlap(left.target, right.target))))
650
1008
  )
651
1009
  add(where, "comparison operands have different types");
652
1010
  if (left && "literal" in req.right) {
653
- checkValue(req.right, left.type, where, input);
1011
+ checkValue(req.right, left.type, where, input, act);
654
1012
  if (
655
1013
  left.type === "enum" &&
656
1014
  !left.values.includes(String(req.right.literal))
@@ -658,7 +1016,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
658
1016
  add(where, "comparison names an undeclared enum value");
659
1017
  }
660
1018
  if (right && "literal" in req.left) {
661
- checkValue(req.left, right.type, where, input);
1019
+ checkValue(req.left, right.type, where, input, act);
662
1020
  if (
663
1021
  right.type === "enum" &&
664
1022
  !right.values.includes(String(req.left.literal))
@@ -666,9 +1024,10 @@ export function validateUdl(value: unknown): UdlValidationResult {
666
1024
  add(where, "comparison names an undeclared enum value");
667
1025
  }
668
1026
  } else if (req.kind === "state") {
669
- const target = expect(req.reference, "ref", where, input);
1027
+ const target = expect(req.reference, "ref", where, input, act);
670
1028
  if (
671
1029
  target?.type === "ref" &&
1030
+ target.targetKind === "instrument" &&
672
1031
  req.states.some((s) =>
673
1032
  targetIds(target.target).some(
674
1033
  (id) => !byId.get(id)?.lifecycle.states.includes(s),
@@ -678,28 +1037,59 @@ export function validateUdl(value: unknown): UdlValidationResult {
678
1037
  add(where, "reference gate names an undeclared state");
679
1038
  } else if (req.kind === "unique") {
680
1039
  for (const p of req.fields)
681
- if (!field(p, input)) add(where, `unknown identity field ${p}`);
1040
+ if (!field(p, input, act))
1041
+ add(where, `unknown identity field ${p}`);
682
1042
  } else if (req.kind === "approval") {
683
- const target = expect(req.target, "ref", where, input);
684
- if (!document.parties[req.party])
685
- add(where, `unknown approving party ${req.party}`);
1043
+ const request = expect(
1044
+ req.protectedRequest,
1045
+ "ref",
1046
+ where,
1047
+ input,
1048
+ act,
1049
+ );
1050
+ if (request?.type === "ref" && request.targetKind !== "instrument")
1051
+ add(
1052
+ where,
1053
+ "protectedRequest must reference an instrument",
1054
+ "UDL5001",
1055
+ );
1056
+ const target = expect(req.target, "ref", where, input, act);
1057
+ if (!approvalParty(document, req.party))
1058
+ add(
1059
+ where,
1060
+ `approval requires staff with a role: ${req.party}`,
1061
+ hasParty(document, inst, req.party)
1062
+ ? "party_kind_mismatch"
1063
+ : "subject_party_unbound",
1064
+ );
686
1065
  if (
687
1066
  req.action &&
688
1067
  target?.type === "ref" &&
1068
+ target.targetKind === "instrument" &&
689
1069
  targetIds(target.target).some(
690
1070
  (id) => !byId.get(id)?.actions[req.action!],
691
1071
  )
692
1072
  )
693
1073
  add(where, `unknown approved action ${req.action}`);
694
1074
  } else if (req.kind === "evidence") {
695
- const subject = field(req.subject, input);
696
- if (!subject || !["account", "text"].includes(subject.type))
1075
+ if (req.instruction) {
1076
+ expect(req.instruction, "text", where, input, act);
1077
+ if (!["confirmed", "rejected"].includes(req.result))
1078
+ add(where, "instruction evidence requires a terminal outcome");
1079
+ }
1080
+ const subject = field(req.subject, input, act);
1081
+ if (
1082
+ !subject ||
1083
+ !["account", "text", ...(req.instruction ? ["ref"] : [])].includes(
1084
+ subject.type,
1085
+ )
1086
+ )
697
1087
  add(
698
1088
  where,
699
1089
  "evidence subject must be an account or text subject id",
700
1090
  );
701
1091
  } else if (req.kind === "hours") {
702
- expect(req.at, "date", where, input);
1092
+ expect(req.at, "date", where, input, act);
703
1093
  try {
704
1094
  new Intl.DateTimeFormat("en", { timeZone: req.timezone });
705
1095
  } catch {
@@ -723,6 +1113,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
723
1113
  req.measure === "count" ? "integer" : "money",
724
1114
  where,
725
1115
  input,
1116
+ act,
726
1117
  );
727
1118
  }
728
1119
  }
@@ -733,11 +1124,83 @@ export function validateUdl(value: unknown): UdlValidationResult {
733
1124
  const where = `${base}.actions.${actionName}`;
734
1125
  if (actionName !== "create" && !inst.lifecycle.transitions[actionName])
735
1126
  add(where, "action needs a lifecycle transition", "UDL3001");
736
- checkFields(action.input, `${where}.input`);
1127
+ if (action.subject) {
1128
+ const subBase = `${where}.subject`;
1129
+ checkFields(
1130
+ action.subject.requirements.map((r) => ({
1131
+ ...r.field,
1132
+ name: r.objectField ?? r.field.name,
1133
+ })),
1134
+ `${subBase}.requirements`,
1135
+ true,
1136
+ );
1137
+ for (const req of action.subject.requirements) {
1138
+ if (req.field.optional)
1139
+ add(
1140
+ `${subBase}.requirements`,
1141
+ `subject requirement ${req.field.name} cannot be optional`,
1142
+ "UDL2002",
1143
+ );
1144
+ const name = req.objectField ?? req.field.name;
1145
+ if (reservedObjectNamesSet.has(name))
1146
+ add(subBase, `${name} is a reserved object name`);
1147
+ if (inst.subject) {
1148
+ const field = byObjectId
1149
+ .get(inst.subject)
1150
+ ?.fields.find((field) => field.name === name);
1151
+ if (!field || !sameObjectField(field, req.field))
1152
+ add(
1153
+ subBase,
1154
+ `object field ${name} must match requirement ${req.field.name}`,
1155
+ field ? "subject_field_conflict" : "subject_field_unknown",
1156
+ );
1157
+ }
1158
+ }
1159
+ duplicate(
1160
+ action.subject.adapters.map((a) => a.binding),
1161
+ `${subBase}.adapters`,
1162
+ );
1163
+ for (const adapter of action.subject.adapters) {
1164
+ if (adapter.snapshot) {
1165
+ checkFields(
1166
+ adapter.snapshot.requirements,
1167
+ `${subBase}.adapters.${adapter.binding}.requirements`,
1168
+ true,
1169
+ );
1170
+ for (const reqField of adapter.snapshot.requirements) {
1171
+ const objectName =
1172
+ adapter.renames?.[reqField.name] ?? reqField.name;
1173
+ const requirement = action.subject.requirements.find(
1174
+ (item) =>
1175
+ (item.objectField ?? item.field.name) === objectName &&
1176
+ sameObjectField(item.field, reqField),
1177
+ );
1178
+ if (!requirement)
1179
+ add(
1180
+ subBase,
1181
+ `adapter ${adapter.binding} requirement ${reqField.name} is absent from the action`,
1182
+ );
1183
+ if (reqField.optional)
1184
+ add(
1185
+ `${subBase}.adapters.${adapter.binding}`,
1186
+ `adapter requirement ${reqField.name} cannot be optional`,
1187
+ "UDL2002",
1188
+ );
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+ checkFields(action.input, `${where}.input`, false, inst);
737
1194
  if (action.reminder) {
738
- expect(action.reminder.installment, "ref", where, action.input);
739
- expect(action.reminder.recipient, "account", where, action.input);
740
- expect(action.reminder.dueAt, "date", where, action.input);
1195
+ expect(action.reminder.installment, "ref", where, action.input, action);
1196
+ expect(
1197
+ action.reminder.recipient,
1198
+ "account",
1199
+ where,
1200
+ action.input,
1201
+ action,
1202
+ );
1203
+ expect(action.reminder.dueAt, "date", where, action.input, action);
741
1204
  if (action.reminder.startHour >= action.reminder.endHour)
742
1205
  add(where, "reminder sending window is empty");
743
1206
  try {
@@ -773,12 +1236,18 @@ export function validateUdl(value: unknown): UdlValidationResult {
773
1236
  action.calculate ?? [],
774
1237
  `${where}.calculate`,
775
1238
  action.input,
1239
+ action,
1240
+ );
1241
+ checkRequirements(
1242
+ action.requires,
1243
+ `${where}.requires`,
1244
+ action.input,
1245
+ action,
776
1246
  );
777
- checkRequirements(action.requires, `${where}.requires`, action.input);
778
1247
  if (
779
1248
  typeof action.actor === "object" &&
780
1249
  "party" in action.actor &&
781
- !document.parties[action.actor.party]
1250
+ !hasParty(document, inst, action.actor.party)
782
1251
  )
783
1252
  add(where, "actor names an undeclared party");
784
1253
  if (
@@ -791,7 +1260,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
791
1260
  add(where, "clock action needs a due instant");
792
1261
  for (const clock of [action.due, action.deadline])
793
1262
  if (clock) {
794
- expect(clock.at, "date", where, action.input);
1263
+ expect(clock.at, "date", where, action.input, action);
795
1264
  if (clock.localDay) {
796
1265
  try {
797
1266
  new Intl.DateTimeFormat("en", {
@@ -803,10 +1272,28 @@ export function validateUdl(value: unknown): UdlValidationResult {
803
1272
  }
804
1273
  }
805
1274
  for (const move of action.moves) {
1275
+ if (
1276
+ move.operation === "internal_transfer.reserve" &&
1277
+ move.boundary &&
1278
+ !action.subject?.adapters.some(
1279
+ (binding) =>
1280
+ binding.binding === move.boundary!.adapter && binding.snapshot,
1281
+ )
1282
+ )
1283
+ add(
1284
+ where,
1285
+ "boundary reservation requires a retained adapter binding",
1286
+ );
806
1287
  if ("amount" in move) {
807
- checkValue(move.amount, "money", where, action.input);
808
- const from = expect(move.from, "account", where, action.input);
809
- const to = expect(move.to, "account", where, action.input);
1288
+ checkValue(move.amount, "money", where, action.input, action);
1289
+ const from = expect(
1290
+ move.from,
1291
+ "account",
1292
+ where,
1293
+ action.input,
1294
+ action,
1295
+ );
1296
+ const to = expect(move.to, "account", where, action.input, action);
810
1297
  if (
811
1298
  from?.type === "account" &&
812
1299
  to?.type === "account" &&
@@ -822,7 +1309,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
822
1309
  (from.key ?? "balance") === (to.key ?? "balance");
823
1310
  if (move.from === move.to || samePartyAccount)
824
1311
  add(where, "a transfer needs distinct accounts", "UDL4001");
825
- } else expect(move.transfer, "text", where, action.input);
1312
+ } else expect(move.transfer, "text", where, action.input, action);
826
1313
  }
827
1314
  duplicate(
828
1315
  action.moves.flatMap((m) =>
@@ -845,7 +1332,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
845
1332
  inst.calculate.some((c) => c.target === key)
846
1333
  )
847
1334
  add(where, `cannot write immutable field ${key}`);
848
- else checkValue(v, target.type, where, action.input);
1335
+ else checkValue(v, target.type, where, action.input, action);
849
1336
  }
850
1337
  const checkArguments = (
851
1338
  targetId: string | undefined,
@@ -875,11 +1362,12 @@ export function validateUdl(value: unknown): UdlValidationResult {
875
1362
  add(where, "range binding requires an integer target input");
876
1363
  continue;
877
1364
  }
878
- checkValue(value, declared.type, where, action.input);
1365
+ checkValue(value, declared.type, where, action.input, action);
879
1366
  if (declared.type === "ref" && "field" in value) {
880
- const supplied = field(value.field, action.input);
1367
+ const supplied = field(value.field, action.input, action);
881
1368
  if (
882
1369
  supplied?.type !== "ref" ||
1370
+ supplied.targetKind !== declared.targetKind ||
883
1371
  !targetIds(supplied.target).every((id) =>
884
1372
  targetIds(declared.target).includes(id),
885
1373
  )
@@ -889,33 +1377,55 @@ export function validateUdl(value: unknown): UdlValidationResult {
889
1377
  }
890
1378
  };
891
1379
  if (action.approval) {
1380
+ const request = expect(
1381
+ action.approval.protectedRequest,
1382
+ "ref",
1383
+ where,
1384
+ action.input,
1385
+ action,
1386
+ );
1387
+ if (request?.type === "ref" && request.targetKind !== "instrument")
1388
+ add(
1389
+ where,
1390
+ "protectedRequest must reference an instrument",
1391
+ "UDL5001",
1392
+ );
892
1393
  const reference = expect(
893
1394
  action.approval.target,
894
1395
  "ref",
895
1396
  where,
896
1397
  action.input,
1398
+ action,
897
1399
  );
898
1400
  if (
899
1401
  reference?.type === "ref" &&
1402
+ reference.targetKind === "instrument" &&
900
1403
  targetIds(reference.target).some(
901
1404
  (id) => !byId.get(id)?.actions[action.approval!.action],
902
1405
  )
903
1406
  )
904
1407
  add(where, "approval target action does not exist");
905
- if (reference?.type === "ref")
1408
+ if (reference?.type === "ref" && reference.targetKind === "instrument")
906
1409
  for (const id of targetIds(reference.target))
907
1410
  checkArguments(id, action.approval.action, action.approval.input);
908
- expect(action.approval.expires, "date", where, action.input);
909
- if (!document.parties[action.approval.party])
910
- add(where, "approval needs a declared party");
1411
+ expect(action.approval.expires, "date", where, action.input, action);
1412
+ if (!approvalParty(document, action.approval.party))
1413
+ add(
1414
+ where,
1415
+ "approval requires staff with a role",
1416
+ hasParty(document, inst, action.approval.party)
1417
+ ? "party_kind_mismatch"
1418
+ : "subject_party_unbound",
1419
+ );
911
1420
  }
912
1421
  const targets: { targets: string[]; count: number }[] = [];
913
1422
  for (const call of action.invoke ?? []) {
914
- if (call.guard) checkRequirements([call.guard], where, action.input);
1423
+ if (call.guard)
1424
+ checkRequirements([call.guard], where, action.input, action);
915
1425
  const range = "instrument" in call ? call.range : undefined;
916
1426
  if (range) {
917
- checkValue(range.count, "integer", where, action.input);
918
- if (["self", "input", "party"].includes(range.bind))
1427
+ checkValue(range.count, "integer", where, action.input, action);
1428
+ if (["self", "input", "party", "subject"].includes(range.bind))
919
1429
  add(where, "range binding cannot shadow a reserved scope");
920
1430
  if (
921
1431
  "literal" in range.count &&
@@ -928,13 +1438,15 @@ export function validateUdl(value: unknown): UdlValidationResult {
928
1438
  if ("selection" in call)
929
1439
  checkSelection(call.selection, where, action.input);
930
1440
  const target =
931
- "reference" in call ? field(call.reference, action.input) : undefined;
1441
+ "reference" in call
1442
+ ? field(call.reference, action.input, action)
1443
+ : undefined;
932
1444
  const selected =
933
1445
  "instrument" in call
934
1446
  ? call.instrument
935
1447
  : "selection" in call
936
1448
  ? call.selection.instrument
937
- : target?.type === "ref"
1449
+ : target?.type === "ref" && target.targetKind === "instrument"
938
1450
  ? target.target
939
1451
  : undefined;
940
1452
  const ids = Array.isArray(selected)
@@ -950,8 +1462,35 @@ export function validateUdl(value: unknown): UdlValidationResult {
950
1462
  where,
951
1463
  "invocation references an existing instance and cannot create it again",
952
1464
  );
953
- if (!byId.get(targetId)?.actions[call.action])
1465
+ const targetInst = byId.get(targetId);
1466
+ if (!targetInst?.actions[call.action])
954
1467
  add(where, "invocation target must name a declared action");
1468
+ else if (!call.guard) {
1469
+ const targetAction = targetInst.actions[call.action]!;
1470
+ if (
1471
+ inst.subject &&
1472
+ targetInst.subject &&
1473
+ inst.subject === targetInst.subject
1474
+ ) {
1475
+ const callerRequirements = action.subject?.requirements ?? [];
1476
+ for (const targetReq of targetAction.subject?.requirements ??
1477
+ []) {
1478
+ const targetObjFieldName =
1479
+ targetReq.objectField ?? targetReq.field.name;
1480
+ const satisfied = callerRequirements.some(
1481
+ (cr) =>
1482
+ (cr.objectField ?? cr.field.name) === targetObjFieldName,
1483
+ );
1484
+ if (!satisfied) {
1485
+ add(
1486
+ `${where}.invoke`,
1487
+ `mandatory invocation of ${targetId}.${call.action} requires subject field ${targetReq.field.name}`,
1488
+ "UDL2002",
1489
+ );
1490
+ }
1491
+ }
1492
+ }
1493
+ }
955
1494
  }
956
1495
  targets.push({
957
1496
  targets: ids.map((id) => `${id}.${call.action}`),