@hyperscale0/udl 3.1.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/dist/diagnostics.d.ts +1 -1
  4. package/dist/diagnostics.d.ts.map +1 -1
  5. package/dist/diagnostics.js +13 -1
  6. package/dist/diagnostics.js.map +1 -1
  7. package/dist/evolution.d.ts.map +1 -1
  8. package/dist/evolution.js +5 -0
  9. package/dist/evolution.js.map +1 -1
  10. package/dist/finance.js +2 -1
  11. package/dist/finance.js.map +1 -1
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/instrument-references.d.ts.map +1 -1
  17. package/dist/instrument-references.js +21 -1
  18. package/dist/instrument-references.js.map +1 -1
  19. package/dist/object-contract.d.ts +157 -0
  20. package/dist/object-contract.d.ts.map +1 -0
  21. package/dist/object-contract.js +306 -0
  22. package/dist/object-contract.js.map +1 -0
  23. package/dist/reporting-validation.d.ts +8 -0
  24. package/dist/reporting-validation.d.ts.map +1 -0
  25. package/dist/reporting-validation.js +245 -0
  26. package/dist/reporting-validation.js.map +1 -0
  27. package/dist/reporting.d.ts +478 -0
  28. package/dist/reporting.d.ts.map +1 -0
  29. package/dist/reporting.js +170 -0
  30. package/dist/reporting.js.map +1 -0
  31. package/dist/schema.d.ts +2933 -24
  32. package/dist/schema.d.ts.map +1 -1
  33. package/dist/schema.js +163 -23
  34. package/dist/schema.js.map +1 -1
  35. package/dist/validation.d.ts +5 -2
  36. package/dist/validation.d.ts.map +1 -1
  37. package/dist/validation.js +495 -124
  38. package/dist/validation.js.map +1 -1
  39. package/docs/README.md +87 -9
  40. package/package.json +4 -4
  41. package/spec/README.md +87 -9
  42. package/spec/{darb.udl.json → cars.udl.json} +7 -7
  43. package/spec/udl.schema.json +2969 -4558
  44. package/src/diagnostics.ts +33 -1
  45. package/src/evolution.ts +8 -0
  46. package/src/finance.ts +1 -1
  47. package/src/index.ts +9 -0
  48. package/src/instrument-references.ts +25 -1
  49. package/src/object-contract.ts +498 -0
  50. package/src/reporting-validation.ts +381 -0
  51. package/src/reporting.ts +179 -0
  52. package/src/schema.ts +219 -23
  53. package/src/validation.ts +765 -136
package/src/validation.ts CHANGED
@@ -1,15 +1,71 @@
1
1
  import {
2
2
  udlDocumentSchema,
3
+ subjectPartyRoles,
4
+ sameObjectField,
5
+ MAX_ACTION_EXPANSION,
3
6
  type UdlDocument,
4
7
  type UdlField,
5
8
  type UdlInstrument,
6
9
  type UdlValue,
7
10
  type UdlCalculation,
8
11
  type UdlSelection,
12
+ type UdlAction,
13
+ type UdlActionSubject,
14
+ type UdlSubjectRequirement,
15
+ type UdlObjectField,
16
+ type UdlObjectKind,
9
17
  } from "./schema.js";
10
18
  import { issue, type UdlIssue } from "./diagnostics.js";
19
+ import { validateReportDefinition } from "./reporting-validation.js";
11
20
  import { analyzeInstrumentFinance } from "./finance.js";
12
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
+
13
69
  export type UdlValidationResult =
14
70
  | { ok: true; value: UdlDocument }
15
71
  | { ok: false; issues: readonly UdlIssue[] };
@@ -56,7 +112,9 @@ const overlap = (a: string | string[], b: string | string[]) =>
56
112
  targetIds(a).some((id) => targetIds(b).includes(id));
57
113
 
58
114
  /** Union paths retain only fields with compatible types on every member. */
59
- function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
115
+ function commonField(
116
+ fields: (UdlField | UdlObjectField | undefined)[],
117
+ ): UdlField | undefined {
60
118
  const first = fields[0];
61
119
  if (
62
120
  !first ||
@@ -69,6 +127,16 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
69
127
  )
70
128
  return;
71
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;
72
140
  const targets = [
73
141
  ...new Set(
74
142
  fields.flatMap((field) =>
@@ -76,7 +144,12 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
76
144
  ),
77
145
  ),
78
146
  ];
79
- 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;
80
153
  }
81
154
  if (
82
155
  first.type === "account" &&
@@ -86,7 +159,6 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
86
159
  field.book !== first.book ||
87
160
  field.owner !== first.owner ||
88
161
  field.key !== first.key ||
89
- field.external !== first.external ||
90
162
  field.contra !== first.contra,
91
163
  )
92
164
  )
@@ -107,11 +179,12 @@ function commonField(fields: (UdlField | undefined)[]): UdlField | undefined {
107
179
  (field) =>
108
180
  field?.type !== "list" ||
109
181
  field.item !== first.item ||
182
+ field.targetKind !== first.targetKind ||
110
183
  field.target !== first.target,
111
184
  )
112
185
  )
113
186
  return;
114
- return first;
187
+ return first as UdlField;
115
188
  }
116
189
 
117
190
  export function resolveField(
@@ -119,8 +192,71 @@ export function resolveField(
119
192
  instrument: UdlInstrument,
120
193
  path: string,
121
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>,
122
221
  ): UdlField | undefined {
123
- return resolvePath(document, instrument, path, input, new Map());
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>,
251
+ ): UdlField | undefined {
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);
124
260
  }
125
261
 
126
262
  function resolvePath(
@@ -129,29 +265,73 @@ function resolvePath(
129
265
  path: string,
130
266
  input: readonly UdlField[],
131
267
  cache: Map<string, UdlField | undefined>,
268
+ action?: UdlAction | UdlActionSubject,
132
269
  ): UdlField | undefined {
133
270
  const parts = path.split(".");
134
271
  const root = parts.shift();
135
272
  const key = parts.shift();
136
273
  if (!key)
137
274
  return root === "self"
138
- ? { name: "id", type: "ref", target: instrument.id }
275
+ ? {
276
+ name: "id",
277
+ type: "ref",
278
+ targetKind: "instrument",
279
+ target: instrument.id,
280
+ }
139
281
  : undefined;
140
282
  if (root === "party") {
141
- if (!document.parties[key]) return;
283
+ if (!moneyParty(document, instrument, key)) return;
142
284
  if (parts.length === 0)
143
285
  return { name: key, type: "account", owner: key, book: "cash" };
144
286
  if (parts.length === 1 && ["balance", "reserved"].includes(parts[0]!))
145
287
  return { name: parts[0]!, type: "money" };
146
288
  return;
147
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
+ }
148
306
  if (root === "self" && parts.length === 0) {
149
- 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
+ };
150
314
  if (key === "now" || key === "createdAt")
151
315
  return { name: key, type: "date" };
316
+ if (key === "productRevision") return { name: key, type: "text" };
152
317
  if (key === "status")
153
318
  return { name: key, type: "enum", values: instrument.lifecycle.states };
154
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
+
155
335
  const scope = instrument;
156
336
  let field = (
157
337
  root === "self" ? instrument.fields : root === "input" ? input : []
@@ -177,18 +357,11 @@ function resolvePath(
177
357
  values: ["reserved", "posted", "settled", "reversed", "voided"],
178
358
  };
179
359
  if (field?.type !== "ref") return;
180
- return commonField(
181
- targetIds(field.target).map((id) => {
182
- const target = document.instruments.find(
183
- (instrument) => instrument.id === id,
184
- );
185
- if (!target) return;
186
- const suffix = "self." + parts.slice(index).join(".");
187
- const key = `${id}:${suffix}`;
188
- if (!cache.has(key))
189
- cache.set(key, resolvePath(document, target, suffix, [], cache));
190
- return cache.get(key);
191
- }),
360
+ return resolveReferencePath(
361
+ document,
362
+ field,
363
+ parts.slice(index).join("."),
364
+ cache,
192
365
  );
193
366
  }
194
367
  return field;
@@ -235,23 +408,282 @@ export function validateUdl(value: unknown): UdlValidationResult {
235
408
  seen.add(v);
236
409
  }
237
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
+ );
238
418
  duplicate(
239
419
  document.instruments.map((i) => i.id),
240
420
  "$.instruments",
241
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]));
242
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
+
642
+ const reportIds = new Set<string>();
643
+ for (const [index, instrument] of document.instruments.entries()) {
644
+ for (const report of instrument.reports ?? []) {
645
+ const path = `$.instruments[${index}].reports`;
646
+ if (reportIds.has(report.identity.id))
647
+ add(path, `duplicate report ${report.identity.id}`);
648
+ reportIds.add(report.identity.id);
649
+ try {
650
+ validateReportDefinition(report, document);
651
+ } catch (error) {
652
+ add(
653
+ path,
654
+ error instanceof Error ? error.message : "Invalid report definition",
655
+ );
656
+ }
657
+ }
658
+ }
659
+
243
660
  const calls = new Map<string, { targets: string[]; count: number }[]>();
244
661
  for (const [index, inst] of document.instruments.entries()) {
245
662
  const base = `$.instruments[${index}]`;
246
- const field = (p: string, input: readonly UdlField[] = []) =>
247
- 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);
248
670
  const expect = (
249
671
  p: string,
250
672
  type: UdlField["type"],
251
673
  where: string,
252
674
  input: readonly UdlField[] = [],
675
+ action?: UdlAction | UdlActionSubject,
253
676
  ) => {
254
- 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);
255
687
  if (found?.type !== type)
256
688
  add(where, `${p} must name a ${type} field`, "UDL5001");
257
689
  return found;
@@ -261,9 +693,10 @@ export function validateUdl(value: unknown): UdlValidationResult {
261
693
  type: UdlField["type"],
262
694
  where: string,
263
695
  input: readonly UdlField[] = [],
696
+ action?: UdlAction | UdlActionSubject,
264
697
  ) => {
265
698
  if ("field" in v) {
266
- expect(v.field, type, where, input);
699
+ expect(v.field, type, where, input, action);
267
700
  return;
268
701
  }
269
702
  const valid =
@@ -279,67 +712,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
279
712
  : typeof v.literal === "string";
280
713
  if (!valid) add(where, `literal must have type ${type}`);
281
714
  };
282
- const checkFields = (fields: readonly UdlField[], where: string) => {
283
- duplicate(
284
- fields.map((f) => f.name),
285
- where,
286
- );
287
- for (const f of fields) {
288
- if (
289
- f.type === "account" &&
290
- ((f.contra && f.book !== "claim") ||
291
- (f.external && (f.book !== "cash" || f.owner === "self")))
292
- )
293
- add(
294
- where,
295
- "contra accounts require the claim book; external accounts require a cash party owner",
296
- );
297
- if (["id", "status", "createdAt", "now"].includes(f.name))
298
- add(where, `${f.name} is a sealed instance field`);
299
- if (f.type === "ref") {
300
- duplicate(targetIds(f.target), where);
301
- for (const id of targetIds(f.target))
302
- if (!byId.has(id))
303
- add(where, `unknown reference target ${id}`, "UDL5001");
304
- }
305
- if (
306
- f.type === "account" &&
307
- f.owner !== "self" &&
308
- !document.parties[f.owner]
309
- )
310
- add(where, `${f.name} needs self or a declared party as owner`);
311
- if (f.type === "enum") {
312
- duplicate(f.values, where);
313
- if (f.value !== undefined && !f.values.includes(f.value))
314
- add(where, `${f.name} constant is outside its enum`);
315
- }
316
- if (
317
- f.type === "list" &&
318
- (f.item === "ref"
319
- ? !f.target || !byId.has(f.target)
320
- : f.target !== undefined)
321
- )
322
- add(
323
- where,
324
- `${f.name} needs a target exactly when its items are references`,
325
- );
326
- if (
327
- (f.type === "integer" || f.type === "money") &&
328
- f.minimum !== undefined &&
329
- f.maximum !== undefined &&
330
- BigInt(f.minimum) > BigInt(f.maximum)
331
- )
332
- add(where, `${f.name} minimum exceeds maximum`);
333
- if (
334
- (f.type === "integer" || f.type === "money") &&
335
- f.value !== undefined &&
336
- ((f.minimum !== undefined && BigInt(f.value) < BigInt(f.minimum)) ||
337
- (f.maximum !== undefined && BigInt(f.value) > BigInt(f.maximum)))
338
- )
339
- add(where, `${f.name} constant violates its bounds`);
340
- }
341
- };
342
- checkFields(inst.fields, `${base}.fields`);
715
+ checkFields(inst.fields, `${base}.fields`, false, inst);
343
716
  const captures = new Set(
344
717
  Object.values(inst.actions).flatMap((a) => [
345
718
  ...a.moves.flatMap((m) =>
@@ -374,9 +747,24 @@ export function validateUdl(value: unknown): UdlValidationResult {
374
747
  a &&
375
748
  b &&
376
749
  a.type === b.type &&
377
- (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)));
378
754
  const anchor = field(selection.anchor, input);
379
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
+ );
380
768
  if (
381
769
  selection.window &&
382
770
  target.fields.find((f) => f.name === selection.window!.field)
@@ -436,6 +824,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
436
824
  calculations: readonly UdlCalculation[],
437
825
  where: string,
438
826
  input: readonly UdlField[] = [],
827
+ act?: UdlAction,
439
828
  ) => {
440
829
  duplicate(
441
830
  calculations.map((c) => c.target),
@@ -449,7 +838,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
449
838
  const resultType =
450
839
  c.op === "at"
451
840
  ? (() => {
452
- const list = field(c.list, input);
841
+ const list = field(c.list, input, act);
453
842
  return list?.type === "list" ? list.item : "text";
454
843
  })()
455
844
  : (c.op === "aggregate" && c.measure === "count") ||
@@ -464,11 +853,11 @@ export function validateUdl(value: unknown): UdlValidationResult {
464
853
  const operands: UdlValue[] = [];
465
854
  const money = (v: UdlValue) => {
466
855
  operands.push(v);
467
- checkValue(v, arithmetic ? resultType : "money", where, input);
856
+ checkValue(v, arithmetic ? resultType : "money", where, input, act);
468
857
  };
469
858
  if (c.op === "at") {
470
- expect(c.list, "list", where, input);
471
- checkValue(c.position, "integer", where, input);
859
+ expect(c.list, "list", where, input, act);
860
+ checkValue(c.position, "integer", where, input, act);
472
861
  if ("literal" in c.position && Number(c.position.literal) < 1)
473
862
  add(where, "list positions start at one");
474
863
  operands.push({ field: c.list }, c.position);
@@ -487,15 +876,15 @@ export function validateUdl(value: unknown): UdlValidationResult {
487
876
  money(c.amount);
488
877
  const type =
489
878
  "field" in c.numerator
490
- ? field(c.numerator.field, input)?.type
879
+ ? field(c.numerator.field, input, act)?.type
491
880
  : typeof c.numerator.literal === "number"
492
881
  ? "integer"
493
882
  : "money";
494
883
  if (type !== "integer" && type !== "money" && type !== "percent")
495
884
  add(where, "ratio weights must share a numeric type");
496
885
  else {
497
- checkValue(c.numerator, type, where, input);
498
- checkValue(c.denominator, type, where, input);
886
+ checkValue(c.numerator, type, where, input, act);
887
+ checkValue(c.denominator, type, where, input, act);
499
888
  }
500
889
  if (
501
890
  "literal" in c.denominator &&
@@ -511,13 +900,13 @@ export function validateUdl(value: unknown): UdlValidationResult {
511
900
  }
512
901
  if (c.op === "rate") {
513
902
  money(c.base);
514
- checkValue(c.bps, "percent", where, input);
903
+ checkValue(c.bps, "percent", where, input, act);
515
904
  operands.push(c.bps);
516
905
  }
517
906
  if (c.op === "multiply" || c.op === "divide") {
518
907
  money(c.amount);
519
908
  const count = c.op === "multiply" ? c.units : c.divisor;
520
- checkValue(count, "integer", where, input);
909
+ checkValue(count, "integer", where, input, act);
521
910
  operands.push(count);
522
911
  if (
523
912
  "literal" in count &&
@@ -531,8 +920,8 @@ export function validateUdl(value: unknown): UdlValidationResult {
531
920
  );
532
921
  }
533
922
  if (c.op === "shift") {
534
- checkValue(c.date, "date", where, input);
535
- checkValue(c.milliseconds, "duration", where, input);
923
+ checkValue(c.date, "date", where, input, act);
924
+ checkValue(c.milliseconds, "duration", where, input, act);
536
925
  operands.push(c.date, c.milliseconds);
537
926
  }
538
927
  dependencies.set(
@@ -561,6 +950,8 @@ export function validateUdl(value: unknown): UdlValidationResult {
561
950
  };
562
951
  checkCalculations(inst.calculate, `${base}.calculate`);
563
952
  const states = new Set(inst.lifecycle.states);
953
+ if (states.has("preserve"))
954
+ add(base, "preserve is a reserved transition destination", "UDL3001");
564
955
  duplicate(inst.lifecycle.states, `${base}.lifecycle.states`);
565
956
  if (!states.has(inst.lifecycle.initial) || !inst.actions.create)
566
957
  add(base, "declare create and a declared initial state", "UDL3001");
@@ -575,14 +966,15 @@ export function validateUdl(value: unknown): UdlValidationResult {
575
966
  if (
576
967
  !inst.actions[action] ||
577
968
  action === "create" ||
578
- !states.has(edge.to) ||
969
+ (edge.to !== "preserve" && !states.has(edge.to)) ||
579
970
  edge.from.some((s) => !states.has(s))
580
971
  )
581
972
  add(base, `invalid transition ${action}`, "UDL3001");
582
973
  }
583
974
  for (let pass = 0; pass < states.size; pass++)
584
975
  for (const edge of Object.values(inst.lifecycle.transitions))
585
- if (edge.from.some((s) => reachable.has(s))) reachable.add(edge.to);
976
+ if (edge.to !== "preserve" && edge.from.some((s) => reachable.has(s)))
977
+ reachable.add(edge.to);
586
978
  for (const state of states)
587
979
  if (!reachable.has(state))
588
980
  add(base, `unreachable state ${state}`, "UDL3001");
@@ -590,13 +982,16 @@ export function validateUdl(value: unknown): UdlValidationResult {
590
982
  requirements: typeof inst.invariants,
591
983
  where: string,
592
984
  input: readonly UdlField[] = [],
985
+ act?: UdlAction,
593
986
  ) => {
594
987
  for (const req of requirements ?? []) {
595
988
  if (req.kind === "compare") {
596
989
  const left =
597
- "field" in req.left ? field(req.left.field, input) : undefined;
990
+ "field" in req.left ? field(req.left.field, input, act) : undefined;
598
991
  const right =
599
- "field" in req.right ? field(req.right.field, input) : undefined;
992
+ "field" in req.right
993
+ ? field(req.right.field, input, act)
994
+ : undefined;
600
995
  if (
601
996
  ("field" in req.left && !left) ||
602
997
  ("field" in req.right && !right)
@@ -608,11 +1003,12 @@ export function validateUdl(value: unknown): UdlValidationResult {
608
1003
  (left.type !== right.type ||
609
1004
  (left.type === "ref" &&
610
1005
  right.type === "ref" &&
611
- !overlap(left.target, right.target)))
1006
+ (left.targetKind !== right.targetKind ||
1007
+ !overlap(left.target, right.target))))
612
1008
  )
613
1009
  add(where, "comparison operands have different types");
614
1010
  if (left && "literal" in req.right) {
615
- checkValue(req.right, left.type, where, input);
1011
+ checkValue(req.right, left.type, where, input, act);
616
1012
  if (
617
1013
  left.type === "enum" &&
618
1014
  !left.values.includes(String(req.right.literal))
@@ -620,7 +1016,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
620
1016
  add(where, "comparison names an undeclared enum value");
621
1017
  }
622
1018
  if (right && "literal" in req.left) {
623
- checkValue(req.left, right.type, where, input);
1019
+ checkValue(req.left, right.type, where, input, act);
624
1020
  if (
625
1021
  right.type === "enum" &&
626
1022
  !right.values.includes(String(req.left.literal))
@@ -628,9 +1024,10 @@ export function validateUdl(value: unknown): UdlValidationResult {
628
1024
  add(where, "comparison names an undeclared enum value");
629
1025
  }
630
1026
  } else if (req.kind === "state") {
631
- const target = expect(req.reference, "ref", where, input);
1027
+ const target = expect(req.reference, "ref", where, input, act);
632
1028
  if (
633
1029
  target?.type === "ref" &&
1030
+ target.targetKind === "instrument" &&
634
1031
  req.states.some((s) =>
635
1032
  targetIds(target.target).some(
636
1033
  (id) => !byId.get(id)?.lifecycle.states.includes(s),
@@ -640,28 +1037,59 @@ export function validateUdl(value: unknown): UdlValidationResult {
640
1037
  add(where, "reference gate names an undeclared state");
641
1038
  } else if (req.kind === "unique") {
642
1039
  for (const p of req.fields)
643
- if (!field(p, input)) add(where, `unknown identity field ${p}`);
1040
+ if (!field(p, input, act))
1041
+ add(where, `unknown identity field ${p}`);
644
1042
  } else if (req.kind === "approval") {
645
- const target = expect(req.target, "ref", where, input);
646
- if (!document.parties[req.party])
647
- 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
+ );
648
1065
  if (
649
1066
  req.action &&
650
1067
  target?.type === "ref" &&
1068
+ target.targetKind === "instrument" &&
651
1069
  targetIds(target.target).some(
652
1070
  (id) => !byId.get(id)?.actions[req.action!],
653
1071
  )
654
1072
  )
655
1073
  add(where, `unknown approved action ${req.action}`);
656
1074
  } else if (req.kind === "evidence") {
657
- const subject = field(req.subject, input);
658
- 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
+ )
659
1087
  add(
660
1088
  where,
661
1089
  "evidence subject must be an account or text subject id",
662
1090
  );
663
1091
  } else if (req.kind === "hours") {
664
- expect(req.at, "date", where, input);
1092
+ expect(req.at, "date", where, input, act);
665
1093
  try {
666
1094
  new Intl.DateTimeFormat("en", { timeZone: req.timezone });
667
1095
  } catch {
@@ -685,6 +1113,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
685
1113
  req.measure === "count" ? "integer" : "money",
686
1114
  where,
687
1115
  input,
1116
+ act,
688
1117
  );
689
1118
  }
690
1119
  }
@@ -695,7 +1124,91 @@ export function validateUdl(value: unknown): UdlValidationResult {
695
1124
  const where = `${base}.actions.${actionName}`;
696
1125
  if (actionName !== "create" && !inst.lifecycle.transitions[actionName])
697
1126
  add(where, "action needs a lifecycle transition", "UDL3001");
698
- 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);
1194
+ if (action.reminder) {
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);
1204
+ if (action.reminder.startHour >= action.reminder.endHour)
1205
+ add(where, "reminder sending window is empty");
1206
+ try {
1207
+ new Intl.DateTimeFormat("en", { timeZone: action.reminder.timezone });
1208
+ } catch {
1209
+ add(where, "reminder requires an IANA timezone");
1210
+ }
1211
+ }
699
1212
  for (const input of action.input) {
700
1213
  if (input.type === "account")
701
1214
  add(where, "account bindings cannot be caller inputs");
@@ -723,29 +1236,64 @@ export function validateUdl(value: unknown): UdlValidationResult {
723
1236
  action.calculate ?? [],
724
1237
  `${where}.calculate`,
725
1238
  action.input,
1239
+ action,
1240
+ );
1241
+ checkRequirements(
1242
+ action.requires,
1243
+ `${where}.requires`,
1244
+ action.input,
1245
+ action,
726
1246
  );
727
- checkRequirements(action.requires, `${where}.requires`, action.input);
728
1247
  if (
729
1248
  typeof action.actor === "object" &&
730
1249
  "party" in action.actor &&
731
- !document.parties[action.actor.party]
1250
+ !hasParty(document, inst, action.actor.party)
732
1251
  )
733
1252
  add(where, "actor names an undeclared party");
734
1253
  if (
735
1254
  typeof action.actor === "object" &&
736
1255
  "parent" in action.actor &&
737
- !byId.has(action.actor.parent)
1256
+ targetIds(action.actor.parent).some((id) => !byId.has(id))
738
1257
  )
739
1258
  add(where, "actor names an undeclared parent");
740
1259
  if (action.actor === "clock" && !action.due)
741
1260
  add(where, "clock action needs a due instant");
742
1261
  for (const clock of [action.due, action.deadline])
743
- if (clock) expect(clock.at, "date", where, action.input);
1262
+ if (clock) {
1263
+ expect(clock.at, "date", where, action.input, action);
1264
+ if (clock.localDay) {
1265
+ try {
1266
+ new Intl.DateTimeFormat("en", {
1267
+ timeZone: clock.localDay.timezone,
1268
+ }).format(0);
1269
+ } catch {
1270
+ add(where, "clock timezone must be an IANA timezone");
1271
+ }
1272
+ }
1273
+ }
744
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
+ );
745
1287
  if ("amount" in move) {
746
- checkValue(move.amount, "money", where, action.input);
747
- const from = expect(move.from, "account", where, action.input);
748
- 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);
749
1297
  if (
750
1298
  from?.type === "account" &&
751
1299
  to?.type === "account" &&
@@ -761,7 +1309,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
761
1309
  (from.key ?? "balance") === (to.key ?? "balance");
762
1310
  if (move.from === move.to || samePartyAccount)
763
1311
  add(where, "a transfer needs distinct accounts", "UDL4001");
764
- } else expect(move.transfer, "text", where, action.input);
1312
+ } else expect(move.transfer, "text", where, action.input, action);
765
1313
  }
766
1314
  duplicate(
767
1315
  action.moves.flatMap((m) =>
@@ -784,12 +1332,13 @@ export function validateUdl(value: unknown): UdlValidationResult {
784
1332
  inst.calculate.some((c) => c.target === key)
785
1333
  )
786
1334
  add(where, `cannot write immutable field ${key}`);
787
- else checkValue(v, target.type, where, action.input);
1335
+ else checkValue(v, target.type, where, action.input, action);
788
1336
  }
789
1337
  const checkArguments = (
790
1338
  targetId: string | undefined,
791
1339
  actionName: string,
792
1340
  values: Record<string, UdlValue>,
1341
+ binding?: string,
793
1342
  ) => {
794
1343
  const target = targetId
795
1344
  ? byId.get(targetId)?.actions[actionName]
@@ -808,11 +1357,17 @@ export function validateUdl(value: unknown): UdlValidationResult {
808
1357
  add(where, `unknown target input ${name}`);
809
1358
  continue;
810
1359
  }
811
- checkValue(value, declared.type, where, action.input);
1360
+ if (binding && "field" in value && value.field === binding) {
1361
+ if (declared.type !== "integer")
1362
+ add(where, "range binding requires an integer target input");
1363
+ continue;
1364
+ }
1365
+ checkValue(value, declared.type, where, action.input, action);
812
1366
  if (declared.type === "ref" && "field" in value) {
813
- const supplied = field(value.field, action.input);
1367
+ const supplied = field(value.field, action.input, action);
814
1368
  if (
815
1369
  supplied?.type !== "ref" ||
1370
+ supplied.targetKind !== declared.targetKind ||
816
1371
  !targetIds(supplied.target).every((id) =>
817
1372
  targetIds(declared.target).includes(id),
818
1373
  )
@@ -822,38 +1377,76 @@ export function validateUdl(value: unknown): UdlValidationResult {
822
1377
  }
823
1378
  };
824
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
+ );
825
1393
  const reference = expect(
826
1394
  action.approval.target,
827
1395
  "ref",
828
1396
  where,
829
1397
  action.input,
1398
+ action,
830
1399
  );
831
1400
  if (
832
1401
  reference?.type === "ref" &&
1402
+ reference.targetKind === "instrument" &&
833
1403
  targetIds(reference.target).some(
834
1404
  (id) => !byId.get(id)?.actions[action.approval!.action],
835
1405
  )
836
1406
  )
837
1407
  add(where, "approval target action does not exist");
838
- if (reference?.type === "ref")
1408
+ if (reference?.type === "ref" && reference.targetKind === "instrument")
839
1409
  for (const id of targetIds(reference.target))
840
1410
  checkArguments(id, action.approval.action, action.approval.input);
841
- expect(action.approval.expires, "date", where, action.input);
842
- if (!document.parties[action.approval.party])
843
- 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
+ );
844
1420
  }
845
1421
  const targets: { targets: string[]; count: number }[] = [];
846
1422
  for (const call of action.invoke ?? []) {
1423
+ if (call.guard)
1424
+ checkRequirements([call.guard], where, action.input, action);
1425
+ const range = "instrument" in call ? call.range : undefined;
1426
+ if (range) {
1427
+ checkValue(range.count, "integer", where, action.input, action);
1428
+ if (["self", "input", "party", "subject"].includes(range.bind))
1429
+ add(where, "range binding cannot shadow a reserved scope");
1430
+ if (
1431
+ "literal" in range.count &&
1432
+ (typeof range.count.literal !== "number" ||
1433
+ range.count.literal < 1 ||
1434
+ range.count.literal > range.maximum)
1435
+ )
1436
+ add(where, "range count must be between one and its maximum");
1437
+ }
847
1438
  if ("selection" in call)
848
1439
  checkSelection(call.selection, where, action.input);
849
1440
  const target =
850
- "reference" in call ? field(call.reference, action.input) : undefined;
1441
+ "reference" in call
1442
+ ? field(call.reference, action.input, action)
1443
+ : undefined;
851
1444
  const selected =
852
1445
  "instrument" in call
853
1446
  ? call.instrument
854
1447
  : "selection" in call
855
1448
  ? call.selection.instrument
856
- : target?.type === "ref"
1449
+ : target?.type === "ref" && target.targetKind === "instrument"
857
1450
  ? target.target
858
1451
  : undefined;
859
1452
  const ids = Array.isArray(selected)
@@ -863,18 +1456,46 @@ export function validateUdl(value: unknown): UdlValidationResult {
863
1456
  : [];
864
1457
  if (!ids.length) add(where, "invocation needs a declared target");
865
1458
  for (const targetId of ids) {
866
- checkArguments(targetId, call.action, call.input);
1459
+ checkArguments(targetId, call.action, call.input, range?.bind);
867
1460
  if (call.action === "create" && !("instrument" in call))
868
1461
  add(
869
1462
  where,
870
1463
  "invocation references an existing instance and cannot create it again",
871
1464
  );
872
- if (!byId.get(targetId)?.actions[call.action])
1465
+ const targetInst = byId.get(targetId);
1466
+ if (!targetInst?.actions[call.action])
873
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
+ }
874
1494
  }
875
1495
  targets.push({
876
1496
  targets: ids.map((id) => `${id}.${call.action}`),
877
- count: "selection" in call ? call.selection.limit : 1,
1497
+ count:
1498
+ "selection" in call ? call.selection.limit : (range?.maximum ?? 1),
878
1499
  });
879
1500
  }
880
1501
  calls.set(`${inst.id}.${actionName}`, targets);
@@ -891,7 +1512,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
891
1512
  const visit = (key: string): number => {
892
1513
  if (active.has(key)) {
893
1514
  add("$.instruments", `invocation cycle at ${key}`, "UDL2010");
894
- return 4097;
1515
+ return MAX_ACTION_EXPANSION + 1;
895
1516
  }
896
1517
  const known = depths.get(key);
897
1518
  if (known !== undefined) return known;
@@ -904,8 +1525,16 @@ export function validateUdl(value: unknown): UdlValidationResult {
904
1525
  );
905
1526
  active.delete(key);
906
1527
  depths.set(key, size);
907
- if (size > 4096)
908
- add("$.instruments", `invocation ${key} exceeds 4096 actions`, "UDL2010");
1528
+ const [id, action] = key.split(".");
1529
+ const limit =
1530
+ document.instruments.find((item) => item.id === id)?.actions[action!]
1531
+ ?.expansionLimit ?? MAX_ACTION_EXPANSION;
1532
+ if (size > limit)
1533
+ add(
1534
+ "$.instruments",
1535
+ `invocation ${key} exceeds ${limit} actions`,
1536
+ "UDL2010",
1537
+ );
909
1538
  return size;
910
1539
  };
911
1540
  for (const key of calls.keys()) visit(key);