@hyperscale0/udl 1.0.0-alpha.1
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/AUTHORS +8 -0
- package/CHANGELOG.md +36 -0
- package/LICENSE +661 -0
- package/LICENSING.md +51 -0
- package/README.md +163 -0
- package/SECURITY.md +42 -0
- package/TRADEMARKS.md +35 -0
- package/conformance/README.md +79 -0
- package/conformance/invalid/blank-title.expected.json +10 -0
- package/conformance/invalid/blank-title.udl +50 -0
- package/conformance/invalid/depth-budget.expected.json +10 -0
- package/conformance/invalid/depth-budget.udl +49 -0
- package/conformance/invalid/format-version.expected.json +10 -0
- package/conformance/invalid/format-version.udl +50 -0
- package/conformance/invalid/invalid-utf8.expected.json +10 -0
- package/conformance/invalid/invalid-utf8.udl +1 -0
- package/conformance/invalid/malformed-json.expected.json +10 -0
- package/conformance/invalid/malformed-json.udl +1 -0
- package/conformance/invalid/missing-create-verb.expected.json +10 -0
- package/conformance/invalid/missing-create-verb.udl +45 -0
- package/conformance/invalid/not-an-object.expected.json +10 -0
- package/conformance/invalid/not-an-object.udl +1 -0
- package/conformance/invalid/noun-id-not-snake-case.expected.json +10 -0
- package/conformance/invalid/noun-id-not-snake-case.udl +50 -0
- package/conformance/invalid/unknown-key.expected.json +10 -0
- package/conformance/invalid/unknown-key.udl +51 -0
- package/conformance/invalid/unreachable-state.expected.json +10 -0
- package/conformance/invalid/unreachable-state.udl +51 -0
- package/conformance/invalid/verb-without-transition.expected.json +10 -0
- package/conformance/invalid/verb-without-transition.udl +55 -0
- package/conformance/valid/cards.expected.json +5 -0
- package/conformance/valid/cards.udl +1092 -0
- package/conformance/valid/commerce-escrow.expected.json +5 -0
- package/conformance/valid/commerce-escrow.udl +1011 -0
- package/conformance/valid/hand-edited.expected.json +5 -0
- package/conformance/valid/hand-edited.udl +1 -0
- package/conformance/valid/insured-car-marketplace.expected.json +5 -0
- package/conformance/valid/insured-car-marketplace.udl +831 -0
- package/conformance/valid/insured-travel.expected.json +5 -0
- package/conformance/valid/insured-travel.udl +2621 -0
- package/conformance/valid/minimal.expected.json +5 -0
- package/conformance/valid/minimal.udl +50 -0
- package/conformance/valid/protection.expected.json +5 -0
- package/conformance/valid/protection.udl +1136 -0
- package/dist/canonical.d.ts +2 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +35 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/duration.d.ts +3 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +23 -0
- package/dist/duration.js.map +1 -0
- package/dist/evolution.d.ts +63 -0
- package/dist/evolution.d.ts.map +1 -0
- package/dist/evolution.js +345 -0
- package/dist/evolution.js.map +1 -0
- package/dist/finance.d.ts +53 -0
- package/dist/finance.d.ts.map +1 -0
- package/dist/finance.js +511 -0
- package/dist/finance.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +33 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +33 -0
- package/dist/limits.js.map +1 -0
- package/dist/parser.d.ts +4 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +54 -0
- package/dist/parser.js.map +1 -0
- package/dist/schema.d.ts +757 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +281 -0
- package/dist/schema.js.map +1 -0
- package/dist/validation.d.ts +64 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +1323 -0
- package/dist/validation.js.map +1 -0
- package/package.json +84 -0
- package/spec/README.md +137 -0
- package/spec/udl.schema.json +1019 -0
- package/src/canonical.ts +41 -0
- package/src/cli.ts +148 -0
- package/src/duration.ts +26 -0
- package/src/evolution.ts +587 -0
- package/src/finance.ts +775 -0
- package/src/index.ts +59 -0
- package/src/limits.ts +32 -0
- package/src/parser.ts +61 -0
- package/src/schema.ts +339 -0
- package/src/validation.ts +2053 -0
package/src/evolution.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import type { UdlAggregate, UdlDocument, UdlNoun, UdlVerb } from "./schema.js";
|
|
2
|
+
|
|
3
|
+
export interface EvolutionFieldSnapshot {
|
|
4
|
+
readonly required: boolean;
|
|
5
|
+
readonly schema: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EvolutionStepSnapshot {
|
|
9
|
+
readonly bind: unknown;
|
|
10
|
+
readonly capture: unknown;
|
|
11
|
+
readonly operation: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EvolutionMoveSnapshot extends EvolutionStepSnapshot {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EvolutionVerbSnapshot {
|
|
19
|
+
readonly deadline: unknown;
|
|
20
|
+
readonly decision: unknown;
|
|
21
|
+
readonly due: unknown;
|
|
22
|
+
readonly earnable: boolean;
|
|
23
|
+
readonly eventName: string | null;
|
|
24
|
+
readonly input: Readonly<Record<string, EvolutionFieldSnapshot>> | null;
|
|
25
|
+
/**
|
|
26
|
+
* Every top-level input schema keyword EXCEPT properties/required (those are
|
|
27
|
+
* diffed field by field). additionalProperties, oneOf, minProperties, and
|
|
28
|
+
* friends change what callers may send, so they are frozen once live.
|
|
29
|
+
*/
|
|
30
|
+
readonly inputConstraints: unknown;
|
|
31
|
+
readonly port: unknown;
|
|
32
|
+
readonly requiresAggregate: unknown;
|
|
33
|
+
readonly requiresDrainedAccount: unknown;
|
|
34
|
+
readonly requiresRefs: unknown;
|
|
35
|
+
readonly setsAt: unknown;
|
|
36
|
+
readonly moves: readonly EvolutionMoveSnapshot[];
|
|
37
|
+
readonly steps: readonly EvolutionStepSnapshot[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EvolutionTransitionSnapshot {
|
|
41
|
+
readonly from: readonly string[];
|
|
42
|
+
readonly to: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The complete serializable algebra protected by append-only evolution. */
|
|
46
|
+
export interface NounEvolutionSnapshot {
|
|
47
|
+
readonly aggregateInvariants: readonly string[];
|
|
48
|
+
readonly distinctParties?: true;
|
|
49
|
+
readonly fields: Readonly<Record<string, EvolutionFieldSnapshot>>;
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly idPrefix: string;
|
|
52
|
+
readonly initial: string;
|
|
53
|
+
readonly parties: Readonly<Record<string, string>>;
|
|
54
|
+
readonly partitions: readonly string[];
|
|
55
|
+
readonly states: readonly string[];
|
|
56
|
+
readonly subjects: readonly string[];
|
|
57
|
+
readonly transitions: Readonly<Record<string, EvolutionTransitionSnapshot>>;
|
|
58
|
+
readonly unwind: unknown;
|
|
59
|
+
readonly updateFields: readonly string[];
|
|
60
|
+
readonly updateStates: readonly string[];
|
|
61
|
+
readonly verbs: Readonly<Record<string, EvolutionVerbSnapshot>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Project an open .udl noun into the evolution engine's exact semantic shape. */
|
|
65
|
+
export function snapshotUdlNoun(noun: UdlNoun): NounEvolutionSnapshot {
|
|
66
|
+
const required = new Set(noun.required);
|
|
67
|
+
return {
|
|
68
|
+
aggregateInvariants: (noun.aggregateInvariants ?? []).map(
|
|
69
|
+
aggregateInvariantKey,
|
|
70
|
+
),
|
|
71
|
+
...(noun.distinctParties ? { distinctParties: true as const } : {}),
|
|
72
|
+
fields: Object.fromEntries(
|
|
73
|
+
Object.entries(noun.fields).map(([field, schema]) => [
|
|
74
|
+
field,
|
|
75
|
+
{ required: required.has(field), schema },
|
|
76
|
+
]),
|
|
77
|
+
),
|
|
78
|
+
id: noun.id,
|
|
79
|
+
idPrefix: noun.idPrefix,
|
|
80
|
+
initial: noun.lifecycle.initial,
|
|
81
|
+
parties: compactStringRecord(noun.parties),
|
|
82
|
+
partitions: (noun.partitions ?? []).map(
|
|
83
|
+
(partition) =>
|
|
84
|
+
`${partition.totalField} = ${[...partition.pieceFields].sort().join(" + ")}`,
|
|
85
|
+
),
|
|
86
|
+
states: [...noun.lifecycle.states],
|
|
87
|
+
subjects: noun.subject ? [...noun.subject.kinds] : [],
|
|
88
|
+
transitions: Object.fromEntries(
|
|
89
|
+
Object.entries(noun.lifecycle.transitions).map(([verb, transition]) => [
|
|
90
|
+
verb,
|
|
91
|
+
{ from: [...transition.from], to: transition.to },
|
|
92
|
+
]),
|
|
93
|
+
),
|
|
94
|
+
unwind: noun.unwind ?? null,
|
|
95
|
+
updateFields: noun.update?.fields ?? [],
|
|
96
|
+
updateStates: noun.update?.states ?? [],
|
|
97
|
+
verbs: Object.fromEntries(
|
|
98
|
+
Object.entries(noun.verbs).map(([verb, definition]) => [
|
|
99
|
+
verb,
|
|
100
|
+
snapshotUdlVerb(definition),
|
|
101
|
+
]),
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Compare two product definitions under the append-only evolution law. */
|
|
107
|
+
export function diffUdlEvolution(
|
|
108
|
+
previous: UdlDocument,
|
|
109
|
+
next: UdlDocument,
|
|
110
|
+
): readonly string[] {
|
|
111
|
+
const violations: string[] = [];
|
|
112
|
+
if (previous.product !== next.product) {
|
|
113
|
+
violations.push(
|
|
114
|
+
`product id changed from ${previous.product} to ${next.product}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (next.version < previous.version) {
|
|
118
|
+
violations.push(
|
|
119
|
+
`product version moved backward from ${previous.version} to ${next.version}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let semanticChange = false;
|
|
124
|
+
const previousSubjects = new Map(
|
|
125
|
+
previous.subjects.map((subject) => [subject.kind, subject] as const),
|
|
126
|
+
);
|
|
127
|
+
const nextSubjects = new Map(
|
|
128
|
+
next.subjects.map((subject) => [subject.kind, subject] as const),
|
|
129
|
+
);
|
|
130
|
+
for (const [kind, subject] of previousSubjects) {
|
|
131
|
+
const current = nextSubjects.get(kind);
|
|
132
|
+
if (!current) {
|
|
133
|
+
semanticChange = true;
|
|
134
|
+
violations.push(`subject kind ${kind} was removed or renamed`);
|
|
135
|
+
} else if (stableStringify(subject) !== stableStringify(current)) {
|
|
136
|
+
semanticChange = true;
|
|
137
|
+
violations.push(`subject kind ${kind} changed after becoming live`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const kind of nextSubjects.keys()) {
|
|
141
|
+
if (!previousSubjects.has(kind)) semanticChange = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const previousNouns = new Map(
|
|
145
|
+
previous.nouns.map((noun) => [noun.id, snapshotUdlNoun(noun)] as const),
|
|
146
|
+
);
|
|
147
|
+
const nextNouns = new Map(
|
|
148
|
+
next.nouns.map((noun) => [noun.id, snapshotUdlNoun(noun)] as const),
|
|
149
|
+
);
|
|
150
|
+
for (const [id, noun] of previousNouns) {
|
|
151
|
+
const current = nextNouns.get(id);
|
|
152
|
+
if (!current) {
|
|
153
|
+
semanticChange = true;
|
|
154
|
+
violations.push(`${id}: live noun was removed from the product`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (stableStringify(noun) !== stableStringify(current)) {
|
|
158
|
+
semanticChange = true;
|
|
159
|
+
}
|
|
160
|
+
violations.push(...diffNounEvolution(noun, current));
|
|
161
|
+
}
|
|
162
|
+
for (const id of nextNouns.keys()) {
|
|
163
|
+
if (!previousNouns.has(id)) semanticChange = true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (semanticChange && next.version <= previous.version) {
|
|
167
|
+
violations.push(
|
|
168
|
+
`product definition changed without increasing version ${previous.version}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return violations;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Diff one noun snapshot. An empty result means the change is additive. */
|
|
175
|
+
export function diffNounEvolution(
|
|
176
|
+
previous: NounEvolutionSnapshot,
|
|
177
|
+
next: NounEvolutionSnapshot,
|
|
178
|
+
): readonly string[] {
|
|
179
|
+
if (previous.id !== next.id) {
|
|
180
|
+
return [`noun id changed from ${previous.id} to ${next.id}`];
|
|
181
|
+
}
|
|
182
|
+
const violations: string[] = [];
|
|
183
|
+
if (previous.idPrefix !== next.idPrefix) {
|
|
184
|
+
violations.push(
|
|
185
|
+
`instance id prefix changed from ${previous.idPrefix} to ${next.idPrefix}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (previous.initial !== next.initial) {
|
|
189
|
+
violations.push(
|
|
190
|
+
`initial lifecycle state changed from ${previous.initial} to ${next.initial}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
violations.push(
|
|
195
|
+
...removedSetEntries(
|
|
196
|
+
previous.states,
|
|
197
|
+
next.states,
|
|
198
|
+
(state) => `lifecycle state ${state} was removed or renamed`,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
for (const [verb, transition] of Object.entries(previous.transitions)) {
|
|
202
|
+
const current = next.transitions[verb];
|
|
203
|
+
if (!current) {
|
|
204
|
+
violations.push(`transition for verb ${verb} was removed or renamed`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (transition.to !== current.to) {
|
|
208
|
+
violations.push(
|
|
209
|
+
`transition for verb ${verb} changed its target state from ${transition.to} to ${current.to}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
violations.push(
|
|
213
|
+
...removedSetEntries(
|
|
214
|
+
transition.from,
|
|
215
|
+
current.from,
|
|
216
|
+
(state) =>
|
|
217
|
+
`transition for verb ${verb} no longer fires from state ${state}`,
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
violations.push(...diffFields(previous.fields, next.fields));
|
|
223
|
+
if (Boolean(previous.distinctParties) !== Boolean(next.distinctParties)) {
|
|
224
|
+
violations.push("distinct-party admission changed after becoming live");
|
|
225
|
+
}
|
|
226
|
+
violations.push(...diffVerbs(previous.verbs, next.verbs));
|
|
227
|
+
violations.push(...diffParties(previous.parties, next.parties));
|
|
228
|
+
violations.push(...diffAggregates(previous, next));
|
|
229
|
+
violations.push(
|
|
230
|
+
...removedSetEntries(
|
|
231
|
+
previous.partitions,
|
|
232
|
+
next.partitions,
|
|
233
|
+
(law) => `partition law [${law}] was removed`,
|
|
234
|
+
),
|
|
235
|
+
...next.partitions
|
|
236
|
+
.filter((law) => !previous.partitions.includes(law))
|
|
237
|
+
.map(
|
|
238
|
+
(law) =>
|
|
239
|
+
`partition law [${law}] was added, which can reject existing callers`,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
violations.push(...diffUnwind(previous.unwind, next.unwind));
|
|
243
|
+
violations.push(
|
|
244
|
+
...removedSetEntries(
|
|
245
|
+
previous.subjects,
|
|
246
|
+
next.subjects,
|
|
247
|
+
(kind) => `subject kind ${kind} was removed, rejecting linked instances`,
|
|
248
|
+
),
|
|
249
|
+
...removedSetEntries(
|
|
250
|
+
previous.updateFields,
|
|
251
|
+
next.updateFields,
|
|
252
|
+
(field) => `update policy no longer permits field ${field}`,
|
|
253
|
+
),
|
|
254
|
+
...removedSetEntries(
|
|
255
|
+
previous.updateStates,
|
|
256
|
+
next.updateStates,
|
|
257
|
+
(state) => `update policy no longer permits state ${state}`,
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
return violations.map((violation) => `${next.id}: ${violation}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function snapshotUdlVerb(definition: UdlVerb): EvolutionVerbSnapshot {
|
|
264
|
+
return {
|
|
265
|
+
deadline: definition.deadline ?? null,
|
|
266
|
+
decision: definition.decision ?? null,
|
|
267
|
+
due: definition.due ?? null,
|
|
268
|
+
earnable: definition.earnable ?? false,
|
|
269
|
+
eventName: definition.eventName ?? null,
|
|
270
|
+
input: definition.input ? snapshotJsonSchemaFields(definition.input) : null,
|
|
271
|
+
inputConstraints: definition.input
|
|
272
|
+
? snapshotJsonSchemaConstraints(definition.input)
|
|
273
|
+
: null,
|
|
274
|
+
port: definition.port ?? null,
|
|
275
|
+
setsAt: definition.setsAt ?? null,
|
|
276
|
+
moves: definition.moves.map((move) => ({
|
|
277
|
+
bind: move.bind,
|
|
278
|
+
capture: move.capture ?? {},
|
|
279
|
+
key: move.key,
|
|
280
|
+
operation: move.operation,
|
|
281
|
+
})),
|
|
282
|
+
requiresAggregate: definition.requiresAggregate ?? [],
|
|
283
|
+
requiresDrainedAccount: definition.requiresDrainedAccount ?? null,
|
|
284
|
+
requiresRefs: definition.requiresRefs ?? [],
|
|
285
|
+
steps: definition.steps.map((step) => ({
|
|
286
|
+
bind: step.bind,
|
|
287
|
+
capture: step.capture ?? {},
|
|
288
|
+
operation: step.operation,
|
|
289
|
+
})),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function snapshotJsonSchemaFields(
|
|
294
|
+
schema: Readonly<Record<string, unknown>>,
|
|
295
|
+
): Readonly<Record<string, EvolutionFieldSnapshot>> {
|
|
296
|
+
const properties = recordValue(schema.properties);
|
|
297
|
+
const required = new Set(
|
|
298
|
+
Array.isArray(schema.required)
|
|
299
|
+
? schema.required.filter(
|
|
300
|
+
(value): value is string => typeof value === "string",
|
|
301
|
+
)
|
|
302
|
+
: [],
|
|
303
|
+
);
|
|
304
|
+
return Object.fromEntries(
|
|
305
|
+
Object.entries(properties).map(([field, fieldSchema]) => [
|
|
306
|
+
field,
|
|
307
|
+
{ required: required.has(field), schema: fieldSchema },
|
|
308
|
+
]),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function snapshotJsonSchemaConstraints(
|
|
313
|
+
schema: Readonly<Record<string, unknown>>,
|
|
314
|
+
): Readonly<Record<string, unknown>> {
|
|
315
|
+
const {
|
|
316
|
+
properties: _properties,
|
|
317
|
+
required: _required,
|
|
318
|
+
...constraints
|
|
319
|
+
} = schema;
|
|
320
|
+
return constraints;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function diffFields(
|
|
324
|
+
previous: Readonly<Record<string, EvolutionFieldSnapshot>>,
|
|
325
|
+
next: Readonly<Record<string, EvolutionFieldSnapshot>>,
|
|
326
|
+
): string[] {
|
|
327
|
+
const violations: string[] = [];
|
|
328
|
+
for (const [field, descriptor] of Object.entries(previous)) {
|
|
329
|
+
const current = next[field];
|
|
330
|
+
if (!current) {
|
|
331
|
+
violations.push(`field ${field} was removed or renamed`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (!descriptor.required && current.required) {
|
|
335
|
+
violations.push(`field ${field} became required, tightening the schema`);
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
stableStringify(descriptor.schema) !== stableStringify(current.schema)
|
|
339
|
+
) {
|
|
340
|
+
violations.push(
|
|
341
|
+
`field ${field} schema changed; a live field schema is frozen`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const [field, descriptor] of Object.entries(next)) {
|
|
346
|
+
if (!previous[field] && descriptor.required) {
|
|
347
|
+
violations.push(
|
|
348
|
+
`field ${field} was added as required, which rejects existing instances (only optional fields are additive)`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return violations;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function diffVerbs(
|
|
356
|
+
previous: Readonly<Record<string, EvolutionVerbSnapshot>>,
|
|
357
|
+
next: Readonly<Record<string, EvolutionVerbSnapshot>>,
|
|
358
|
+
): string[] {
|
|
359
|
+
const violations: string[] = [];
|
|
360
|
+
for (const [verb, descriptor] of Object.entries(previous)) {
|
|
361
|
+
const current = next[verb];
|
|
362
|
+
if (!current) {
|
|
363
|
+
violations.push(`verb ${verb} was removed or renamed`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (descriptor.moves.length !== current.moves.length) {
|
|
367
|
+
violations.push(
|
|
368
|
+
`verb ${verb} changed its move count from ${descriptor.moves.length} to ${current.moves.length}; money movement is frozen once live`,
|
|
369
|
+
);
|
|
370
|
+
} else {
|
|
371
|
+
descriptor.moves.forEach((move, index) => {
|
|
372
|
+
const nextMove = current.moves[index] as EvolutionMoveSnapshot;
|
|
373
|
+
if (stableStringify(move) !== stableStringify(nextMove)) {
|
|
374
|
+
violations.push(
|
|
375
|
+
`verb ${verb} changed move ${move.key} at index ${index}; money movement is frozen once live`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (descriptor.steps.length !== current.steps.length) {
|
|
381
|
+
violations.push(
|
|
382
|
+
`verb ${verb} changed its step count from ${descriptor.steps.length} to ${current.steps.length}; a live verb's steps are frozen`,
|
|
383
|
+
);
|
|
384
|
+
} else {
|
|
385
|
+
descriptor.steps.forEach((step, index) => {
|
|
386
|
+
const nextStep = current.steps[index] as EvolutionStepSnapshot;
|
|
387
|
+
if (stableStringify(step) === stableStringify(nextStep)) return;
|
|
388
|
+
violations.push(
|
|
389
|
+
`verb ${verb} changed step ${index} (${step.operation}); a live verb's steps are frozen`,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
violations.push(
|
|
394
|
+
...diffInput(verb, descriptor.input ?? {}, current.input ?? {}),
|
|
395
|
+
);
|
|
396
|
+
// A verb gaining its first input is additive (per-field checks still catch
|
|
397
|
+
// added-as-required); once an input is live, its schema envelope is frozen.
|
|
398
|
+
if (
|
|
399
|
+
descriptor.input !== null &&
|
|
400
|
+
stableStringify(descriptor.inputConstraints) !==
|
|
401
|
+
stableStringify(current.inputConstraints)
|
|
402
|
+
) {
|
|
403
|
+
violations.push(
|
|
404
|
+
`verb ${verb} changed its input schema beyond declared fields; a live verb input is frozen`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (
|
|
408
|
+
stableStringify(descriptor.requiresRefs) !==
|
|
409
|
+
stableStringify(current.requiresRefs)
|
|
410
|
+
) {
|
|
411
|
+
violations.push(`verb ${verb} changed its cross-noun gates`);
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
stableStringify(descriptor.requiresAggregate) !==
|
|
415
|
+
stableStringify(current.requiresAggregate)
|
|
416
|
+
) {
|
|
417
|
+
violations.push(`verb ${verb} changed its aggregate admission gates`);
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
stableStringify(descriptor.requiresDrainedAccount) !==
|
|
421
|
+
stableStringify(current.requiresDrainedAccount)
|
|
422
|
+
) {
|
|
423
|
+
violations.push(`verb ${verb} changed its drained-account gate`);
|
|
424
|
+
}
|
|
425
|
+
if (descriptor.earnable !== current.earnable) {
|
|
426
|
+
violations.push(`verb ${verb} changed its earnable flag`);
|
|
427
|
+
}
|
|
428
|
+
if (descriptor.eventName !== current.eventName) {
|
|
429
|
+
violations.push(`verb ${verb} changed its event name`);
|
|
430
|
+
}
|
|
431
|
+
if (stableStringify(descriptor.due) !== stableStringify(current.due)) {
|
|
432
|
+
violations.push(`verb ${verb} changed its due condition`);
|
|
433
|
+
}
|
|
434
|
+
if (
|
|
435
|
+
stableStringify(descriptor.deadline) !== stableStringify(current.deadline)
|
|
436
|
+
) {
|
|
437
|
+
violations.push(`verb ${verb} changed its admission deadline`);
|
|
438
|
+
}
|
|
439
|
+
if (
|
|
440
|
+
stableStringify(descriptor.decision) !== stableStringify(current.decision)
|
|
441
|
+
) {
|
|
442
|
+
violations.push(`verb ${verb} changed its provider decision`);
|
|
443
|
+
}
|
|
444
|
+
if (stableStringify(descriptor.port) !== stableStringify(current.port)) {
|
|
445
|
+
violations.push(`verb ${verb} changed its decision port`);
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
stableStringify(descriptor.setsAt) !== stableStringify(current.setsAt)
|
|
449
|
+
) {
|
|
450
|
+
violations.push(`verb ${verb} changed its computed timestamp`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return violations;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function diffInput(
|
|
457
|
+
verb: string,
|
|
458
|
+
previous: Readonly<Record<string, EvolutionFieldSnapshot>>,
|
|
459
|
+
next: Readonly<Record<string, EvolutionFieldSnapshot>>,
|
|
460
|
+
): string[] {
|
|
461
|
+
const violations: string[] = [];
|
|
462
|
+
for (const [field, descriptor] of Object.entries(previous)) {
|
|
463
|
+
const current = next[field];
|
|
464
|
+
if (!current) {
|
|
465
|
+
// Compiled verb inputs are strict objects, so a removed field starts
|
|
466
|
+
// rejecting every caller that still sends it -- same law as noun fields.
|
|
467
|
+
violations.push(
|
|
468
|
+
`verb ${verb} input field ${field} was removed or renamed`,
|
|
469
|
+
);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (!descriptor.required && current.required) {
|
|
473
|
+
violations.push(
|
|
474
|
+
`verb ${verb} input field ${field} became required, tightening the verb input`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (
|
|
478
|
+
stableStringify(descriptor.schema) !== stableStringify(current.schema)
|
|
479
|
+
) {
|
|
480
|
+
violations.push(
|
|
481
|
+
`verb ${verb} input field ${field} schema changed; a live verb input schema is frozen`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const [field, descriptor] of Object.entries(next)) {
|
|
486
|
+
if (!previous[field] && descriptor.required) {
|
|
487
|
+
violations.push(
|
|
488
|
+
`verb ${verb} input field ${field} was added as required, tightening the verb input`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return violations;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function diffParties(
|
|
496
|
+
previous: Readonly<Record<string, string>>,
|
|
497
|
+
next: Readonly<Record<string, string>>,
|
|
498
|
+
): string[] {
|
|
499
|
+
const violations: string[] = [];
|
|
500
|
+
for (const [role, field] of Object.entries(previous)) {
|
|
501
|
+
const current = next[role];
|
|
502
|
+
if (!current) violations.push(`party role ${role} was removed or renamed`);
|
|
503
|
+
else if (current !== field) {
|
|
504
|
+
violations.push(
|
|
505
|
+
`party role ${role} moved from field ${field} to ${current}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return violations;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function diffAggregates(
|
|
513
|
+
previous: NounEvolutionSnapshot,
|
|
514
|
+
next: NounEvolutionSnapshot,
|
|
515
|
+
): string[] {
|
|
516
|
+
return [
|
|
517
|
+
...removedSetEntries(
|
|
518
|
+
previous.aggregateInvariants,
|
|
519
|
+
next.aggregateInvariants,
|
|
520
|
+
(key) => `aggregate invariant [${key}] was removed`,
|
|
521
|
+
),
|
|
522
|
+
...next.aggregateInvariants
|
|
523
|
+
.filter((key) => !previous.aggregateInvariants.includes(key))
|
|
524
|
+
.map(
|
|
525
|
+
(key) =>
|
|
526
|
+
`aggregate invariant [${key}] was added, which can reject existing instances`,
|
|
527
|
+
),
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function diffUnwind(previous: unknown, next: unknown): string[] {
|
|
532
|
+
if (previous === null) return [];
|
|
533
|
+
if (next === null) return ["unwind policy was removed"];
|
|
534
|
+
return stableStringify(previous) === stableStringify(next)
|
|
535
|
+
? []
|
|
536
|
+
: [
|
|
537
|
+
"unwind policy changed; the penalty schedule and refund destination are frozen once live",
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function aggregateInvariantKey(invariant: UdlAggregate): string {
|
|
542
|
+
const measure =
|
|
543
|
+
"childField" in invariant
|
|
544
|
+
? invariant.childField
|
|
545
|
+
: `count${invariant.window ? `[${invariant.window.field} per ${invariant.window.days}d]` : ""}`;
|
|
546
|
+
return `${invariant.childNounId}.${measure} within ${invariant.parentField} via ${invariant.childRefField} while ${[...invariant.childStatuses].sort().join(",")}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function removedSetEntries(
|
|
550
|
+
previous: readonly string[],
|
|
551
|
+
next: readonly string[],
|
|
552
|
+
message: (entry: string) => string,
|
|
553
|
+
): string[] {
|
|
554
|
+
const current = new Set(next);
|
|
555
|
+
return previous.filter((entry) => !current.has(entry)).map(message);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function compactStringRecord(
|
|
559
|
+
value: Readonly<Record<string, string | undefined>> | undefined,
|
|
560
|
+
): Readonly<Record<string, string>> {
|
|
561
|
+
return Object.fromEntries(
|
|
562
|
+
Object.entries(value ?? {}).filter(
|
|
563
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
564
|
+
),
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function recordValue(value: unknown): Readonly<Record<string, unknown>> {
|
|
569
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
570
|
+
? (value as Readonly<Record<string, unknown>>)
|
|
571
|
+
: {};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function stableStringify(value: unknown): string {
|
|
575
|
+
if (value === null || typeof value !== "object") {
|
|
576
|
+
return JSON.stringify(value) ?? "null";
|
|
577
|
+
}
|
|
578
|
+
if (Array.isArray(value)) {
|
|
579
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
580
|
+
}
|
|
581
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
582
|
+
.filter(([, entry]) => entry !== undefined)
|
|
583
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
584
|
+
return `{${entries
|
|
585
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
|
|
586
|
+
.join(",")}}`;
|
|
587
|
+
}
|