@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.
Files changed (97) hide show
  1. package/AUTHORS +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/LICENSE +661 -0
  4. package/LICENSING.md +51 -0
  5. package/README.md +163 -0
  6. package/SECURITY.md +42 -0
  7. package/TRADEMARKS.md +35 -0
  8. package/conformance/README.md +79 -0
  9. package/conformance/invalid/blank-title.expected.json +10 -0
  10. package/conformance/invalid/blank-title.udl +50 -0
  11. package/conformance/invalid/depth-budget.expected.json +10 -0
  12. package/conformance/invalid/depth-budget.udl +49 -0
  13. package/conformance/invalid/format-version.expected.json +10 -0
  14. package/conformance/invalid/format-version.udl +50 -0
  15. package/conformance/invalid/invalid-utf8.expected.json +10 -0
  16. package/conformance/invalid/invalid-utf8.udl +1 -0
  17. package/conformance/invalid/malformed-json.expected.json +10 -0
  18. package/conformance/invalid/malformed-json.udl +1 -0
  19. package/conformance/invalid/missing-create-verb.expected.json +10 -0
  20. package/conformance/invalid/missing-create-verb.udl +45 -0
  21. package/conformance/invalid/not-an-object.expected.json +10 -0
  22. package/conformance/invalid/not-an-object.udl +1 -0
  23. package/conformance/invalid/noun-id-not-snake-case.expected.json +10 -0
  24. package/conformance/invalid/noun-id-not-snake-case.udl +50 -0
  25. package/conformance/invalid/unknown-key.expected.json +10 -0
  26. package/conformance/invalid/unknown-key.udl +51 -0
  27. package/conformance/invalid/unreachable-state.expected.json +10 -0
  28. package/conformance/invalid/unreachable-state.udl +51 -0
  29. package/conformance/invalid/verb-without-transition.expected.json +10 -0
  30. package/conformance/invalid/verb-without-transition.udl +55 -0
  31. package/conformance/valid/cards.expected.json +5 -0
  32. package/conformance/valid/cards.udl +1092 -0
  33. package/conformance/valid/commerce-escrow.expected.json +5 -0
  34. package/conformance/valid/commerce-escrow.udl +1011 -0
  35. package/conformance/valid/hand-edited.expected.json +5 -0
  36. package/conformance/valid/hand-edited.udl +1 -0
  37. package/conformance/valid/insured-car-marketplace.expected.json +5 -0
  38. package/conformance/valid/insured-car-marketplace.udl +831 -0
  39. package/conformance/valid/insured-travel.expected.json +5 -0
  40. package/conformance/valid/insured-travel.udl +2621 -0
  41. package/conformance/valid/minimal.expected.json +5 -0
  42. package/conformance/valid/minimal.udl +50 -0
  43. package/conformance/valid/protection.expected.json +5 -0
  44. package/conformance/valid/protection.udl +1136 -0
  45. package/dist/canonical.d.ts +2 -0
  46. package/dist/canonical.d.ts.map +1 -0
  47. package/dist/canonical.js +35 -0
  48. package/dist/canonical.js.map +1 -0
  49. package/dist/cli.d.ts +3 -0
  50. package/dist/cli.d.ts.map +1 -0
  51. package/dist/cli.js +138 -0
  52. package/dist/cli.js.map +1 -0
  53. package/dist/duration.d.ts +3 -0
  54. package/dist/duration.d.ts.map +1 -0
  55. package/dist/duration.js +23 -0
  56. package/dist/duration.js.map +1 -0
  57. package/dist/evolution.d.ts +63 -0
  58. package/dist/evolution.d.ts.map +1 -0
  59. package/dist/evolution.js +345 -0
  60. package/dist/evolution.js.map +1 -0
  61. package/dist/finance.d.ts +53 -0
  62. package/dist/finance.d.ts.map +1 -0
  63. package/dist/finance.js +511 -0
  64. package/dist/finance.js.map +1 -0
  65. package/dist/index.d.ts +11 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +8 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/limits.d.ts +33 -0
  70. package/dist/limits.d.ts.map +1 -0
  71. package/dist/limits.js +33 -0
  72. package/dist/limits.js.map +1 -0
  73. package/dist/parser.d.ts +4 -0
  74. package/dist/parser.d.ts.map +1 -0
  75. package/dist/parser.js +54 -0
  76. package/dist/parser.js.map +1 -0
  77. package/dist/schema.d.ts +757 -0
  78. package/dist/schema.d.ts.map +1 -0
  79. package/dist/schema.js +281 -0
  80. package/dist/schema.js.map +1 -0
  81. package/dist/validation.d.ts +64 -0
  82. package/dist/validation.d.ts.map +1 -0
  83. package/dist/validation.js +1323 -0
  84. package/dist/validation.js.map +1 -0
  85. package/package.json +84 -0
  86. package/spec/README.md +137 -0
  87. package/spec/udl.schema.json +1019 -0
  88. package/src/canonical.ts +41 -0
  89. package/src/cli.ts +148 -0
  90. package/src/duration.ts +26 -0
  91. package/src/evolution.ts +587 -0
  92. package/src/finance.ts +775 -0
  93. package/src/index.ts +59 -0
  94. package/src/limits.ts +32 -0
  95. package/src/parser.ts +61 -0
  96. package/src/schema.ts +339 -0
  97. package/src/validation.ts +2053 -0
package/src/finance.ts ADDED
@@ -0,0 +1,775 @@
1
+ import type { UdlBinding } from "./schema.js";
2
+ import { UDL_LIMITS } from "./limits.js";
3
+
4
+ export interface FinanceIssue {
5
+ readonly message: string;
6
+ readonly path: readonly PropertyKey[];
7
+ }
8
+
9
+ export interface FinanceOptions {
10
+ readonly penaltyMayBeNonzero?: boolean;
11
+ }
12
+
13
+ interface FinancialStep {
14
+ readonly operation: string;
15
+ readonly bind: Readonly<Record<string, UdlBinding>>;
16
+ readonly capture?: Readonly<Record<string, string>> | undefined;
17
+ }
18
+
19
+ interface FinancialMove extends FinancialStep {
20
+ readonly key: string;
21
+ }
22
+
23
+ interface FinancialNoun {
24
+ readonly lifecycle: {
25
+ readonly initial: string;
26
+ readonly states: readonly string[];
27
+ readonly transitions: Readonly<
28
+ Record<string, { readonly from: readonly string[]; readonly to: string }>
29
+ >;
30
+ };
31
+ readonly parties?:
32
+ | {
33
+ readonly beneficiary?: string | undefined;
34
+ readonly payer?: string | undefined;
35
+ readonly subjectHolder?: string | undefined;
36
+ }
37
+ | undefined;
38
+ readonly unwind?:
39
+ | {
40
+ readonly confirm: string;
41
+ readonly penalty: readonly {
42
+ readonly bps: number;
43
+ }[];
44
+ readonly refundableField: string;
45
+ }
46
+ | undefined;
47
+ readonly verbs: Readonly<
48
+ Record<
49
+ string,
50
+ {
51
+ readonly moves?: readonly FinancialMove[];
52
+ readonly steps: readonly FinancialStep[];
53
+ }
54
+ >
55
+ >;
56
+ }
57
+
58
+ type Balance =
59
+ | { readonly kind: "empty" }
60
+ | { readonly kind: "funded"; readonly amounts: readonly string[] }
61
+ | { readonly kind: "penalty" }
62
+ | { readonly kind: "unknown" };
63
+
64
+ interface AccountState {
65
+ readonly balance: Balance;
66
+ readonly holds: Readonly<Record<string, Balance>>;
67
+ }
68
+
69
+ interface Reservation {
70
+ readonly amount: string;
71
+ readonly destination?: string;
72
+ readonly source?: string;
73
+ }
74
+
75
+ interface Effect {
76
+ readonly amount: string;
77
+ readonly kind:
78
+ | "credit"
79
+ | "debit"
80
+ | "dynamic_debit"
81
+ | "incoming_reserve"
82
+ | "outgoing_reserve"
83
+ | "penalty_debit"
84
+ | "post"
85
+ | "refund"
86
+ | "void";
87
+ readonly path: readonly PropertyKey[];
88
+ readonly reservation?: string;
89
+ }
90
+
91
+ const EMPTY: Balance = { kind: "empty" };
92
+
93
+ const UNKNOWN: Balance = { kind: "unknown" };
94
+
95
+ // A funded balance is the exact set of amount identities currently held.
96
+ // Distinct identities stack (a partitioned funding); re-crediting an identity
97
+ // already present degrades to unknown, which keeps the state space finite.
98
+ function funded(amounts: readonly string[]): Balance {
99
+ if (amounts.length === 0) return EMPTY;
100
+ return { kind: "funded", amounts: [...amounts].sort() };
101
+ }
102
+
103
+ function withoutPiece(
104
+ amounts: readonly string[],
105
+ amount: string,
106
+ ): readonly string[] {
107
+ const index = amounts.indexOf(amount);
108
+ return amounts.filter((_, position) => position !== index);
109
+ }
110
+
111
+ /**
112
+ * One financial typestate owner for canonical UDL and static noun contracts.
113
+ * It proves a tracked escrow account holds an exact set of funded amounts,
114
+ * models held transfers through reserve/post/void, consumes every direct
115
+ * debit piece by piece, and makes the unwind refund's sole remainder an
116
+ * exactly-once penalty payout.
117
+ */
118
+ export function analyzeNounFinance(
119
+ noun: FinancialNoun,
120
+ options: FinanceOptions = {},
121
+ ): readonly FinanceIssue[] {
122
+ const issues: FinanceIssue[] = [];
123
+ const seenIssues = new Set<string>();
124
+ const add = (path: readonly PropertyKey[], message: string): void => {
125
+ const key = `${path.join(".")}\0${message}`;
126
+ if (seenIssues.has(key)) return;
127
+ seenIssues.add(key);
128
+ issues.push({ message, path });
129
+ };
130
+
131
+ const admissionProblem = financeAdmissionProblem(noun);
132
+ if (admissionProblem) {
133
+ add(["lifecycle"], admissionProblem);
134
+ return issues;
135
+ }
136
+
137
+ const trackedAccounts = workspaceEscrowAccounts(noun);
138
+ const confirmTransfer = noun.unwind
139
+ ? noun.verbs[noun.unwind.confirm]?.moves?.filter(
140
+ (move) => move.operation === "internal_transfer.create",
141
+ )[0]
142
+ : undefined;
143
+ const refundSource = canonicalAccount(
144
+ noun,
145
+ confirmTransfer?.bind.sourceAccountId,
146
+ );
147
+ if (refundSource) trackedAccounts.add(refundSource);
148
+ if (trackedAccounts.size === 0) return [];
149
+ if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
150
+ add(
151
+ ["verbs"],
152
+ `financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`,
153
+ );
154
+ return issues;
155
+ }
156
+
157
+ const reservations = reservationsByKey(noun);
158
+ const declaresPenaltyPayout = Object.values(noun.verbs).some((verb) =>
159
+ (verb.moves ?? []).some((move) =>
160
+ Object.values(move.bind).some(
161
+ (binding) =>
162
+ binding.from === "instance" && binding.path === "refs.unwindPenalty",
163
+ ),
164
+ ),
165
+ );
166
+ const penaltyCanBeNonzero =
167
+ options.penaltyMayBeNonzero === true ||
168
+ declaresPenaltyPayout ||
169
+ noun.unwind?.penalty.some((tier) => tier.bps > 0) === true;
170
+
171
+ if (noun.unwind) {
172
+ validatePenaltyPayout(noun, refundSource, penaltyCanBeNonzero, add);
173
+ }
174
+
175
+ let pathVariants = 0;
176
+ let work = 0;
177
+ for (const account of trackedAccounts) {
178
+ const effects = effectsByVerb(
179
+ noun,
180
+ account,
181
+ reservations,
182
+ penaltyCanBeNonzero,
183
+ );
184
+ const createEffects = effects.get("create") ?? [];
185
+ work += 1 + createEffects.length;
186
+ if (work > UDL_LIMITS.financeWork) {
187
+ add(
188
+ ["lifecycle"],
189
+ `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
190
+ );
191
+ return issues;
192
+ }
193
+ const initial = applyEffects(
194
+ noun,
195
+ account,
196
+ "create",
197
+ { balance: EMPTY, holds: {} },
198
+ createEffects,
199
+ reservations,
200
+ account === refundSource,
201
+ add,
202
+ );
203
+ const states = new Map<string, Map<string, AccountState>>([
204
+ [noun.lifecycle.initial, new Map([[stateKey(initial), initial]])],
205
+ ]);
206
+ pathVariants += 1;
207
+ const pending = [noun.lifecycle.initial];
208
+ while (pending.length > 0) {
209
+ const from = pending.shift() as string;
210
+ const sourceStates = states.get(from);
211
+ if (!sourceStates) continue;
212
+ for (const [verb, transition] of Object.entries(
213
+ noun.lifecycle.transitions,
214
+ )) {
215
+ if (!transition.from.includes(from)) continue;
216
+ const targetStates = states.get(transition.to) ?? new Map();
217
+ states.set(transition.to, targetStates);
218
+ let grew = false;
219
+ for (const sourceState of sourceStates.values()) {
220
+ const transitionEffects = effects.get(verb) ?? [];
221
+ work += 1 + transitionEffects.length;
222
+ if (work > UDL_LIMITS.financeWork) {
223
+ add(
224
+ ["lifecycle"],
225
+ `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
226
+ );
227
+ return issues;
228
+ }
229
+ const targetState = applyEffects(
230
+ noun,
231
+ account,
232
+ verb,
233
+ sourceState,
234
+ transitionEffects,
235
+ reservations,
236
+ account === refundSource,
237
+ add,
238
+ );
239
+ const key = stateKey(targetState);
240
+ if (targetStates.has(key)) continue;
241
+ targetStates.set(key, targetState);
242
+ pathVariants += 1;
243
+ if (pathVariants > UDL_LIMITS.financePathVariants) {
244
+ add(
245
+ ["lifecycle"],
246
+ `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`,
247
+ );
248
+ return issues;
249
+ }
250
+ grew = true;
251
+ }
252
+ if (grew && !pending.includes(transition.to))
253
+ pending.push(transition.to);
254
+ }
255
+ }
256
+ const nonterminalStates = new Set(
257
+ Object.values(noun.lifecycle.transitions).flatMap(
258
+ (transition) => transition.from,
259
+ ),
260
+ );
261
+ for (const [state, variants] of states) {
262
+ if (nonterminalStates.has(state)) continue;
263
+ for (const variant of variants.values()) {
264
+ if (
265
+ variant.balance.kind === "empty" &&
266
+ Object.keys(variant.holds).length === 0
267
+ ) {
268
+ continue;
269
+ }
270
+ add(
271
+ ["lifecycle", "states", noun.lifecycle.states.indexOf(state)],
272
+ `terminal state ${state} can strand value in ${formatAccount(account)}`,
273
+ );
274
+ }
275
+ }
276
+ }
277
+
278
+ return issues;
279
+ }
280
+
281
+ export function financeAdmissionProblem(
282
+ noun: FinancialNoun,
283
+ ): string | undefined {
284
+ if (noun.lifecycle.states.length > UDL_LIMITS.financeStates) {
285
+ return `financial analysis exceeds ${UDL_LIMITS.financeStates} lifecycle states`;
286
+ }
287
+ let transitionCount = 0;
288
+ let transitionEdges = 0;
289
+ for (const name in noun.lifecycle.transitions) {
290
+ if (!Object.hasOwn(noun.lifecycle.transitions, name)) continue;
291
+ transitionCount += 1;
292
+ if (transitionCount > UDL_LIMITS.financeTransitions) {
293
+ return `financial analysis exceeds ${UDL_LIMITS.financeTransitions} lifecycle transitions`;
294
+ }
295
+ transitionEdges += noun.lifecycle.transitions[name]?.from.length ?? 0;
296
+ if (transitionEdges > UDL_LIMITS.financeTransitionEdges) {
297
+ return `financial analysis exceeds ${UDL_LIMITS.financeTransitionEdges} lifecycle transition edges`;
298
+ }
299
+ }
300
+ let verbCount = 0;
301
+ let effectCount = 0;
302
+ for (const name in noun.verbs) {
303
+ if (!Object.hasOwn(noun.verbs, name)) continue;
304
+ verbCount += 1;
305
+ if (verbCount > UDL_LIMITS.financeVerbs) {
306
+ return `financial analysis exceeds ${UDL_LIMITS.financeVerbs} verbs`;
307
+ }
308
+ const verb = noun.verbs[name];
309
+ effectCount += (verb?.steps.length ?? 0) + (verb?.moves?.length ?? 0);
310
+ if (effectCount > UDL_LIMITS.financeEffects) {
311
+ return `financial analysis exceeds ${UDL_LIMITS.financeEffects} kernel effects`;
312
+ }
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ function workspaceEscrowAccounts(noun: FinancialNoun): Set<string> {
318
+ const accounts = new Set<string>();
319
+ for (const verb of Object.values(noun.verbs)) {
320
+ for (const step of verb.steps) {
321
+ if (
322
+ step.operation !== "account.escrow.provision" ||
323
+ step.bind.role?.from !== "const" ||
324
+ step.bind.role.value !== "workspace_escrow"
325
+ ) {
326
+ continue;
327
+ }
328
+ for (const [key, result] of Object.entries(step.capture ?? {})) {
329
+ if (result === "accountId") accounts.add(`ref:${key}`);
330
+ }
331
+ }
332
+ }
333
+
334
+ // `workspace_escrow` also backs open-ended balance products such as wallets.
335
+ // Only instance-bound amounts claim static conservation; caller-sized flows
336
+ // remain runtime balance checks. Unwind sources are added separately above.
337
+ return new Set(
338
+ [...accounts].filter((account) =>
339
+ Object.values(noun.verbs).some((verb) =>
340
+ (verb.moves ?? []).some((step) => {
341
+ if (
342
+ (step.operation !== "internal_transfer.create" &&
343
+ step.operation !== "internal_transfer.reserve") ||
344
+ step.bind.amount?.from !== "instance"
345
+ ) {
346
+ return false;
347
+ }
348
+ return (
349
+ canonicalAccount(noun, step.bind.sourceAccountId) === account ||
350
+ canonicalAccount(noun, step.bind.destinationAccountId) === account
351
+ );
352
+ }),
353
+ ),
354
+ ),
355
+ );
356
+ }
357
+
358
+ function reservationsByKey(
359
+ noun: FinancialNoun,
360
+ ): ReadonlyMap<string, Reservation> {
361
+ const reservations = new Map<string, Reservation>();
362
+ for (const verb of Object.values(noun.verbs)) {
363
+ for (const step of verb.moves ?? []) {
364
+ if (step.operation !== "internal_transfer.reserve") continue;
365
+ const source = canonicalAccount(noun, step.bind.sourceAccountId);
366
+ const destination = canonicalAccount(
367
+ noun,
368
+ step.bind.destinationAccountId,
369
+ );
370
+ const amount = amountIdentity(step.bind.amount);
371
+ for (const [key, result] of Object.entries(step.capture ?? {})) {
372
+ if (result === "transferId") {
373
+ reservations.set(key, {
374
+ amount,
375
+ ...(destination ? { destination } : {}),
376
+ ...(source ? { source } : {}),
377
+ });
378
+ }
379
+ }
380
+ }
381
+ }
382
+ return reservations;
383
+ }
384
+
385
+ function effectsByVerb(
386
+ noun: FinancialNoun,
387
+ account: string,
388
+ reservations: ReadonlyMap<string, Reservation>,
389
+ penaltyCanBeNonzero: boolean,
390
+ ): ReadonlyMap<string, readonly Effect[]> {
391
+ return new Map(
392
+ Object.entries(noun.verbs).map(([verbName, verb]) => [
393
+ verbName,
394
+ (verb.moves ?? []).flatMap((step, stepIndex) => {
395
+ const path = ["verbs", verbName, "moves", stepIndex, "bind"] as const;
396
+ if (step.operation === "internal_transfer.create") {
397
+ const effects: Effect[] = [];
398
+ const amount = amountIdentity(step.bind.amount);
399
+ if (
400
+ canonicalAccount(noun, step.bind.destinationAccountId) === account
401
+ ) {
402
+ effects.push({ amount, kind: "credit", path });
403
+ }
404
+ const source = canonicalAccount(noun, step.bind.sourceAccountId);
405
+ if (source && accountsMayAlias(source, account)) {
406
+ const kind =
407
+ verbName === noun.unwind?.confirm &&
408
+ step.bind.amount?.from === "instance" &&
409
+ step.bind.amount.path === "refs.unwindRefund"
410
+ ? "refund"
411
+ : step.bind.amount?.from === "instance" &&
412
+ step.bind.amount.path === "refs.unwindPenalty"
413
+ ? "penalty_debit"
414
+ : step.bind.amount?.from === "instance"
415
+ ? "debit"
416
+ : "dynamic_debit";
417
+ effects.push({
418
+ amount:
419
+ kind === "refund" && !penaltyCanBeNonzero
420
+ ? "refund_without_penalty"
421
+ : amount,
422
+ kind,
423
+ path,
424
+ });
425
+ }
426
+ return effects;
427
+ }
428
+ if (step.operation === "internal_transfer.reserve") {
429
+ const reservation =
430
+ transferRefKey(step) ?? `${verbName}:${stepIndex}`;
431
+ const effects: Effect[] = [];
432
+ const source = canonicalAccount(noun, step.bind.sourceAccountId);
433
+ if (source && accountsMayAlias(source, account)) {
434
+ effects.push({
435
+ amount: amountIdentity(step.bind.amount),
436
+ kind: "outgoing_reserve",
437
+ path,
438
+ reservation,
439
+ });
440
+ }
441
+ if (
442
+ canonicalAccount(noun, step.bind.destinationAccountId) === account
443
+ ) {
444
+ effects.push({
445
+ amount: amountIdentity(step.bind.amount),
446
+ kind: "incoming_reserve",
447
+ path,
448
+ reservation,
449
+ });
450
+ }
451
+ return effects;
452
+ }
453
+ if (
454
+ step.operation === "internal_transfer.post" ||
455
+ step.operation === "internal_transfer.void"
456
+ ) {
457
+ const reservation = boundTransferRefKey(step);
458
+ if (!reservation) return [];
459
+ const reserved = reservations.get(reservation);
460
+ if (
461
+ !reserved ||
462
+ (!(reserved.source && accountsMayAlias(reserved.source, account)) &&
463
+ reserved.destination !== account)
464
+ ) {
465
+ return [];
466
+ }
467
+ return [
468
+ {
469
+ amount: reserved.amount,
470
+ kind:
471
+ step.operation === "internal_transfer.post" ? "post" : "void",
472
+ path,
473
+ reservation,
474
+ } satisfies Effect,
475
+ ];
476
+ }
477
+ return [];
478
+ }),
479
+ ]),
480
+ );
481
+ }
482
+
483
+ function applyEffects(
484
+ noun: FinancialNoun,
485
+ account: string,
486
+ verb: string,
487
+ input: AccountState,
488
+ effects: readonly Effect[],
489
+ reservations: ReadonlyMap<string, Reservation>,
490
+ exactBalance: boolean,
491
+ add: (path: readonly PropertyKey[], message: string) => void,
492
+ ): AccountState {
493
+ let balance = input.balance;
494
+ const holds = { ...input.holds };
495
+ const accountLabel = formatAccount(account);
496
+ for (const effect of effects) {
497
+ const fail = (message: string): void =>
498
+ add([...effect.path, "sourceAccountId"], `verb ${verb} ${message}`);
499
+ if (effect.kind === "credit") {
500
+ if (
501
+ exactBalance &&
502
+ (balance.kind !== "empty" || Object.keys(holds).length > 0)
503
+ ) {
504
+ add(
505
+ [...effect.path, "destinationAccountId"],
506
+ `verb ${verb} funds ${accountLabel} while earlier value may remain; unwind funding must establish exactly one refundable balance`,
507
+ );
508
+ balance = UNKNOWN;
509
+ continue;
510
+ }
511
+ const pieces = balance.kind === "funded" ? balance.amounts : [];
512
+ const stacks =
513
+ (balance.kind === "empty" || balance.kind === "funded") &&
514
+ !pieces.includes(effect.amount);
515
+ balance = stacks ? funded([...pieces, effect.amount]) : UNKNOWN;
516
+ continue;
517
+ }
518
+ if (effect.kind === "incoming_reserve") continue;
519
+ if (effect.kind === "outgoing_reserve") {
520
+ const held =
521
+ balance.kind === "funded" && balance.amounts.includes(effect.amount);
522
+ if (!held) {
523
+ fail(
524
+ `cannot reserve ${effect.amount} from ${accountLabel}; that exact balance is not guaranteed`,
525
+ );
526
+ }
527
+ holds[effect.reservation as string] = held
528
+ ? { kind: "funded", amounts: [effect.amount] }
529
+ : UNKNOWN;
530
+ balance =
531
+ held && balance.kind === "funded"
532
+ ? funded(withoutPiece(balance.amounts, effect.amount))
533
+ : EMPTY;
534
+ continue;
535
+ }
536
+ if (effect.kind === "post" || effect.kind === "void") {
537
+ const key = effect.reservation as string;
538
+ const reservation = reservations.get(key);
539
+ if (
540
+ reservation?.source &&
541
+ accountsMayAlias(reservation.source, account)
542
+ ) {
543
+ const held = holds[key];
544
+ delete holds[key];
545
+ if (effect.kind === "void" && held) {
546
+ const pieces = balance.kind === "funded" ? balance.amounts : [];
547
+ const restorable =
548
+ held.kind === "funded" &&
549
+ (balance.kind === "empty" ||
550
+ (balance.kind === "funded" &&
551
+ held.amounts.every((piece) => !pieces.includes(piece))));
552
+ if (restorable && held.kind === "funded") {
553
+ balance = funded([...pieces, ...held.amounts]);
554
+ } else if (balance.kind === "empty") {
555
+ balance = held;
556
+ } else {
557
+ add(
558
+ [...effect.path, "transferId"],
559
+ `verb ${verb} restores a held ${accountLabel} balance on top of existing value`,
560
+ );
561
+ balance = UNKNOWN;
562
+ }
563
+ }
564
+ }
565
+ if (effect.kind === "post" && reservation?.destination === account) {
566
+ if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
567
+ add(
568
+ [...effect.path, "transferId"],
569
+ `verb ${verb} posts funding into ${accountLabel} while earlier value may remain`,
570
+ );
571
+ balance = UNKNOWN;
572
+ } else {
573
+ balance = { kind: "funded", amounts: [effect.amount] };
574
+ }
575
+ }
576
+ continue;
577
+ }
578
+ if (effect.kind === "refund") {
579
+ const refundable = `fields.${noun.unwind?.refundableField ?? ""}`;
580
+ if (
581
+ balance.kind !== "funded" ||
582
+ balance.amounts.length !== 1 ||
583
+ balance.amounts[0] !== refundable
584
+ ) {
585
+ fail(
586
+ `cannot refund ${refundable} from ${accountLabel}; that exact balance is not guaranteed`,
587
+ );
588
+ balance = UNKNOWN;
589
+ } else {
590
+ balance =
591
+ effect.amount === "refund_without_penalty"
592
+ ? EMPTY
593
+ : { kind: "penalty" };
594
+ }
595
+ continue;
596
+ }
597
+ if (effect.kind === "penalty_debit") {
598
+ if (balance.kind !== "penalty") {
599
+ fail(
600
+ `cannot pay refs.unwindPenalty from ${accountLabel} before the refund leaves that exact remainder`,
601
+ );
602
+ }
603
+ balance = EMPTY;
604
+ continue;
605
+ }
606
+ if (effect.kind === "dynamic_debit") {
607
+ if (balance.kind === "empty") {
608
+ fail(`can debit unfunded ${accountLabel}`);
609
+ } else if (balance.kind === "penalty") {
610
+ fail(
611
+ `can debit ${accountLabel} after its refund left only refs.unwindPenalty`,
612
+ );
613
+ }
614
+ balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
615
+ continue;
616
+ }
617
+ if (balance.kind === "funded" && balance.amounts.includes(effect.amount)) {
618
+ balance = funded(withoutPiece(balance.amounts, effect.amount));
619
+ continue;
620
+ }
621
+ if (balance.kind === "empty") {
622
+ fail(`can debit unfunded ${accountLabel}`);
623
+ } else if (balance.kind === "penalty") {
624
+ fail(
625
+ `can debit ${accountLabel} after its refund left only refs.unwindPenalty`,
626
+ );
627
+ } else if (balance.kind === "funded" || exactBalance) {
628
+ fail(
629
+ `cannot prove ${effect.amount} is the exact available balance of ${accountLabel}`,
630
+ );
631
+ }
632
+ balance = EMPTY;
633
+ }
634
+ return { balance, holds };
635
+ }
636
+
637
+ function validatePenaltyPayout(
638
+ noun: FinancialNoun,
639
+ refundSource: string | undefined,
640
+ required: boolean,
641
+ add: (path: readonly PropertyKey[], message: string) => void,
642
+ ): void {
643
+ const uses = Object.entries(noun.verbs).flatMap(([verbName, verb]) =>
644
+ (verb.moves ?? []).flatMap((step, stepIndex) =>
645
+ Object.entries(step.bind).flatMap(([target, binding]) =>
646
+ binding.from === "instance" && binding.path === "refs.unwindPenalty"
647
+ ? [{ binding, step, stepIndex, target, verb, verbName }]
648
+ : [],
649
+ ),
650
+ ),
651
+ );
652
+ if (required && uses.length !== 1) {
653
+ add(
654
+ ["verbs"],
655
+ `refs.unwindPenalty is consumed ${uses.length} times; a nonzero penalty schedule requires exactly one payout`,
656
+ );
657
+ }
658
+ if (uses.length === 0) return;
659
+ if (uses.length !== 1) {
660
+ if (!required) {
661
+ add(
662
+ ["verbs"],
663
+ `refs.unwindPenalty is consumed ${uses.length} times; it may fund at most one payout`,
664
+ );
665
+ }
666
+ return;
667
+ }
668
+
669
+ const use = uses[0] as (typeof uses)[number];
670
+ const payoutTransition = noun.lifecycle.transitions[use.verbName];
671
+ const confirmTarget = noun.lifecycle.transitions[noun.unwind!.confirm]?.to;
672
+ const source = canonicalAccount(noun, use.step.bind.sourceAccountId);
673
+ const destination = canonicalAccount(
674
+ noun,
675
+ use.step.bind.destinationAccountId,
676
+ );
677
+ const directAfterConfirm =
678
+ confirmTarget !== undefined &&
679
+ payoutTransition?.from.length === 1 &&
680
+ payoutTransition.from[0] === confirmTarget;
681
+ const oneWay =
682
+ payoutTransition !== undefined &&
683
+ payoutTransition.to !== confirmTarget &&
684
+ !stateCanReach(noun, payoutTransition.to, confirmTarget);
685
+ const valid =
686
+ use.target === "amount" &&
687
+ use.step.operation === "internal_transfer.create" &&
688
+ (use.verb.moves?.length ?? 0) === 1 &&
689
+ directAfterConfirm &&
690
+ oneWay &&
691
+ source !== undefined &&
692
+ source === refundSource &&
693
+ destination !== undefined &&
694
+ !accountsMayAlias(source, destination);
695
+ if (!valid) {
696
+ add(
697
+ ["verbs", use.verbName, "moves", use.stepIndex, "bind", use.target],
698
+ `refs.unwindPenalty must be the amount of one internal transfer from the unwind refund source to a different account, in its own one-way verb directly after ${noun.unwind!.confirm}`,
699
+ );
700
+ }
701
+ }
702
+
703
+ function canonicalAccount(
704
+ noun: FinancialNoun,
705
+ binding: UdlBinding | undefined,
706
+ ): string | undefined {
707
+ if (binding?.from !== "instance") return undefined;
708
+ const [root, key, extra] = binding.path.split(".");
709
+ if (!key || extra !== undefined) return undefined;
710
+ if (root === "fields") return `field:${key}`;
711
+ if (root === "refs") return `ref:${key}`;
712
+ if (root === "party") {
713
+ const field =
714
+ noun.parties?.[key as keyof NonNullable<FinancialNoun["parties"]>];
715
+ return field ? `field:${field}` : undefined;
716
+ }
717
+ return undefined;
718
+ }
719
+
720
+ function accountsMayAlias(left: string, right: string): boolean {
721
+ if (left.startsWith("field:") && right.startsWith("field:")) return true;
722
+ return left === right;
723
+ }
724
+
725
+ function amountIdentity(binding: UdlBinding | undefined): string {
726
+ if (binding?.from === "instance") return binding.path;
727
+ return binding?.from === "const" ? `const:${binding.value}` : "dynamic";
728
+ }
729
+
730
+ function transferRefKey(step: FinancialStep): string | undefined {
731
+ return Object.entries(step.capture ?? {}).find(
732
+ ([, result]) => result === "transferId",
733
+ )?.[0];
734
+ }
735
+
736
+ function boundTransferRefKey(step: FinancialStep): string | undefined {
737
+ const binding = step.bind.transferId;
738
+ return binding?.from === "instance" && binding.path.startsWith("refs.")
739
+ ? binding.path.slice("refs.".length)
740
+ : undefined;
741
+ }
742
+
743
+ function stateCanReach(
744
+ noun: FinancialNoun,
745
+ start: string,
746
+ target: string | undefined,
747
+ ): boolean {
748
+ if (!target) return false;
749
+ const seen = new Set([start]);
750
+ const pending = [start];
751
+ while (pending.length > 0) {
752
+ const state = pending.shift() as string;
753
+ if (state === target) return true;
754
+ for (const transition of Object.values(noun.lifecycle.transitions)) {
755
+ if (!transition.from.includes(state) || seen.has(transition.to)) continue;
756
+ seen.add(transition.to);
757
+ pending.push(transition.to);
758
+ }
759
+ }
760
+ return false;
761
+ }
762
+
763
+ function stateKey(state: AccountState): string {
764
+ return JSON.stringify({
765
+ balance: state.balance,
766
+ holds: Object.fromEntries(
767
+ Object.entries(state.holds).sort(([a], [b]) => a.localeCompare(b)),
768
+ ),
769
+ });
770
+ }
771
+
772
+ function formatAccount(account: string): string {
773
+ const [kind, key] = account.split(":");
774
+ return `${kind === "field" ? "fields" : "refs"}.${key}`;
775
+ }