@hyperscale0/udl 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as z from "zod";
2
+ import { reportDefinitionSchema } from "./reporting.js";
2
3
 
3
4
  export const UDL_FORMAT_VERSION = 3 as const;
5
+ /** Counts the root and every nested invocation, including selected and ranged children. */
6
+ export const MAX_ACTION_EXPANSION = 4096;
4
7
  const name = z
5
8
  .string()
6
9
  .regex(/^[a-z][a-zA-Z0-9_]*$/)
@@ -62,7 +65,13 @@ export const udlFieldSchema = z.discriminatedUnion("type", [
62
65
  ...fieldBase,
63
66
  type: z.literal("text"),
64
67
  value: text.optional(),
68
+ minLength: integer.positive().max(2048).optional(),
65
69
  maxLength: integer.positive().max(2048).optional(),
70
+ // One anchored ASCII character class and a fixed repetition. No executable patterns.
71
+ pattern: z
72
+ .string()
73
+ .regex(/^\^\[[A-Za-z0-9-]+\]\{[1-9][0-9]{0,3}\}\$$/)
74
+ .optional(),
66
75
  }),
67
76
  z.strictObject({
68
77
  ...fieldBase,
@@ -183,6 +192,13 @@ export const udlCalculationSchema = z.discriminatedUnion("op", [
183
192
  }),
184
193
  ]);
185
194
 
195
+ const comparisonRequirement = z.strictObject({
196
+ kind: z.literal("compare"),
197
+ left: value,
198
+ operator: z.enum(["==", "!=", "<", "<=", ">", ">="]),
199
+ right: value,
200
+ });
201
+
186
202
  export const udlRequirementSchema = z.discriminatedUnion("kind", [
187
203
  z.strictObject({
188
204
  kind: z.literal("hours"),
@@ -191,12 +207,7 @@ export const udlRequirementSchema = z.discriminatedUnion("kind", [
191
207
  end: integer.min(0).max(23),
192
208
  timezone: text,
193
209
  }),
194
- z.strictObject({
195
- kind: z.literal("compare"),
196
- left: value,
197
- operator: z.enum(["==", "!=", "<", "<=", ">", ">="]),
198
- right: value,
199
- }),
210
+ comparisonRequirement,
200
211
  z.strictObject({
201
212
  kind: z.literal("state"),
202
213
  reference: path,
@@ -267,16 +278,46 @@ export const udlMoveSchema = z.discriminatedUnion("operation", [
267
278
  transfer: path,
268
279
  }),
269
280
  ]);
270
- const clock = z.strictObject({ at: path, offset: integer.optional() });
281
+ const clock = z.strictObject({
282
+ at: path,
283
+ offset: integer.optional(),
284
+ localDay: z
285
+ .strictObject({
286
+ days: integer.min(0).max(366),
287
+ direction: z.enum(["before", "after"]),
288
+ hour: integer.min(0).max(23),
289
+ timezone: text,
290
+ })
291
+ .optional(),
292
+ });
271
293
  export const udlActionSchema = z.strictObject({
272
294
  summary: text,
273
295
  publicAction: name.optional(),
296
+ expansionLimit: z.literal(8192).optional(),
297
+ humanApproval: z.literal("distinct_member").optional(),
298
+ reminder: z
299
+ .strictObject({
300
+ installment: path,
301
+ recipient: path,
302
+ dueAt: path,
303
+ channel: z.literal("email"),
304
+ template: z.literal("payment_reminder"),
305
+ beforeDays: integer.min(0).max(366),
306
+ overdueDays: integer.min(1).max(366),
307
+ maxPerDay: integer.min(1).max(10),
308
+ startHour: integer.min(0).max(23),
309
+ endHour: integer.min(1).max(24),
310
+ timezone: text,
311
+ })
312
+ .optional(),
274
313
  event: text,
275
314
  actor: z.union([
276
315
  z.literal("caller"),
277
316
  z.literal("clock"),
278
317
  z.strictObject({ party: name }),
279
- z.strictObject({ parent: instrumentId }),
318
+ z.strictObject({
319
+ parent: z.union([instrumentId, z.array(instrumentId).min(1).max(16)]),
320
+ }),
280
321
  ]),
281
322
  input: z.array(udlFieldSchema).max(128),
282
323
  requires: z.array(udlRequirementSchema).max(128),
@@ -292,16 +333,26 @@ export const udlActionSchema = z.strictObject({
292
333
  instrument: instrumentId,
293
334
  action: z.literal("create"),
294
335
  input: z.record(name, value),
336
+ guard: comparisonRequirement.optional(),
337
+ range: z
338
+ .strictObject({
339
+ count: value,
340
+ maximum: integer.min(1).max(366),
341
+ bind: name,
342
+ })
343
+ .optional(),
295
344
  }),
296
345
  z.strictObject({
297
346
  reference: path,
298
347
  action: name,
299
348
  input: z.record(name, value),
349
+ guard: comparisonRequirement.optional(),
300
350
  }),
301
351
  z.strictObject({
302
352
  selection,
303
353
  action: name,
304
354
  input: z.record(name, value),
355
+ guard: comparisonRequirement.optional(),
305
356
  }),
306
357
  ]),
307
358
  )
@@ -327,6 +378,8 @@ export const udlLifecycleSchema = z.strictObject({
327
378
  ),
328
379
  });
329
380
  export const udlInstrumentSchema = z.strictObject({
381
+ reports: z.array(reportDefinitionSchema).max(16).optional(),
382
+ revisioned: z.literal(true).optional(),
330
383
  id: instrumentId,
331
384
  title: text,
332
385
  summary: text,
package/src/validation.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  udlDocumentSchema,
3
+ MAX_ACTION_EXPANSION,
3
4
  type UdlDocument,
4
5
  type UdlField,
5
6
  type UdlInstrument,
@@ -8,6 +9,7 @@ import {
8
9
  type UdlSelection,
9
10
  } from "./schema.js";
10
11
  import { issue, type UdlIssue } from "./diagnostics.js";
12
+ import { validateReportDefinition } from "./reporting-validation.js";
11
13
  import { analyzeInstrumentFinance } from "./finance.js";
12
14
 
13
15
  export type UdlValidationResult =
@@ -149,6 +151,7 @@ function resolvePath(
149
151
  if (key === "id") return { name: key, type: "ref", target: instrument.id };
150
152
  if (key === "now" || key === "createdAt")
151
153
  return { name: key, type: "date" };
154
+ if (key === "productRevision") return { name: key, type: "text" };
152
155
  if (key === "status")
153
156
  return { name: key, type: "enum", values: instrument.lifecycle.states };
154
157
  }
@@ -239,6 +242,23 @@ export function validateUdl(value: unknown): UdlValidationResult {
239
242
  document.instruments.map((i) => i.id),
240
243
  "$.instruments",
241
244
  );
245
+ const reportIds = new Set<string>();
246
+ for (const [index, instrument] of document.instruments.entries()) {
247
+ for (const report of instrument.reports ?? []) {
248
+ const path = `$.instruments[${index}].reports`;
249
+ if (reportIds.has(report.identity.id))
250
+ add(path, `duplicate report ${report.identity.id}`);
251
+ reportIds.add(report.identity.id);
252
+ try {
253
+ validateReportDefinition(report, document);
254
+ } catch (error) {
255
+ add(
256
+ path,
257
+ error instanceof Error ? error.message : "Invalid report definition",
258
+ );
259
+ }
260
+ }
261
+ }
242
262
  const byId = new Map(document.instruments.map((i) => [i.id, i]));
243
263
  const calls = new Map<string, { targets: string[]; count: number }[]>();
244
264
  for (const [index, inst] of document.instruments.entries()) {
@@ -294,8 +314,23 @@ export function validateUdl(value: unknown): UdlValidationResult {
294
314
  where,
295
315
  "contra accounts require the claim book; external accounts require a cash party owner",
296
316
  );
297
- if (["id", "status", "createdAt", "now"].includes(f.name))
317
+ if (
318
+ ["id", "status", "createdAt", "now", "productRevision"].includes(
319
+ f.name,
320
+ )
321
+ )
298
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
+ }
299
334
  if (f.type === "ref") {
300
335
  duplicate(targetIds(f.target), where);
301
336
  for (const id of targetIds(f.target))
@@ -561,6 +596,8 @@ export function validateUdl(value: unknown): UdlValidationResult {
561
596
  };
562
597
  checkCalculations(inst.calculate, `${base}.calculate`);
563
598
  const states = new Set(inst.lifecycle.states);
599
+ if (states.has("preserve"))
600
+ add(base, "preserve is a reserved transition destination", "UDL3001");
564
601
  duplicate(inst.lifecycle.states, `${base}.lifecycle.states`);
565
602
  if (!states.has(inst.lifecycle.initial) || !inst.actions.create)
566
603
  add(base, "declare create and a declared initial state", "UDL3001");
@@ -575,14 +612,15 @@ export function validateUdl(value: unknown): UdlValidationResult {
575
612
  if (
576
613
  !inst.actions[action] ||
577
614
  action === "create" ||
578
- !states.has(edge.to) ||
615
+ (edge.to !== "preserve" && !states.has(edge.to)) ||
579
616
  edge.from.some((s) => !states.has(s))
580
617
  )
581
618
  add(base, `invalid transition ${action}`, "UDL3001");
582
619
  }
583
620
  for (let pass = 0; pass < states.size; pass++)
584
621
  for (const edge of Object.values(inst.lifecycle.transitions))
585
- if (edge.from.some((s) => reachable.has(s))) reachable.add(edge.to);
622
+ if (edge.to !== "preserve" && edge.from.some((s) => reachable.has(s)))
623
+ reachable.add(edge.to);
586
624
  for (const state of states)
587
625
  if (!reachable.has(state))
588
626
  add(base, `unreachable state ${state}`, "UDL3001");
@@ -696,6 +734,18 @@ export function validateUdl(value: unknown): UdlValidationResult {
696
734
  if (actionName !== "create" && !inst.lifecycle.transitions[actionName])
697
735
  add(where, "action needs a lifecycle transition", "UDL3001");
698
736
  checkFields(action.input, `${where}.input`);
737
+ 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);
741
+ if (action.reminder.startHour >= action.reminder.endHour)
742
+ add(where, "reminder sending window is empty");
743
+ try {
744
+ new Intl.DateTimeFormat("en", { timeZone: action.reminder.timezone });
745
+ } catch {
746
+ add(where, "reminder requires an IANA timezone");
747
+ }
748
+ }
699
749
  for (const input of action.input) {
700
750
  if (input.type === "account")
701
751
  add(where, "account bindings cannot be caller inputs");
@@ -734,13 +784,24 @@ export function validateUdl(value: unknown): UdlValidationResult {
734
784
  if (
735
785
  typeof action.actor === "object" &&
736
786
  "parent" in action.actor &&
737
- !byId.has(action.actor.parent)
787
+ targetIds(action.actor.parent).some((id) => !byId.has(id))
738
788
  )
739
789
  add(where, "actor names an undeclared parent");
740
790
  if (action.actor === "clock" && !action.due)
741
791
  add(where, "clock action needs a due instant");
742
792
  for (const clock of [action.due, action.deadline])
743
- if (clock) expect(clock.at, "date", where, action.input);
793
+ if (clock) {
794
+ expect(clock.at, "date", where, action.input);
795
+ if (clock.localDay) {
796
+ try {
797
+ new Intl.DateTimeFormat("en", {
798
+ timeZone: clock.localDay.timezone,
799
+ }).format(0);
800
+ } catch {
801
+ add(where, "clock timezone must be an IANA timezone");
802
+ }
803
+ }
804
+ }
744
805
  for (const move of action.moves) {
745
806
  if ("amount" in move) {
746
807
  checkValue(move.amount, "money", where, action.input);
@@ -790,6 +851,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
790
851
  targetId: string | undefined,
791
852
  actionName: string,
792
853
  values: Record<string, UdlValue>,
854
+ binding?: string,
793
855
  ) => {
794
856
  const target = targetId
795
857
  ? byId.get(targetId)?.actions[actionName]
@@ -808,6 +870,11 @@ export function validateUdl(value: unknown): UdlValidationResult {
808
870
  add(where, `unknown target input ${name}`);
809
871
  continue;
810
872
  }
873
+ if (binding && "field" in value && value.field === binding) {
874
+ if (declared.type !== "integer")
875
+ add(where, "range binding requires an integer target input");
876
+ continue;
877
+ }
811
878
  checkValue(value, declared.type, where, action.input);
812
879
  if (declared.type === "ref" && "field" in value) {
813
880
  const supplied = field(value.field, action.input);
@@ -844,6 +911,20 @@ export function validateUdl(value: unknown): UdlValidationResult {
844
911
  }
845
912
  const targets: { targets: string[]; count: number }[] = [];
846
913
  for (const call of action.invoke ?? []) {
914
+ if (call.guard) checkRequirements([call.guard], where, action.input);
915
+ const range = "instrument" in call ? call.range : undefined;
916
+ if (range) {
917
+ checkValue(range.count, "integer", where, action.input);
918
+ if (["self", "input", "party"].includes(range.bind))
919
+ add(where, "range binding cannot shadow a reserved scope");
920
+ if (
921
+ "literal" in range.count &&
922
+ (typeof range.count.literal !== "number" ||
923
+ range.count.literal < 1 ||
924
+ range.count.literal > range.maximum)
925
+ )
926
+ add(where, "range count must be between one and its maximum");
927
+ }
847
928
  if ("selection" in call)
848
929
  checkSelection(call.selection, where, action.input);
849
930
  const target =
@@ -863,7 +944,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
863
944
  : [];
864
945
  if (!ids.length) add(where, "invocation needs a declared target");
865
946
  for (const targetId of ids) {
866
- checkArguments(targetId, call.action, call.input);
947
+ checkArguments(targetId, call.action, call.input, range?.bind);
867
948
  if (call.action === "create" && !("instrument" in call))
868
949
  add(
869
950
  where,
@@ -874,7 +955,8 @@ export function validateUdl(value: unknown): UdlValidationResult {
874
955
  }
875
956
  targets.push({
876
957
  targets: ids.map((id) => `${id}.${call.action}`),
877
- count: "selection" in call ? call.selection.limit : 1,
958
+ count:
959
+ "selection" in call ? call.selection.limit : (range?.maximum ?? 1),
878
960
  });
879
961
  }
880
962
  calls.set(`${inst.id}.${actionName}`, targets);
@@ -891,7 +973,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
891
973
  const visit = (key: string): number => {
892
974
  if (active.has(key)) {
893
975
  add("$.instruments", `invocation cycle at ${key}`, "UDL2010");
894
- return 4097;
976
+ return MAX_ACTION_EXPANSION + 1;
895
977
  }
896
978
  const known = depths.get(key);
897
979
  if (known !== undefined) return known;
@@ -904,8 +986,16 @@ export function validateUdl(value: unknown): UdlValidationResult {
904
986
  );
905
987
  active.delete(key);
906
988
  depths.set(key, size);
907
- if (size > 4096)
908
- add("$.instruments", `invocation ${key} exceeds 4096 actions`, "UDL2010");
989
+ const [id, action] = key.split(".");
990
+ const limit =
991
+ document.instruments.find((item) => item.id === id)?.actions[action!]
992
+ ?.expansionLimit ?? MAX_ACTION_EXPANSION;
993
+ if (size > limit)
994
+ add(
995
+ "$.instruments",
996
+ `invocation ${key} exceeds ${limit} actions`,
997
+ "UDL2010",
998
+ );
909
999
  return size;
910
1000
  };
911
1001
  for (const key of calls.keys()) visit(key);