import * as z from "zod"; import { currencySchema } from "./money.js"; import { reportDefinitionSchema } from "./reporting.js"; import { canonicalJson } from "./canonical.js"; import { amount, integer, name, path, text } from "./primitives.js"; export const UDL_FORMAT_VERSION = 2 as const; /** Counts the root and every nested invocation, including selected and ranged children. */ export const MAX_ACTION_EXPANSION = 8192; const scalar = z.union([z.string(), integer, z.boolean()]); const instrumentId = name.meta({ "x-udl-reference": "instrument" }); const objectKindId = name.meta({ "x-udl-reference": "object" }); export const udlPartyNameSchema = name; export const udlObjectIdSchema = z.string().min(1).max(160); export const udlExternalIdSchema = z.string().min(1).max(2048); export const udlObjectRevisionSchema = integer.positive(); /** Currency belongs to the document. Accounts and amounts cannot override it. */ export const udlPartySchema = z.strictObject({ kind: z.enum(["person", "business", "staff"]), role: name.optional(), }); /** Format 2 names a std declaration by the digest of its normal form. */ export const udlFamilySchema = z.strictObject({ module: name, exportPath: path, digest: z.string().regex(/^[a-f0-9]{64}$/), }); /** Format 1 named it by a hand-written revision. Stored Builds keep it. */ export const udlFormat1FamilySchema = z.strictObject({ module: name, exportPath: path, revision: integer.positive(), }); // One tree serves both formats; each document schema checks its families. const family = z.union([udlFamilySchema, udlFormat1FamilySchema]); const fieldBase = { name, label: text.optional(), optional: z.literal(true).optional(), description: text.optional(), sensitive: z.literal(true).optional(), }; const referenceField = z.strictObject({ ...fieldBase, type: z.literal("ref"), targetKind: z.enum(["instrument", "object"]), targetFamily: family.optional(), target: z.union([name, z.array(name).min(1).max(16)]), }); const listField = z.strictObject({ ...fieldBase, type: z.literal("list"), item: z.enum(["money", "date", "text", "integer", "ref"]), targetKind: z.enum(["instrument", "object"]).optional(), targetFamily: family.optional(), target: name.optional(), maxItems: integer.min(1).max(366), }); export const udlFieldSchema = z.discriminatedUnion("type", [ z.strictObject({ ...fieldBase, type: z.literal("money"), value: amount.optional(), minimum: amount.optional(), maximum: amount.optional(), }), z.strictObject({ ...fieldBase, type: z.literal("account"), owner: z.union([name, z.strictObject({ adapter: name })]), key: name.optional(), book: z.enum(["cash", "claim"]).default("cash"), contra: z.literal(true).optional(), }), referenceField, z.strictObject({ ...fieldBase, type: z.literal("date"), value: z.iso.datetime({ offset: true }).optional(), }), z.strictObject({ ...fieldBase, type: z.literal("duration"), value: integer.positive().optional(), }), z.strictObject({ ...fieldBase, type: z.literal("text"), value: text.optional(), minLength: integer.positive().max(2048).optional(), maxLength: integer.positive().max(2048).optional(), // One anchored ASCII character class and a fixed repetition. No executable patterns. pattern: z .string() .regex(/^\^\[[A-Za-z0-9-]+\]\{[1-9][0-9]{0,3}\}\$$/) .optional(), }), z.strictObject({ ...fieldBase, type: z.literal("integer"), value: integer.optional(), minimum: integer.optional(), maximum: integer.optional(), }), z.strictObject({ ...fieldBase, type: z.literal("percent"), value: integer.min(0).max(10000).optional(), }), z.strictObject({ ...fieldBase, type: z.literal("boolean"), value: z.boolean().optional(), }), z.strictObject({ ...fieldBase, type: z.literal("enum"), values: z.array(name).min(1).max(64), value: name.optional(), }), listField, ]); const [ moneyField, , refField, dateField, durationField, textField, integerField, percentField, booleanField, enumField, boundedListField, ] = udlFieldSchema.options; export const udlObjectFieldSchema = z.discriminatedUnion("type", [ moneyField, refField, dateField, durationField, textField, integerField, percentField, booleanField, enumField, boundedListField, ]); /** Values are constants or resolved typed paths, never executable strings. */ export const udlValueSchema = z.union([ z.strictObject({ literal: scalar }), z.strictObject({ field: path }), ]); const value = udlValueSchema; const comparisonRequirement = z.strictObject({ kind: z.literal("compare"), left: value, operator: z.enum(["==", "!=", "<", "<=", ">", ">="]), right: value, }); // Alternatives are OR; each invocation path is a conjunction of scoped guards. export const udlSubjectConditionsSchema = z .array( z .array( z.strictObject({ instrument: instrumentId, action: name, guard: comparisonRequirement, valueType: z.enum(["money", "date"]).optional(), }), ) .min(1) .max(32), ) .min(1) .max(128); export const udlSubjectRequirementSchema = z.strictObject({ field: udlObjectFieldSchema, // Present only for an authored rename. objectField: name.optional(), when: udlSubjectConditionsSchema.optional(), }); export const adapterSubjectSnapshotSchema = z.strictObject({ onboarding: z .array(z.enum(["person", "organization"])) .min(1) .max(2) .optional(), provider: text, capability: text, operation: text, declarationDigest: z.string().regex(/^[a-f0-9]{64}$/), requirements: z.array(udlObjectFieldSchema).max(128), }); export const udlActionSubjectSchema = z.strictObject({ requirements: z.array(udlSubjectRequirementSchema).max(128), adapters: z .array( z.strictObject({ binding: name, // null means unavailable, never "no requirements". snapshot: adapterSubjectSnapshotSchema.nullable(), renames: z.record(name, name).optional(), }), ) .max(16), }); export const subjectPartyRoles = ["owner", "actor", "operator"] as const; export const attachmentPartyBindingSchema = z.union([ z.strictObject({ role: z.enum(subjectPartyRoles) }), z.strictObject({ party: name }), ]); export type AttachmentPartyBinding = z.infer< typeof attachmentPartyBindingSchema >; export const udlObjectAttachmentSchema = z.strictObject({ name, title: text.optional(), parent: name.optional(), instrument: instrumentId, parties: z.record(name, attachmentPartyBindingSchema), }); export type SubjectPartyRole = (typeof subjectPartyRoles)[number]; export type UdlObjectAttachment = z.infer; export const udlObjectKindSchema = z.strictObject({ entryActions: z.array(name).min(1).max(128).optional(), id: objectKindId, title: text, authoredFields: z.array(name).max(256), attachments: z.array(udlObjectAttachmentSchema).max(256), fields: z.array(udlObjectFieldSchema).max(256), columns: z.array(name).max(8), }); const values = z.array(value).min(1).max(256); const selection = z.strictObject({ family: family.optional(), instrument: z.union([instrumentId, z.array(instrumentId).min(1).max(16)]), reference: name, anchor: path, states: z.array(name).min(1).max(64), limit: integer.min(1).max(366), /** Takes the first limit rows by order and id instead of refusing more. */ batch: z.literal(true).optional(), order: z.array(path).min(1).max(4).optional(), window: z .strictObject({ field: name, milliseconds: integer.positive() }) .optional(), /** Rows whose half-open [start, end) period meets [from, until). */ overlaps: z .strictObject({ start: name, end: name, from: value, until: value }) .optional(), where: z .record(path, value) .refine( (filters) => Object.keys(filters).length <= 8, "at most eight selection filters", ) .meta({ maxProperties: 8 }) .optional(), }); /** A finite, ordered calculation graph. Every result is a declared field. */ export const udlCalculationSchema = z.discriminatedUnion("op", [ z.strictObject({ target: name, op: z.literal("at"), list: path, position: value, }), z.strictObject({ target: name, op: z.literal("aggregate"), selection, measure: z.union([z.literal("count"), z.strictObject({ sum: path })]), }), z.strictObject({ target: name, op: z.literal("ratio"), amount: value, numerator: value, denominator: value, rounding: z.literal("floor"), }), z.strictObject({ target: name, op: z.literal("sum"), values }), z.strictObject({ target: name, op: z.literal("subtract"), base: value, subtract: values, }), z.strictObject({ target: name, op: z.literal("rate"), base: value, bps: value, rounding: z.literal("floor"), }), z.strictObject({ target: name, op: z.literal("minimum"), values }), z.strictObject({ target: name, op: z.literal("multiply"), amount: value, units: value, }), z.strictObject({ target: name, op: z.literal("divide"), amount: value, divisor: value, rounding: z.literal("floor"), }), z.strictObject({ target: name, op: z.literal("annuity"), amount: value, rate: value, periods: value, position: value, part: z.enum(["principal", "interest"]), }), z.strictObject({ target: name, op: z.literal("shift"), date: value, milliseconds: value, direction: z.enum(["before", "after"]), }), /** * The date `times` periods after `date`. Calendar months count from the * anchor, so a 31 January start falls on 28 February, then 31 March. */ z.strictObject({ target: name, op: z.literal("step"), date: value, period: value, times: value, }), ]); export const udlRequirementSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("hours"), at: path, start: integer.min(0).max(23), end: integer.min(0).max(23), timezone: text, }), comparisonRequirement, z.strictObject({ kind: z.literal("state"), reference: path, states: z.array(name).min(1).max(64), }), z.strictObject({ kind: z.literal("unique"), namespace: name, fields: z.array(path).min(1).max(8), }), z.strictObject({ kind: z.literal("aggregate"), selection, measure: z.union([z.literal("count"), z.strictObject({ sum: path })]), operator: z.enum(["==", "<=", ">=", "<", ">"]), value, }), z.strictObject({ kind: z.literal("evidence"), instruction: path.optional(), subject: path, family: name, check: name, result: name, maxAge: integer.positive(), }), ]); export const udlKernelOperationSchema = z.enum([ "internal_transfer.create", "internal_transfer.reserve", "internal_transfer.post", "internal_transfer.void", ]); /** The economic purposes a money move can declare. */ export const economicPurposes = [ "earning", "principal", "participant_payout", "internal", "prepaid_credit", "pass_through", ] as const; export type EconomicPurpose = (typeof economicPurposes)[number]; const moveEconomics = z.strictObject({ purpose: z.enum(economicPurposes), sourceParty: name, reversalOf: path.optional(), }); export const udlMoveSchema = z.discriminatedUnion("operation", [ z.strictObject({ key: name, operation: z.literal("internal_transfer.create"), economics: moveEconomics.optional(), capture: name.optional(), amount: value, from: path, to: path, }), z.strictObject({ key: name, operation: z.literal("internal_transfer.reserve"), economics: moveEconomics.optional(), boundary: z.strictObject({ adapter: name }).optional(), amount: value, from: path, to: path, capture: name, }), z.strictObject({ key: name, operation: z.literal("internal_transfer.post"), economics: moveEconomics.optional(), capture: name.optional(), transfer: path, }), z.strictObject({ key: name, operation: z.literal("internal_transfer.void"), economics: moveEconomics.optional(), capture: name.optional(), transfer: path, }), ]); const clock = z.strictObject({ at: path, offset: integer.optional(), localDay: z .strictObject({ days: integer.min(0).max(366), direction: z.enum(["before", "after"]), hour: integer.min(0).max(23), timezone: text, }) .optional(), }); export const udlActionSchema = z.strictObject({ allowZero: z.literal(true).optional(), purpose: z.enum(["obligation", "option"]).optional(), summary: text, title: text.optional(), publicAction: name.optional(), // Retained Builds keep this key; execution uses MAX_ACTION_EXPANSION. expansionLimit: z.literal(8192).optional(), reminder: z .strictObject({ installment: path, recipient: path, dueAt: path, channel: z.literal("email"), template: z.literal("payment_reminder"), beforeDays: integer.min(0).max(366), overdueDays: integer.min(1).max(366), maxPerDay: integer.min(1).max(10), startHour: integer.min(0).max(23), endHour: integer.min(1).max(24), timezone: text, }) .optional(), event: text, actor: z.union([ z.literal("caller"), z.literal("clock"), z.strictObject({ party: name }), z.strictObject({ parent: z.union([instrumentId, z.array(instrumentId).min(1).max(16)]), }), ]), subject: udlActionSubjectSchema.optional(), // The subject requirement this action keeps in step with. When another // action on the same object changes the object field it binds, the engine // runs this action on the agreement in the same request. follows: name.optional(), input: z.array(udlFieldSchema).max(128), requires: z.array(udlRequirementSchema).max(128), due: clock.optional(), deadline: clock.optional(), set: z.record(name, value).optional(), calculate: z.array(udlCalculationSchema).max(128).optional(), moves: z.array(udlMoveSchema).max(256), invoke: z .array( z.union([ z.strictObject({ instrument: instrumentId, action: z.literal("create"), input: z.record(name, value), guard: comparisonRequirement.optional(), range: z .strictObject({ count: value, maximum: integer.min(1).max(366), bind: name, }) .optional(), }), z.strictObject({ reference: path, action: name, input: z.record(name, value), guard: comparisonRequirement.optional(), }), z.strictObject({ selection, action: name, input: z.record(name, value), guard: comparisonRequirement.optional(), }), ]), ) .max(16) .optional(), }); export const udlLifecycleSchema = z.strictObject({ states: z.array(name).min(1).max(64), labels: z.record(name, text).optional(), initial: name, transitions: z.record( name, z.strictObject({ from: z.array(name).min(1).max(64), to: name }), ), }); export const udlInstrumentSchema = z.strictObject({ scope: z.literal("product").optional(), reports: z.array(reportDefinitionSchema).max(16).optional(), revisioned: z.literal(true).optional(), /** * One agreement per record: while one is in a state outside `reopenOn`, no * other customer sees the record or starts another. A one-off sale declares * it; a repeatable deal leaves it out. */ oncePerRecord: z .strictObject({ reopenOn: z.array(name).min(1).max(64) }) .optional(), family: family.optional(), id: instrumentId, subject: objectKindId.optional(), // The authored instrument an attachment instantiates, when its scoped id // (object_attachment) differs from the name the founder wrote. template: name.optional(), title: text, summary: text, fields: z.array(udlFieldSchema).max(256), calculate: z.array(udlCalculationSchema).max(128), lifecycle: udlLifecycleSchema, actions: z.record(name, udlActionSchema), actionOrder: z.array(name).min(1).max(128), invariants: z.array(udlRequirementSchema).max(128).optional(), examples: z .array(z.strictObject({ name: text, input: z.json() })) .max(16) .optional(), }); const documentShape = { version: integer.positive(), product: name, title: text, currency: currencySchema, parties: z.record(name, udlPartySchema), objects: z.array(udlObjectKindSchema).max(256), instruments: z.array(udlInstrumentSchema).max(256), }; /** Every family a document names: instruments, reference fields, selections. */ function families(instruments: unknown[]): [PropertyKey[], object][] { const found: [PropertyKey[], object][] = []; const visit = (value: unknown, path: PropertyKey[]): void => { if (!value || typeof value !== "object") return; for (const [key, child] of Object.entries(value)) { // An evidence `family` is a name, not a std family. if ( (key === "family" || key === "targetFamily") && child && typeof child === "object" ) found.push([[...path, key], child]); else if (key !== "examples") visit(child, [...path, Array.isArray(value) ? Number(key) : key]); } }; visit(instruments, ["instruments"]); return found; } const familiesIn = (key: "digest" | "revision") => (document: { instruments: unknown[] }, context: z.RefinementCtx) => { for (const [path, item] of families(document.instruments)) if (!(key in item)) context.addIssue({ code: "custom", path, message: `a format ${key === "digest" ? 2 : 1} family names its ${key}`, }); }; export const udlDocumentSchema = z .strictObject({ udl: z.literal(UDL_FORMAT_VERSION), ...documentShape }) .superRefine(familiesIn("digest")); /** The format 1 reader: families carry the revision they were stored with. */ export const udlFormat1DocumentSchema = z .strictObject({ udl: z.literal(1), ...documentShape }) .superRefine(familiesIn("revision")); /** Any document a stored Build may hold, in the format it compiled to. */ export const udlStoredDocumentSchema = z.discriminatedUnion("udl", [ udlDocumentSchema, udlFormat1DocumentSchema, ]); export type UdlFamily = z.infer; export type UdlFormat1Family = z.infer; export type UdlAnyFamily = UdlFamily | UdlFormat1Family; export type UdlDocument = z.infer; export type UdlInstrument = z.infer; export type UdlField = z.infer; export type UdlValue = z.infer; export type UdlCalculation = z.infer; export type UdlRequirement = z.infer; export type UdlAction = z.infer; export type UdlSelection = z.infer; export type UdlObjectField = z.infer; export type UdlObjectKind = z.infer; export type UdlActionSubject = z.infer; export type UdlSubjectRequirement = z.infer; export type UdlAdapterSubjectSnapshot = z.infer< typeof adapterSubjectSnapshotSchema >; /** * The adapter declaration a subject snapshot freezes. Its digest is the * snapshot's `declarationDigest`, so the compiler and the engine both build it * here. */ export function adapterDeclaration(declaration: { readonly onboarding?: readonly ("person" | "organization")[] | undefined; readonly provider: string; readonly capability: string; readonly operation: string; readonly requirements: readonly UdlObjectField[]; }): Omit { return { ...(declaration.onboarding ? { onboarding: [...declaration.onboarding] } : {}), provider: declaration.provider, capability: declaration.capability, operation: declaration.operation, requirements: [...declaration.requirements], }; } /** Names and display text may differ; executable constraints must match. */ export function sameObjectField( left: UdlObjectField, right: UdlObjectField, ): boolean { const signature = (field: UdlObjectField) => canonicalJson( Object.fromEntries( Object.entries(field).filter( ([key, value]) => !["name", "label", "description", "optional"].includes(key) && value !== undefined, ), ), ); return signature(left) === signature(right); }