@hyperscale0/udl 3.2.0 → 4.1.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 (45) 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 +11 -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/index.d.ts +2 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/instrument-references.d.ts.map +1 -1
  15. package/dist/instrument-references.js +21 -1
  16. package/dist/instrument-references.js.map +1 -1
  17. package/dist/object-contract.d.ts +144 -0
  18. package/dist/object-contract.d.ts.map +1 -0
  19. package/dist/object-contract.js +279 -0
  20. package/dist/object-contract.js.map +1 -0
  21. package/dist/reporting.d.ts +0 -1
  22. package/dist/reporting.d.ts.map +1 -1
  23. package/dist/reporting.js +0 -1
  24. package/dist/reporting.js.map +1 -1
  25. package/dist/schema.d.ts +1815 -126
  26. package/dist/schema.d.ts.map +1 -1
  27. package/dist/schema.js +100 -33
  28. package/dist/schema.js.map +1 -1
  29. package/dist/validation.d.ts +5 -2
  30. package/dist/validation.d.ts.map +1 -1
  31. package/dist/validation.js +403 -146
  32. package/dist/validation.js.map +1 -1
  33. package/docs/README.md +66 -17
  34. package/package.json +4 -4
  35. package/spec/README.md +66 -17
  36. package/spec/{darb.udl.json → cars.udl.json} +7 -7
  37. package/spec/udl.schema.json +2876 -6516
  38. package/src/diagnostics.ts +27 -1
  39. package/src/evolution.ts +8 -0
  40. package/src/index.ts +3 -0
  41. package/src/instrument-references.ts +25 -1
  42. package/src/object-contract.ts +464 -0
  43. package/src/reporting.ts +0 -1
  44. package/src/schema.ts +155 -33
  45. package/src/validation.ts +635 -174
@@ -1,4 +1,14 @@
1
1
  export type UdlIssueCode =
2
+ | "subject_field_conflict"
3
+ | "subject_field_unknown"
4
+ | "subject_requirement_missing"
5
+ | "subject_adapter_unbound"
6
+ | "subject_party_unbound"
7
+ | "party_name_reserved"
8
+ | "party_kind_mismatch"
9
+ | "staff_role_unknown"
10
+ | "product_party_unbound"
11
+ | "product_party_invalid"
2
12
  | "UDL1001"
3
13
  | "UDL1002"
4
14
  | "UDL1003"
@@ -19,9 +29,25 @@ export interface UdlIssue {
19
29
  category: string;
20
30
  }
21
31
  const fixes: Record<UdlIssueCode, string> = {
32
+ subject_field_conflict:
33
+ "Rename the field or use matching types and constraints.",
34
+ subject_field_unknown: "Name a declared subject requirement or object field.",
35
+ subject_requirement_missing: "Supply the required action field.",
36
+ subject_adapter_unbound: "Bind the required adapter declaration.",
37
+ subject_party_unbound:
38
+ "Bind the parameter to a subject role or declared party.",
39
+ party_name_reserved:
40
+ "Choose a party name other than owner, actor or operator.",
41
+ party_kind_mismatch: "Use a business or subject role for money.",
42
+ staff_role_unknown: "Use a registered staff permission role.",
43
+ product_party_unbound:
44
+ "Supply the consumed business in the Build party bindings.",
45
+ product_party_invalid:
46
+ "Bind a business participant in the same tenant and environment.",
47
+
22
48
  UDL1001: "Encode the source as UTF-8.",
23
49
  UDL1002: "Repair JSON syntax.",
24
- UDL1003: "Use the UDL 3 typed grammar.",
50
+ UDL1003: "Use the UDL 4 typed grammar.",
25
51
  UDL1004: "Reduce the declared structure or expansion.",
26
52
  UDL2001: "Give each declaration a distinct name.",
27
53
  UDL2002: "Repair the named type or declaration.",
package/src/evolution.ts CHANGED
@@ -28,6 +28,14 @@ export function diffValidatedUdlEvolution(
28
28
  for (const [name, party] of Object.entries(live.parties))
29
29
  if (stable(party) !== stable(next.parties[name]))
30
30
  refuse(`$.parties.${name}`, "a live party cannot change or disappear");
31
+ for (const object of live.objects) {
32
+ const updated = next.objects.find((o) => o.id === object.id);
33
+ if (!updated || stable(object) !== stable(updated))
34
+ refuse(
35
+ `$.objects.${object.id}`,
36
+ "a live object kind cannot change or disappear; recreate development estates",
37
+ );
38
+ }
31
39
  for (const instrument of live.instruments) {
32
40
  const updated = next.instruments.find((i) => i.id === instrument.id);
33
41
  if (!updated || stable(instrument) !== stable(updated))
package/src/index.ts CHANGED
@@ -3,8 +3,10 @@ export { parseUdl, canonicalizeUdl } from "./parser.js";
3
3
  export { serializeUdl, canonicalDigest } from "./canonical.js";
4
4
  export {
5
5
  validateUdl,
6
+ RESERVED_OBJECT_NAMES,
6
7
  assertValidUdl,
7
8
  resolveField,
9
+ resolveSubjectRequirement,
8
10
  UdlError,
9
11
  type UdlValidationResult,
10
12
  } from "./validation.js";
@@ -28,3 +30,4 @@ export {
28
30
  validateReportDefinition,
29
31
  reportExpressionTypes,
30
32
  } from "./reporting-validation.js";
33
+ export * from "./object-contract.js";
@@ -52,6 +52,29 @@ export function mapUdlInstrumentReferences(
52
52
  );
53
53
  } else if (object(value)) {
54
54
  const properties = object(schema.properties) ? schema.properties : {};
55
+ const discriminator = object(properties.type)
56
+ ? properties.type.const
57
+ : undefined;
58
+ if (
59
+ (discriminator === "ref" || discriminator === "list") &&
60
+ value.type === discriminator &&
61
+ value.targetKind === "instrument"
62
+ ) {
63
+ if (typeof value.target === "string")
64
+ locations.set(JSON.stringify([...path, "target"]), {
65
+ parent: value,
66
+ key: "target",
67
+ kind: "instrument",
68
+ });
69
+ else if (Array.isArray(value.target))
70
+ value.target.forEach((_, index) =>
71
+ locations.set(JSON.stringify([...path, "target", index]), {
72
+ parent: value.target as unknown[],
73
+ key: index,
74
+ kind: "instrument",
75
+ }),
76
+ );
77
+ }
55
78
  for (const [name, entry] of Object.entries(value))
56
79
  visit(
57
80
  properties[name] ?? schema.additionalProperties,
@@ -81,12 +104,13 @@ export function referencedUdlInstrumentIds(
81
104
  const found = new Set<string>();
82
105
  mapUdlInstrumentReferences(
83
106
  {
84
- udl: 3,
107
+ udl: 4,
85
108
  version: 1,
86
109
  product: "references",
87
110
  title: "References",
88
111
  currency: "SAR",
89
112
  parties: {},
113
+ objects: [],
90
114
  instruments: [instrument],
91
115
  },
92
116
  (id) => {
@@ -0,0 +1,464 @@
1
+ import * as z from "zod";
2
+ import {
3
+ udlExternalIdSchema,
4
+ udlObjectIdSchema,
5
+ type UdlDocument,
6
+ type UdlField,
7
+ type UdlObjectField,
8
+ type UdlObjectKind,
9
+ } from "./schema.js";
10
+
11
+ export const requestAttributionSchema = z.union([
12
+ z.strictObject({
13
+ kind: z.literal("human"),
14
+ principalId: z.string().min(1),
15
+ humanId: z.string().min(1),
16
+ source: z.enum(["staff_session", "customer_session"]),
17
+ }),
18
+ z.strictObject({
19
+ kind: z.literal("machine"),
20
+ principalId: z.string().min(1),
21
+ source: z.enum(["api_key", "system"]),
22
+ }),
23
+ z.strictObject({
24
+ kind: z.literal("machine"),
25
+ principalId: z.string().min(1),
26
+ humanId: z.string().min(1),
27
+ source: z.literal("api_key_delegate"),
28
+ }),
29
+ ]);
30
+ export type RequestAttribution = z.infer<typeof requestAttributionSchema>;
31
+
32
+ export type JsonSchemaDocument = z.core.JSONSchema.BaseSchema;
33
+
34
+ export interface ObjectKindDiscovery {
35
+ creation: true;
36
+ kind: string;
37
+ title: string;
38
+ fields: readonly UdlObjectField[];
39
+ authoredFields: readonly string[];
40
+ columns: readonly string[];
41
+ createSchema: JsonSchemaDocument;
42
+ actions: readonly ObjectActionDiscovery[];
43
+ }
44
+ export type ObjectActionTarget =
45
+ | { kind: "attachment"; attachment: string }
46
+ | { kind: "instance"; attachment: string; instanceId: string };
47
+ export interface ObjectActionContext {
48
+ productBuildId: string;
49
+ digest: string;
50
+ target: ObjectActionTarget;
51
+ }
52
+ export interface ObjectActionDiscovery extends ObjectActionContext {
53
+ name: string;
54
+ title: string;
55
+ instrument: string;
56
+ action: string;
57
+ requirements: readonly UdlObjectField[];
58
+ creationRequirements?: readonly UdlObjectField[];
59
+ creationOnlyNames?: readonly string[];
60
+ inputSchema: JsonSchemaDocument;
61
+ fieldsSchema: JsonSchemaDocument;
62
+ availability:
63
+ | { status: "available" }
64
+ | { status: "unavailable"; code: string; message: string };
65
+ }
66
+ export type EvidenceState =
67
+ | "missing"
68
+ | "pending"
69
+ | "satisfied"
70
+ | "refused"
71
+ | "stale";
72
+
73
+ export interface ObjectEvidenceSummary {
74
+ requirementId: string;
75
+ subjectId: string;
76
+ protectedInputDigest: string;
77
+ adapter: string;
78
+ declarationDigest: string;
79
+ productBuildId: string;
80
+ state: EvidenceState;
81
+ reference?: string;
82
+ observedAt?: string;
83
+ reasonCode?: string;
84
+ }
85
+
86
+ export interface ObjectActionState extends ObjectActionDiscovery {
87
+ requiredNow: readonly string[];
88
+ evidence?: readonly ObjectEvidenceSummary[];
89
+ }
90
+ /** GET /v1/products/{productId}/objects. Core supplies the frozen Build identity. */
91
+ export type RetainedObjectKind = Pick<
92
+ ObjectKindDiscovery,
93
+ "kind" | "title" | "fields" | "authoredFields" | "columns"
94
+ > & {
95
+ productBuildId: string;
96
+ digest: string;
97
+ creation: false;
98
+ };
99
+ export interface ObjectDiscovery {
100
+ retainedKinds: readonly RetainedObjectKind[];
101
+ productBuildId: string;
102
+ digest: string;
103
+ kinds: readonly ObjectKindDiscovery[];
104
+ }
105
+ export interface ObjectActionsResponse {
106
+ objectId: string;
107
+ revision: number;
108
+ actions: readonly ObjectActionState[];
109
+ evidence: readonly ObjectEvidenceSummary[];
110
+ }
111
+ export interface ObjectInstance {
112
+ objectId: string;
113
+ kind: string;
114
+ revision: number;
115
+ externalId?: string;
116
+ fields: Record<string, unknown>;
117
+ productBuildId: string;
118
+ }
119
+ export interface ObjectListResponse {
120
+ items: ObjectInstance[];
121
+ nextCursor?: string;
122
+ }
123
+ export interface ObjectActionResponse {
124
+ object: ObjectInstance;
125
+ outcome: { instrument: string; instanceId: string; state: string } | null;
126
+ evidence: readonly ObjectEvidenceSummary[];
127
+ }
128
+
129
+ const amount = z.string().regex(/^(0|[1-9][0-9]{0,17})$/);
130
+ const text = z.string().min(1).max(2048);
131
+
132
+ /** Values, rather than declarations. The same validator feeds forms and admission. */
133
+ export function udlFieldValueSchema(field: UdlField): z.ZodType {
134
+ let schema: z.ZodType;
135
+ switch (field.type) {
136
+ case "money":
137
+ schema = amount
138
+ .refine(
139
+ (value) =>
140
+ /^(0|[1-9][0-9]{0,17})$/.test(value) &&
141
+ (field.minimum === undefined ||
142
+ BigInt(value) >= BigInt(field.minimum)) &&
143
+ (field.maximum === undefined ||
144
+ BigInt(value) <= BigInt(field.maximum)),
145
+ "Money is outside its declared bounds",
146
+ )
147
+ .meta({
148
+ ...(field.minimum !== undefined
149
+ ? { "x-udl-minimum": field.minimum }
150
+ : {}),
151
+ ...(field.maximum !== undefined
152
+ ? { "x-udl-maximum": field.maximum }
153
+ : {}),
154
+ });
155
+ break;
156
+ case "account":
157
+ schema = text;
158
+ break;
159
+ case "ref":
160
+ schema = field.targetKind === "object" ? udlObjectIdSchema : text;
161
+ break;
162
+ case "date":
163
+ schema = z.iso.datetime({ offset: true });
164
+ break;
165
+ case "duration":
166
+ schema = z.number().int().safe().positive();
167
+ break;
168
+ case "text": {
169
+ let value = z
170
+ .string()
171
+ .min(field.minLength ?? 1)
172
+ .max(field.maxLength ?? 2048);
173
+ if (field.pattern) value = value.regex(new RegExp(field.pattern));
174
+ schema = value;
175
+ break;
176
+ }
177
+ case "integer": {
178
+ let value = z.number().int().safe();
179
+ if (field.minimum !== undefined) value = value.min(field.minimum);
180
+ if (field.maximum !== undefined) value = value.max(field.maximum);
181
+ schema = value;
182
+ break;
183
+ }
184
+ case "percent":
185
+ schema = z.number().int().min(0).max(10000);
186
+ break;
187
+ case "boolean":
188
+ schema = z.boolean();
189
+ break;
190
+ case "enum":
191
+ schema = z.enum(field.values);
192
+ break;
193
+ case "list": {
194
+ const item =
195
+ field.item === "money"
196
+ ? amount
197
+ : field.item === "date"
198
+ ? z.iso.datetime({ offset: true })
199
+ : field.item === "integer"
200
+ ? z.number().int().safe()
201
+ : field.item === "ref" && field.targetKind === "object"
202
+ ? udlObjectIdSchema
203
+ : text;
204
+ schema = z.array(item).max(field.maxItems);
205
+ break;
206
+ }
207
+ }
208
+ if ("value" in field && field.value !== undefined)
209
+ schema = z.literal(field.value);
210
+ if (field.description) schema = schema.describe(field.description);
211
+ return schema;
212
+ }
213
+ /** Creation accepts the full metadata union, with every value optional. */
214
+ export function objectCreateSchema(kind: UdlObjectKind): z.ZodObject {
215
+ return z.strictObject({
216
+ externalId: udlExternalIdSchema.optional(),
217
+ fields: z
218
+ .strictObject(
219
+ Object.fromEntries(
220
+ kind.fields.map((field) => [
221
+ field.name,
222
+ udlFieldValueSchema(field).optional(),
223
+ ]),
224
+ ),
225
+ )
226
+ .optional(),
227
+ });
228
+ }
229
+
230
+ /** Canonical UDL supplies meaning; core adds authority, lifecycle and readiness. */
231
+ export function projectObjectDiscovery(
232
+ document: UdlDocument,
233
+ build: Pick<ObjectDiscovery, "productBuildId" | "digest">,
234
+ ): ObjectDiscovery {
235
+ return {
236
+ ...build,
237
+ retainedKinds: [],
238
+ kinds: document.objects.map((kind) => ({
239
+ creation: true,
240
+ kind: kind.id,
241
+ title: kind.title,
242
+ fields: kind.fields,
243
+ authoredFields: kind.authoredFields,
244
+ columns: kind.columns,
245
+ createSchema: z.toJSONSchema(objectCreateSchema(kind), { io: "input" }),
246
+ actions: document.instruments
247
+ .filter((instrument) => instrument.subject === kind.id)
248
+ .flatMap((instrument) => {
249
+ const createHidden =
250
+ !!instrument.actions.create &&
251
+ !instrument.actions.create.publicAction;
252
+ const attachmentRefListFields = instrument.fields.filter(
253
+ (field) =>
254
+ field.type === "list" &&
255
+ field.item === "ref" &&
256
+ field.targetKind !== "object",
257
+ );
258
+ return instrument.actionOrder.flatMap((name) => {
259
+ const action = instrument.actions[name]!;
260
+ if (!action.publicAction) return [];
261
+ const transition = instrument.lifecycle.transitions[name];
262
+ const canStartFromInitial = Boolean(
263
+ transition?.from.includes(instrument.lifecycle.initial),
264
+ );
265
+ const isCreationAction =
266
+ name === "create" || (createHidden && canStartFromInitial);
267
+
268
+ const rawCreateReqs: {
269
+ field: UdlField;
270
+ objectField?: string | undefined;
271
+ }[] = isCreationAction
272
+ ? [
273
+ ...(instrument.actions.create?.subject?.requirements ?? []),
274
+ ...attachmentRefListFields.map((field) => ({ field })),
275
+ ]
276
+ : [];
277
+ const actionReqs = action.subject?.requirements ?? [];
278
+ const actionReqNames = new Set(
279
+ actionReqs.map((r) => r.objectField ?? r.field.name),
280
+ );
281
+ const mergedReqs: {
282
+ field: UdlField;
283
+ objectField?: string | undefined;
284
+ }[] = [...actionReqs];
285
+ for (const req of rawCreateReqs) {
286
+ const reqName = req.objectField ?? req.field.name;
287
+ if (
288
+ !mergedReqs.some(
289
+ (r) => (r.objectField ?? r.field.name) === reqName,
290
+ )
291
+ ) {
292
+ mergedReqs.push(req);
293
+ }
294
+ }
295
+ const unbound = [
296
+ ...(action.subject?.adapters ?? []),
297
+ ...(isCreationAction
298
+ ? (instrument.actions.create?.subject?.adapters ?? [])
299
+ : []),
300
+ ].find((adapter) => adapter.snapshot === null);
301
+ const seenCreation = new Set<string>();
302
+ const creationRequirements: UdlObjectField[] = [];
303
+ for (const requirement of rawCreateReqs) {
304
+ const name = requirement.objectField ?? requirement.field.name;
305
+ if (!seenCreation.has(name)) {
306
+ seenCreation.add(name);
307
+ creationRequirements.push({
308
+ ...requirement.field,
309
+ name,
310
+ } as UdlObjectField);
311
+ }
312
+ }
313
+ const creationOnlyNames = creationRequirements
314
+ .map((field) => field.name)
315
+ .filter((name) => !actionReqNames.has(name));
316
+ return [
317
+ {
318
+ ...build,
319
+ target: {
320
+ kind: "attachment" as const,
321
+ attachment: kind.attachments.find(
322
+ (attachment) => attachment.instrument === instrument.id,
323
+ )!.name,
324
+ },
325
+ name: action.publicAction,
326
+ title: action.summary,
327
+ instrument: instrument.id,
328
+ action: name,
329
+ requirements: mergedReqs.map(
330
+ (requirement) =>
331
+ ({
332
+ ...requirement.field,
333
+ name: requirement.objectField ?? requirement.field.name,
334
+ }) as UdlObjectField,
335
+ ),
336
+ ...(creationRequirements.length > 0
337
+ ? { creationRequirements }
338
+ : {}),
339
+ ...(creationRequirements.length > 0
340
+ ? { creationOnlyNames }
341
+ : {}),
342
+ fieldsSchema: z.toJSONSchema(
343
+ z.strictObject(
344
+ Object.fromEntries(
345
+ mergedReqs.map((requirement) => [
346
+ requirement.objectField ?? requirement.field.name,
347
+ udlFieldValueSchema(requirement.field).optional(),
348
+ ]),
349
+ ),
350
+ ),
351
+ { io: "input" },
352
+ ),
353
+ inputSchema: z.toJSONSchema(
354
+ z.strictObject(
355
+ Object.fromEntries(
356
+ action.input.map((field) => [
357
+ field.name,
358
+ field.optional
359
+ ? udlFieldValueSchema(field).optional()
360
+ : udlFieldValueSchema(field),
361
+ ]),
362
+ ),
363
+ ),
364
+ { io: "input" },
365
+ ),
366
+ availability: unbound
367
+ ? {
368
+ status: "unavailable" as const,
369
+ code: "subject_adapter_unbound",
370
+ message: `Adapter declaration ${unbound.binding} is unavailable`,
371
+ }
372
+ : { status: "available" as const },
373
+ },
374
+ ];
375
+ });
376
+ }),
377
+ })),
378
+ };
379
+ }
380
+
381
+ /** Object-specific values determine missing fields; global discovery cannot. */
382
+ export function objectActionState(
383
+ action: ObjectActionDiscovery,
384
+ fields: Readonly<Record<string, unknown>>,
385
+ options?: { attached?: boolean } | boolean,
386
+ ): ObjectActionState {
387
+ const attached =
388
+ typeof options === "boolean" ? options : (options?.attached ?? false);
389
+ const creationFilter = new Set(action.creationOnlyNames ?? []);
390
+ return {
391
+ ...action,
392
+ requiredNow: action.requirements
393
+ .filter((field) => {
394
+ const value = fields[field.name];
395
+ const optional =
396
+ field.optional || (attached && creationFilter.has(field.name));
397
+ return (
398
+ !(optional && value === undefined) &&
399
+ !udlFieldValueSchema(field).safeParse(value).success
400
+ );
401
+ })
402
+ .map((field) => field.name),
403
+ };
404
+ }
405
+ export interface SubjectRequirementIssue {
406
+ code:
407
+ | "subject_requirement_missing"
408
+ | "subject_field_unknown"
409
+ | "subject_adapter_unbound";
410
+ action: string;
411
+ field?: string;
412
+ origin: string;
413
+ message: string;
414
+ }
415
+
416
+ /** Check merged metadata before dispatch. Runtime guards add their active child requirements. */
417
+ export function validateObjectActionSubject(
418
+ action: ObjectActionDiscovery,
419
+ storedFields: Readonly<Record<string, unknown>>,
420
+ submittedFields: Readonly<Record<string, unknown>>,
421
+ options?: { attached?: boolean } | boolean,
422
+ ): readonly SubjectRequirementIssue[] {
423
+ const origin = `${action.instrument}.${action.action}.subject`;
424
+ const issues: SubjectRequirementIssue[] = [];
425
+ if (
426
+ action.availability.status === "unavailable" &&
427
+ action.availability.code === "subject_adapter_unbound"
428
+ ) {
429
+ issues.push({
430
+ code: "subject_adapter_unbound",
431
+ action: action.name,
432
+ origin,
433
+ message: action.availability.message,
434
+ });
435
+ }
436
+ const admitted = new Set(action.requirements.map((field) => field.name));
437
+ for (const field of Object.keys(submittedFields)) {
438
+ if (!admitted.has(field))
439
+ issues.push({
440
+ code: "subject_field_unknown",
441
+ action: action.name,
442
+ field,
443
+ origin,
444
+ message: `${field} is not admitted by ${action.name}`,
445
+ });
446
+ }
447
+ for (const field of objectActionState(
448
+ action,
449
+ {
450
+ ...storedFields,
451
+ ...submittedFields,
452
+ },
453
+ options,
454
+ ).requiredNow) {
455
+ issues.push({
456
+ code: "subject_requirement_missing",
457
+ action: action.name,
458
+ field,
459
+ origin: `${origin}.${field}`,
460
+ message: `${action.name} requires a valid ${field}`,
461
+ });
462
+ }
463
+ return issues;
464
+ }
package/src/reporting.ts CHANGED
@@ -171,7 +171,6 @@ export const reportDefinitionSchema = z.strictObject({
171
171
  request: z.array(name).min(1).max(64),
172
172
  read: z.array(name).min(1).max(64),
173
173
  release: z.array(name).max(64),
174
- independentApproval: z.literal(true),
175
174
  retention: z.strictObject({ policy: name, years: integer.min(1).max(100) }),
176
175
  }),
177
176
  });