@hyperscale0/udl 1.0.0-beta.1 → 1.0.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 (179) hide show
  1. package/CHANGELOG.md +78 -25
  2. package/README.md +37 -99
  3. package/conformance/README.md +44 -41
  4. package/conformance/evolution/action-contract.expected.json +10 -0
  5. package/conformance/evolution/action-contract.live.udl +44 -0
  6. package/conformance/evolution/action-contract.next.udl +45 -0
  7. package/conformance/evolution/product-identity.expected.json +10 -0
  8. package/conformance/evolution/product-identity.live.udl +55 -0
  9. package/conformance/evolution/product-identity.next.udl +55 -0
  10. package/conformance/evolution/version-required.expected.json +10 -0
  11. package/conformance/evolution/version-required.live.udl +55 -0
  12. package/conformance/evolution/version-required.next.udl +58 -0
  13. package/conformance/invalid/action-without-transition.expected.json +10 -0
  14. package/conformance/invalid/{verb-without-transition.udl → action-without-transition.udl} +7 -2
  15. package/conformance/invalid/agent-description-too-long.expected.json +10 -0
  16. package/conformance/invalid/agent-description-too-long.udl +57 -0
  17. package/conformance/invalid/blank-title.expected.json +1 -1
  18. package/conformance/invalid/blank-title.udl +6 -2
  19. package/conformance/invalid/composition-dial-duplicate-key.expected.json +10 -0
  20. package/conformance/invalid/composition-dial-duplicate-key.udl +73 -0
  21. package/conformance/invalid/depth-budget.expected.json +1 -1
  22. package/conformance/invalid/duplicate-subject.expected.json +10 -0
  23. package/conformance/invalid/duplicate-subject.udl +230 -0
  24. package/conformance/invalid/forged-effects.expected.json +10 -0
  25. package/conformance/invalid/forged-effects.udl +76 -0
  26. package/conformance/invalid/format-version.expected.json +1 -1
  27. package/conformance/invalid/format-version.udl +6 -2
  28. package/conformance/invalid/instrument-id-not-snake-case.expected.json +10 -0
  29. package/conformance/invalid/{noun-id-not-snake-case.udl → instrument-id-not-snake-case.udl} +6 -2
  30. package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +10 -0
  31. package/conformance/invalid/invalid-aggregate-gate-shape.udl +1819 -0
  32. package/conformance/invalid/invalid-check-duration.expected.json +10 -0
  33. package/conformance/invalid/invalid-check-duration.udl +219 -0
  34. package/conformance/invalid/invalid-dial-anchor.expected.json +10 -0
  35. package/conformance/invalid/invalid-dial-anchor.udl +219 -0
  36. package/conformance/invalid/invalid-exception-parent-ref.expected.json +10 -0
  37. package/conformance/invalid/invalid-exception-parent-ref.udl +2487 -0
  38. package/conformance/invalid/invalid-exposure-shape.expected.json +10 -0
  39. package/conformance/invalid/invalid-exposure-shape.udl +1819 -0
  40. package/conformance/invalid/invalid-remainder.expected.json +10 -0
  41. package/conformance/invalid/invalid-remainder.udl +220 -0
  42. package/conformance/invalid/invalid-schema-keyword.expected.json +10 -0
  43. package/conformance/invalid/invalid-schema-keyword.udl +220 -0
  44. package/conformance/invalid/invalid-utf8.expected.json +1 -1
  45. package/conformance/invalid/malformed-json.expected.json +1 -1
  46. package/conformance/invalid/missing-create-action.expected.json +10 -0
  47. package/conformance/invalid/{missing-create-verb.udl → missing-create-action.udl} +5 -2
  48. package/conformance/invalid/missing-exception-amount-field.expected.json +10 -0
  49. package/conformance/invalid/missing-exception-amount-field.udl +2487 -0
  50. package/conformance/invalid/missing-exception-contract.expected.json +14 -0
  51. package/conformance/invalid/missing-exception-contract.udl +2523 -0
  52. package/conformance/invalid/missing-exception-reason-field.expected.json +10 -0
  53. package/conformance/invalid/missing-exception-reason-field.udl +2487 -0
  54. package/conformance/invalid/not-an-object.expected.json +1 -1
  55. package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +10 -0
  56. package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +259 -0
  57. package/conformance/invalid/quote-freeze-set-incomplete.expected.json +10 -0
  58. package/conformance/invalid/quote-freeze-set-incomplete.udl +261 -0
  59. package/conformance/invalid/quote-named-reference-gate.expected.json +10 -0
  60. package/conformance/invalid/quote-named-reference-gate.udl +50 -0
  61. package/conformance/invalid/reconcile-named-reference-gate.expected.json +10 -0
  62. package/conformance/invalid/reconcile-named-reference-gate.udl +50 -0
  63. package/conformance/invalid/unknown-key.expected.json +1 -1
  64. package/conformance/invalid/unknown-key.udl +6 -2
  65. package/conformance/invalid/unknown-reference-gate-field.expected.json +10 -0
  66. package/conformance/invalid/unknown-reference-gate-field.udl +1819 -0
  67. package/conformance/invalid/unknown-required-field.expected.json +10 -0
  68. package/conformance/invalid/unknown-required-field.udl +220 -0
  69. package/conformance/invalid/unreachable-state.expected.json +2 -2
  70. package/conformance/invalid/unreachable-state.udl +6 -2
  71. package/conformance/invalid/wrong-exception-amount-field.expected.json +10 -0
  72. package/conformance/invalid/wrong-exception-amount-field.udl +2487 -0
  73. package/conformance/invalid/wrong-exception-reason-field.expected.json +10 -0
  74. package/conformance/invalid/wrong-exception-reason-field.udl +2487 -0
  75. package/conformance/valid/agent-description.expected.json +6 -0
  76. package/conformance/valid/agent-description.udl +66 -0
  77. package/conformance/valid/cards.expected.json +1 -0
  78. package/conformance/valid/cards.udl +843 -356
  79. package/conformance/valid/commerce-escrow.expected.json +1 -0
  80. package/conformance/valid/commerce-escrow.udl +792 -291
  81. package/conformance/valid/compiled-crowdfunding.expected.json +6 -0
  82. package/conformance/valid/compiled-crowdfunding.udl +1843 -0
  83. package/conformance/valid/compiled-watch-club.expected.json +6 -0
  84. package/conformance/valid/compiled-watch-club.udl +2486 -0
  85. package/conformance/valid/complete-contract.expected.json +6 -0
  86. package/conformance/valid/complete-contract.udl +218 -0
  87. package/conformance/valid/effect-signatures.expected.json +6 -0
  88. package/conformance/valid/effect-signatures.udl +75 -0
  89. package/conformance/valid/hand-edited.expected.json +1 -0
  90. package/conformance/valid/hand-edited.udl +1 -1
  91. package/conformance/valid/insured-car-marketplace.expected.json +1 -0
  92. package/conformance/valid/insured-car-marketplace.udl +534 -315
  93. package/conformance/valid/insured-travel.expected.json +2 -1
  94. package/conformance/valid/insured-travel.udl +1489 -641
  95. package/conformance/valid/minimal.expected.json +1 -0
  96. package/conformance/valid/minimal.udl +18 -14
  97. package/conformance/valid/protection.expected.json +1 -0
  98. package/conformance/valid/protection.udl +719 -304
  99. package/conformance/valid/string-escaping.expected.json +6 -0
  100. package/conformance/valid/string-escaping.udl +54 -0
  101. package/dist/canonical.d.ts +2 -0
  102. package/dist/canonical.d.ts.map +1 -1
  103. package/dist/canonical.js +7 -0
  104. package/dist/canonical.js.map +1 -1
  105. package/dist/check-profiles.d.ts +57 -0
  106. package/dist/check-profiles.d.ts.map +1 -0
  107. package/dist/check-profiles.js +62 -0
  108. package/dist/check-profiles.js.map +1 -0
  109. package/dist/cli.js +48 -4
  110. package/dist/cli.js.map +1 -1
  111. package/dist/diagnostics.d.ts +168 -0
  112. package/dist/diagnostics.d.ts.map +1 -0
  113. package/dist/diagnostics.js +164 -0
  114. package/dist/diagnostics.js.map +1 -0
  115. package/dist/effects.d.ts +41 -0
  116. package/dist/effects.d.ts.map +1 -0
  117. package/dist/effects.js +110 -0
  118. package/dist/effects.js.map +1 -0
  119. package/dist/evolution.d.ts +51 -18
  120. package/dist/evolution.d.ts.map +1 -1
  121. package/dist/evolution.js +244 -102
  122. package/dist/evolution.js.map +1 -1
  123. package/dist/finance.d.ts +17 -13
  124. package/dist/finance.d.ts.map +1 -1
  125. package/dist/finance.js +165 -109
  126. package/dist/finance.js.map +1 -1
  127. package/dist/index.d.ts +12 -8
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +8 -5
  130. package/dist/index.js.map +1 -1
  131. package/dist/limits.d.ts +2 -2
  132. package/dist/limits.d.ts.map +1 -1
  133. package/dist/limits.js +4 -3
  134. package/dist/limits.js.map +1 -1
  135. package/dist/parser.d.ts.map +1 -1
  136. package/dist/parser.js +6 -16
  137. package/dist/parser.js.map +1 -1
  138. package/dist/schema.d.ts +2059 -135
  139. package/dist/schema.d.ts.map +1 -1
  140. package/dist/schema.js +759 -73
  141. package/dist/schema.js.map +1 -1
  142. package/dist/validation.d.ts +22 -9
  143. package/dist/validation.d.ts.map +1 -1
  144. package/dist/validation.js +1463 -457
  145. package/dist/validation.js.map +1 -1
  146. package/docs/README.md +14 -0
  147. package/docs/guide/01-a-document.md +37 -0
  148. package/docs/guide/02-money-steps.md +23 -0
  149. package/docs/guide/03-laws.md +18 -0
  150. package/docs/guide/04-fees-and-remainder.md +15 -0
  151. package/docs/guide/05-checks-updates-dials.md +7 -0
  152. package/docs/guide/06-effects.md +11 -0
  153. package/docs/guide/07-evolution.md +11 -0
  154. package/docs/guide/08-implementing.md +13 -0
  155. package/docs/llms-full.txt +1241 -0
  156. package/docs/llms.txt +14 -0
  157. package/docs/reference/canonical.md +16 -0
  158. package/docs/reference/clauses.md +986 -0
  159. package/docs/reference/cli.md +22 -0
  160. package/docs/reference/diagnostics.md +32 -0
  161. package/package.json +6 -2
  162. package/skills/udl/SKILL.md +28 -0
  163. package/spec/README.md +82 -51
  164. package/spec/udl.schema.json +1191 -119
  165. package/src/canonical.ts +11 -0
  166. package/src/check-profiles.ts +80 -0
  167. package/src/cli.ts +49 -3
  168. package/src/diagnostics.ts +221 -0
  169. package/src/effects.ts +163 -0
  170. package/src/evolution.ts +375 -125
  171. package/src/finance.ts +255 -160
  172. package/src/index.ts +50 -14
  173. package/src/limits.ts +4 -3
  174. package/src/parser.ts +15 -18
  175. package/src/schema.ts +855 -81
  176. package/src/validation.ts +2666 -560
  177. package/conformance/invalid/missing-create-verb.expected.json +0 -10
  178. package/conformance/invalid/noun-id-not-snake-case.expected.json +0 -10
  179. package/conformance/invalid/verb-without-transition.expected.json +0 -10
package/src/finance.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { UdlBinding } from "./schema.js";
2
2
  import { UDL_LIMITS } from "./limits.js";
3
+ import type { UdlIssueCode } from "./diagnostics.js";
3
4
 
4
5
  export interface FinanceIssue {
6
+ readonly code: UdlIssueCode;
5
7
  readonly message: string;
6
8
  readonly path: readonly PropertyKey[];
7
9
  }
@@ -20,7 +22,7 @@ interface FinancialMove extends FinancialStep {
20
22
  readonly key: string;
21
23
  }
22
24
 
23
- interface FinancialNoun {
25
+ interface FinancialInstrument {
24
26
  readonly lifecycle: {
25
27
  readonly initial: string;
26
28
  readonly states: readonly string[];
@@ -35,26 +37,40 @@ interface FinancialNoun {
35
37
  readonly subjectHolder?: string | undefined;
36
38
  }
37
39
  | 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<
40
+ readonly actions: Readonly<
48
41
  Record<
49
42
  string,
50
43
  {
44
+ readonly commit?: string | undefined;
51
45
  readonly moves?: readonly FinancialMove[];
46
+ readonly quote?:
47
+ | {
48
+ readonly baseField: string;
49
+ readonly chargeRef: string;
50
+ readonly charges: readonly { readonly bps: number }[];
51
+ readonly netRef: string;
52
+ }
53
+ | undefined;
52
54
  readonly steps: readonly FinancialStep[];
53
55
  }
54
56
  >
55
57
  >;
56
58
  }
57
59
 
60
+ /**
61
+ * One quote-commit pair, flattened for the typestate. The quoting action prices
62
+ * `baseField` into the charge and the net; the committing action spends the net
63
+ * out of the account that funded the base.
64
+ */
65
+ interface QuoteFacts {
66
+ readonly baseField: string;
67
+ readonly chargeRef: string;
68
+ readonly chargeCanBeNonzero: boolean;
69
+ readonly commit: string;
70
+ readonly netRef: string;
71
+ readonly quoting: string;
72
+ }
73
+
58
74
  type Balance =
59
75
  | { readonly kind: "empty" }
60
76
  | { readonly kind: "funded"; readonly amounts: readonly string[] }
@@ -74,6 +90,8 @@ interface Reservation {
74
90
 
75
91
  interface Effect {
76
92
  readonly amount: string;
93
+ /** The quoted base a refund effect returns; absent on every other kind. */
94
+ readonly refundable?: string;
77
95
  readonly kind:
78
96
  | "credit"
79
97
  | "debit"
@@ -109,131 +127,128 @@ function withoutPiece(
109
127
  }
110
128
 
111
129
  /**
112
- * One financial typestate owner for canonical UDL and static noun contracts.
130
+ * One financial typestate owner for canonical UDL and static instrument contracts.
113
131
  * It proves a tracked escrow account holds an exact set of funded amounts,
114
132
  * models held transfers through reserve/post/void, consumes every direct
115
- * debit piece by piece, and makes the unwind refund's sole remainder an
133
+ * debit piece by piece, and makes the quoted refund's sole remainder an
116
134
  * exactly-once penalty payout.
117
135
  */
118
- export function analyzeNounFinance(
119
- noun: FinancialNoun,
136
+ export function analyzeInstrumentFinance(
137
+ instrument: FinancialInstrument,
120
138
  options: FinanceOptions = {},
121
139
  ): readonly FinanceIssue[] {
122
140
  const issues: FinanceIssue[] = [];
123
141
  const seenIssues = new Set<string>();
124
- const add = (path: readonly PropertyKey[], message: string): void => {
142
+ const add = (
143
+ path: readonly PropertyKey[],
144
+ message: string,
145
+ code: UdlIssueCode = "UDL4001",
146
+ ): void => {
125
147
  const key = `${path.join(".")}\0${message}`;
126
148
  if (seenIssues.has(key)) return;
127
149
  seenIssues.add(key);
128
- issues.push({ message, path });
150
+ issues.push({ code, message, path });
129
151
  };
130
152
 
131
- const admissionProblem = financeAdmissionProblem(noun);
153
+ const admissionProblem = financeAdmissionProblem(instrument);
132
154
  if (admissionProblem) {
133
- add(["lifecycle"], admissionProblem);
155
+ add(["lifecycle"], admissionProblem, "UDL1004");
134
156
  return issues;
135
157
  }
136
158
 
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);
159
+ const trackedAccounts = productEscrowAccounts(instrument);
160
+ const quotes = quoteFacts(instrument, options);
161
+ const refundSources = new Map<string, string>();
162
+ for (const quote of quotes) {
163
+ const commitTransfer = instrument.actions[quote.commit]?.moves?.filter(
164
+ (move) => move.operation === "internal_transfer.create",
165
+ )[0];
166
+ const source = canonicalAccount(
167
+ instrument,
168
+ commitTransfer?.bind.sourceAccountId,
169
+ );
170
+ if (!source) continue;
171
+ refundSources.set(quote.commit, source);
172
+ trackedAccounts.add(source);
173
+ }
174
+ const refundSourceAccounts = new Set(refundSources.values());
148
175
  if (trackedAccounts.size === 0) return [];
149
176
  if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
150
177
  add(
151
- ["verbs"],
178
+ ["actions"],
152
179
  `financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`,
180
+ "UDL1004",
153
181
  );
154
182
  return issues;
155
183
  }
156
184
 
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);
185
+ const reservations = reservationsByKey(instrument);
186
+ for (const quote of quotes) {
187
+ validateChargePayout(
188
+ instrument,
189
+ quote,
190
+ refundSources.get(quote.commit),
191
+ add,
192
+ );
173
193
  }
174
194
 
175
195
  let pathVariants = 0;
176
196
  let work = 0;
177
197
  for (const account of trackedAccounts) {
178
- const effects = effectsByVerb(
179
- noun,
180
- account,
181
- reservations,
182
- penaltyCanBeNonzero,
183
- );
198
+ const effects = effectsByAction(instrument, account, reservations, quotes);
184
199
  const createEffects = effects.get("create") ?? [];
185
200
  work += 1 + createEffects.length;
186
201
  if (work > UDL_LIMITS.financeWork) {
187
202
  add(
188
203
  ["lifecycle"],
189
204
  `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
205
+ "UDL1004",
190
206
  );
191
207
  return issues;
192
208
  }
193
209
  const initial = applyEffects(
194
- noun,
195
210
  account,
196
211
  "create",
197
212
  { balance: EMPTY, holds: {} },
198
213
  createEffects,
199
214
  reservations,
200
- account === refundSource,
215
+ refundSourceAccounts.has(account),
201
216
  add,
202
217
  );
203
218
  const states = new Map<string, Map<string, AccountState>>([
204
- [noun.lifecycle.initial, new Map([[stateKey(initial), initial]])],
219
+ [instrument.lifecycle.initial, new Map([[stateKey(initial), initial]])],
205
220
  ]);
206
221
  pathVariants += 1;
207
- const pending = [noun.lifecycle.initial];
222
+ const pending = [instrument.lifecycle.initial];
208
223
  while (pending.length > 0) {
209
224
  const from = pending.shift() as string;
210
225
  const sourceStates = states.get(from);
211
226
  if (!sourceStates) continue;
212
- for (const [verb, transition] of Object.entries(
213
- noun.lifecycle.transitions,
227
+ for (const [action, transition] of Object.entries(
228
+ instrument.lifecycle.transitions,
214
229
  )) {
215
230
  if (!transition.from.includes(from)) continue;
216
231
  const targetStates = states.get(transition.to) ?? new Map();
217
232
  states.set(transition.to, targetStates);
218
233
  let grew = false;
219
234
  for (const sourceState of sourceStates.values()) {
220
- const transitionEffects = effects.get(verb) ?? [];
235
+ const transitionEffects = effects.get(action) ?? [];
221
236
  work += 1 + transitionEffects.length;
222
237
  if (work > UDL_LIMITS.financeWork) {
223
238
  add(
224
239
  ["lifecycle"],
225
240
  `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
241
+ "UDL1004",
226
242
  );
227
243
  return issues;
228
244
  }
229
245
  const targetState = applyEffects(
230
- noun,
231
246
  account,
232
- verb,
247
+ action,
233
248
  sourceState,
234
249
  transitionEffects,
235
250
  reservations,
236
- account === refundSource,
251
+ refundSourceAccounts.has(account),
237
252
  add,
238
253
  );
239
254
  const key = stateKey(targetState);
@@ -244,6 +259,7 @@ export function analyzeNounFinance(
244
259
  add(
245
260
  ["lifecycle"],
246
261
  `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`,
262
+ "UDL1004",
247
263
  );
248
264
  return issues;
249
265
  }
@@ -254,7 +270,7 @@ export function analyzeNounFinance(
254
270
  }
255
271
  }
256
272
  const nonterminalStates = new Set(
257
- Object.values(noun.lifecycle.transitions).flatMap(
273
+ Object.values(instrument.lifecycle.transitions).flatMap(
258
274
  (transition) => transition.from,
259
275
  ),
260
276
  );
@@ -268,7 +284,7 @@ export function analyzeNounFinance(
268
284
  continue;
269
285
  }
270
286
  add(
271
- ["lifecycle", "states", noun.lifecycle.states.indexOf(state)],
287
+ ["lifecycle", "states", instrument.lifecycle.states.indexOf(state)],
272
288
  `terminal state ${state} can strand value in ${formatAccount(account)}`,
273
289
  );
274
290
  }
@@ -279,34 +295,34 @@ export function analyzeNounFinance(
279
295
  }
280
296
 
281
297
  export function financeAdmissionProblem(
282
- noun: FinancialNoun,
298
+ instrument: FinancialInstrument,
283
299
  ): string | undefined {
284
- if (noun.lifecycle.states.length > UDL_LIMITS.financeStates) {
300
+ if (instrument.lifecycle.states.length > UDL_LIMITS.financeStates) {
285
301
  return `financial analysis exceeds ${UDL_LIMITS.financeStates} lifecycle states`;
286
302
  }
287
303
  let transitionCount = 0;
288
304
  let transitionEdges = 0;
289
- for (const name in noun.lifecycle.transitions) {
290
- if (!Object.hasOwn(noun.lifecycle.transitions, name)) continue;
305
+ for (const name in instrument.lifecycle.transitions) {
306
+ if (!Object.hasOwn(instrument.lifecycle.transitions, name)) continue;
291
307
  transitionCount += 1;
292
308
  if (transitionCount > UDL_LIMITS.financeTransitions) {
293
309
  return `financial analysis exceeds ${UDL_LIMITS.financeTransitions} lifecycle transitions`;
294
310
  }
295
- transitionEdges += noun.lifecycle.transitions[name]?.from.length ?? 0;
311
+ transitionEdges += instrument.lifecycle.transitions[name]?.from.length ?? 0;
296
312
  if (transitionEdges > UDL_LIMITS.financeTransitionEdges) {
297
313
  return `financial analysis exceeds ${UDL_LIMITS.financeTransitionEdges} lifecycle transition edges`;
298
314
  }
299
315
  }
300
- let verbCount = 0;
316
+ let actionCount = 0;
301
317
  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`;
318
+ for (const name in instrument.actions) {
319
+ if (!Object.hasOwn(instrument.actions, name)) continue;
320
+ actionCount += 1;
321
+ if (actionCount > UDL_LIMITS.financeActions) {
322
+ return `financial analysis exceeds ${UDL_LIMITS.financeActions} actions`;
307
323
  }
308
- const verb = noun.verbs[name];
309
- effectCount += (verb?.steps.length ?? 0) + (verb?.moves?.length ?? 0);
324
+ const action = instrument.actions[name];
325
+ effectCount += (action?.steps.length ?? 0) + (action?.moves?.length ?? 0);
310
326
  if (effectCount > UDL_LIMITS.financeEffects) {
311
327
  return `financial analysis exceeds ${UDL_LIMITS.financeEffects} kernel effects`;
312
328
  }
@@ -314,14 +330,14 @@ export function financeAdmissionProblem(
314
330
  return undefined;
315
331
  }
316
332
 
317
- function workspaceEscrowAccounts(noun: FinancialNoun): Set<string> {
333
+ function productEscrowAccounts(instrument: FinancialInstrument): Set<string> {
318
334
  const accounts = new Set<string>();
319
- for (const verb of Object.values(noun.verbs)) {
320
- for (const step of verb.steps) {
335
+ for (const action of Object.values(instrument.actions)) {
336
+ for (const step of action.steps) {
321
337
  if (
322
338
  step.operation !== "account.escrow.provision" ||
323
339
  step.bind.role?.from !== "const" ||
324
- step.bind.role.value !== "workspace_escrow"
340
+ step.bind.role.value !== "product_escrow"
325
341
  ) {
326
342
  continue;
327
343
  }
@@ -331,13 +347,13 @@ function workspaceEscrowAccounts(noun: FinancialNoun): Set<string> {
331
347
  }
332
348
  }
333
349
 
334
- // `workspace_escrow` also backs open-ended balance products such as wallets.
350
+ // `product_escrow` also backs open-ended balance products such as wallets.
335
351
  // Only instance-bound amounts claim static conservation; caller-sized flows
336
- // remain runtime balance checks. Unwind sources are added separately above.
352
+ // remain runtime balance checks. Commit sources are added separately above.
337
353
  return new Set(
338
354
  [...accounts].filter((account) =>
339
- Object.values(noun.verbs).some((verb) =>
340
- (verb.moves ?? []).some((step) => {
355
+ Object.values(instrument.actions).some((action) =>
356
+ (action.moves ?? []).some((step) => {
341
357
  if (
342
358
  (step.operation !== "internal_transfer.create" &&
343
359
  step.operation !== "internal_transfer.reserve") ||
@@ -346,8 +362,10 @@ function workspaceEscrowAccounts(noun: FinancialNoun): Set<string> {
346
362
  return false;
347
363
  }
348
364
  return (
349
- canonicalAccount(noun, step.bind.sourceAccountId) === account ||
350
- canonicalAccount(noun, step.bind.destinationAccountId) === account
365
+ canonicalAccount(instrument, step.bind.sourceAccountId) ===
366
+ account ||
367
+ canonicalAccount(instrument, step.bind.destinationAccountId) ===
368
+ account
351
369
  );
352
370
  }),
353
371
  ),
@@ -356,15 +374,15 @@ function workspaceEscrowAccounts(noun: FinancialNoun): Set<string> {
356
374
  }
357
375
 
358
376
  function reservationsByKey(
359
- noun: FinancialNoun,
377
+ instrument: FinancialInstrument,
360
378
  ): ReadonlyMap<string, Reservation> {
361
379
  const reservations = new Map<string, Reservation>();
362
- for (const verb of Object.values(noun.verbs)) {
363
- for (const step of verb.moves ?? []) {
380
+ for (const action of Object.values(instrument.actions)) {
381
+ for (const step of action.moves ?? []) {
364
382
  if (step.operation !== "internal_transfer.reserve") continue;
365
- const source = canonicalAccount(noun, step.bind.sourceAccountId);
383
+ const source = canonicalAccount(instrument, step.bind.sourceAccountId);
366
384
  const destination = canonicalAccount(
367
- noun,
385
+ instrument,
368
386
  step.bind.destinationAccountId,
369
387
  );
370
388
  const amount = amountIdentity(step.bind.amount);
@@ -382,54 +400,80 @@ function reservationsByKey(
382
400
  return reservations;
383
401
  }
384
402
 
385
- function effectsByVerb(
386
- noun: FinancialNoun,
403
+ function effectsByAction(
404
+ instrument: FinancialInstrument,
387
405
  account: string,
388
406
  reservations: ReadonlyMap<string, Reservation>,
389
- penaltyCanBeNonzero: boolean,
407
+ quotes: readonly QuoteFacts[],
390
408
  ): ReadonlyMap<string, readonly Effect[]> {
409
+ const quoteByCommit = new Map(quotes.map((quote) => [quote.commit, quote]));
410
+ const chargePaths = new Map(
411
+ quotes.map((quote) => [`refs.${quote.chargeRef}`, quote]),
412
+ );
391
413
  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;
414
+ Object.entries(instrument.actions).map(([actionName, action]) => [
415
+ actionName,
416
+ (action.moves ?? []).flatMap((step, stepIndex) => {
417
+ const path = [
418
+ "actions",
419
+ actionName,
420
+ "moves",
421
+ stepIndex,
422
+ "bind",
423
+ ] as const;
396
424
  if (step.operation === "internal_transfer.create") {
397
425
  const effects: Effect[] = [];
398
426
  const amount = amountIdentity(step.bind.amount);
399
427
  if (
400
- canonicalAccount(noun, step.bind.destinationAccountId) === account
428
+ canonicalAccount(instrument, step.bind.destinationAccountId) ===
429
+ account
401
430
  ) {
402
431
  effects.push({ amount, kind: "credit", path });
403
432
  }
404
- const source = canonicalAccount(noun, step.bind.sourceAccountId);
433
+ const source = canonicalAccount(
434
+ instrument,
435
+ step.bind.sourceAccountId,
436
+ );
405
437
  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";
438
+ const committed = quoteByCommit.get(actionName);
439
+ const amountPath =
440
+ step.bind.amount?.from === "instance"
441
+ ? step.bind.amount.path
442
+ : undefined;
443
+ const refunded =
444
+ committed && amountPath === `refs.${committed.netRef}`
445
+ ? committed
446
+ : undefined;
447
+ const charged = amountPath
448
+ ? chargePaths.get(amountPath)
449
+ : undefined;
450
+ const kind = refunded
451
+ ? "refund"
452
+ : charged
453
+ ? "penalty_debit"
454
+ : amountPath !== undefined
455
+ ? "debit"
456
+ : "dynamic_debit";
417
457
  effects.push({
418
458
  amount:
419
- kind === "refund" && !penaltyCanBeNonzero
459
+ refunded && !refunded.chargeCanBeNonzero
420
460
  ? "refund_without_penalty"
421
461
  : amount,
422
462
  kind,
423
463
  path,
464
+ ...(refunded ? { refundable: refunded.baseField } : {}),
424
465
  });
425
466
  }
426
467
  return effects;
427
468
  }
428
469
  if (step.operation === "internal_transfer.reserve") {
429
470
  const reservation =
430
- transferRefKey(step) ?? `${verbName}:${stepIndex}`;
471
+ transferRefKey(step) ?? `${actionName}:${stepIndex}`;
431
472
  const effects: Effect[] = [];
432
- const source = canonicalAccount(noun, step.bind.sourceAccountId);
473
+ const source = canonicalAccount(
474
+ instrument,
475
+ step.bind.sourceAccountId,
476
+ );
433
477
  if (source && accountsMayAlias(source, account)) {
434
478
  effects.push({
435
479
  amount: amountIdentity(step.bind.amount),
@@ -439,7 +483,8 @@ function effectsByVerb(
439
483
  });
440
484
  }
441
485
  if (
442
- canonicalAccount(noun, step.bind.destinationAccountId) === account
486
+ canonicalAccount(instrument, step.bind.destinationAccountId) ===
487
+ account
443
488
  ) {
444
489
  effects.push({
445
490
  amount: amountIdentity(step.bind.amount),
@@ -481,9 +526,8 @@ function effectsByVerb(
481
526
  }
482
527
 
483
528
  function applyEffects(
484
- noun: FinancialNoun,
485
529
  account: string,
486
- verb: string,
530
+ action: string,
487
531
  input: AccountState,
488
532
  effects: readonly Effect[],
489
533
  reservations: ReadonlyMap<string, Reservation>,
@@ -495,7 +539,7 @@ function applyEffects(
495
539
  const accountLabel = formatAccount(account);
496
540
  for (const effect of effects) {
497
541
  const fail = (message: string): void =>
498
- add([...effect.path, "sourceAccountId"], `verb ${verb} ${message}`);
542
+ add([...effect.path, "sourceAccountId"], `action ${action} ${message}`);
499
543
  if (effect.kind === "credit") {
500
544
  if (
501
545
  exactBalance &&
@@ -503,7 +547,7 @@ function applyEffects(
503
547
  ) {
504
548
  add(
505
549
  [...effect.path, "destinationAccountId"],
506
- `verb ${verb} funds ${accountLabel} while earlier value may remain; unwind funding must establish exactly one refundable balance`,
550
+ `action ${action} funds ${accountLabel} while earlier value may remain; quoted funding must establish exactly one refundable balance`,
507
551
  );
508
552
  balance = UNKNOWN;
509
553
  continue;
@@ -556,7 +600,7 @@ function applyEffects(
556
600
  } else {
557
601
  add(
558
602
  [...effect.path, "transferId"],
559
- `verb ${verb} restores a held ${accountLabel} balance on top of existing value`,
603
+ `action ${action} restores a held ${accountLabel} balance on top of existing value`,
560
604
  );
561
605
  balance = UNKNOWN;
562
606
  }
@@ -566,7 +610,7 @@ function applyEffects(
566
610
  if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
567
611
  add(
568
612
  [...effect.path, "transferId"],
569
- `verb ${verb} posts funding into ${accountLabel} while earlier value may remain`,
613
+ `action ${action} posts funding into ${accountLabel} while earlier value may remain`,
570
614
  );
571
615
  balance = UNKNOWN;
572
616
  } else {
@@ -576,7 +620,7 @@ function applyEffects(
576
620
  continue;
577
621
  }
578
622
  if (effect.kind === "refund") {
579
- const refundable = `fields.${noun.unwind?.refundableField ?? ""}`;
623
+ const refundable = `fields.${effect.refundable ?? ""}`;
580
624
  if (
581
625
  balance.kind !== "funded" ||
582
626
  balance.amounts.length !== 1 ||
@@ -597,7 +641,7 @@ function applyEffects(
597
641
  if (effect.kind === "penalty_debit") {
598
642
  if (balance.kind !== "penalty") {
599
643
  fail(
600
- `cannot pay refs.unwindPenalty from ${accountLabel} before the refund leaves that exact remainder`,
644
+ `cannot pay the quoted charge from ${accountLabel} before the refund leaves that exact remainder`,
601
645
  );
602
646
  }
603
647
  balance = EMPTY;
@@ -608,7 +652,7 @@ function applyEffects(
608
652
  fail(`can debit unfunded ${accountLabel}`);
609
653
  } else if (balance.kind === "penalty") {
610
654
  fail(
611
- `can debit ${accountLabel} after its refund left only refs.unwindPenalty`,
655
+ `can debit ${accountLabel} after its refund left only the quoted charge`,
612
656
  );
613
657
  }
614
658
  balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
@@ -622,7 +666,7 @@ function applyEffects(
622
666
  fail(`can debit unfunded ${accountLabel}`);
623
667
  } else if (balance.kind === "penalty") {
624
668
  fail(
625
- `can debit ${accountLabel} after its refund left only refs.unwindPenalty`,
669
+ `can debit ${accountLabel} after its refund left only the quoted charge`,
626
670
  );
627
671
  } else if (balance.kind === "funded" || exactBalance) {
628
672
  fail(
@@ -634,59 +678,108 @@ function applyEffects(
634
678
  return { balance, holds };
635
679
  }
636
680
 
637
- function validatePenaltyPayout(
638
- noun: FinancialNoun,
681
+ /**
682
+ * Flattens every quote-commit pair the instrument declares. A quoting action
683
+ * with no committing action, or a commit naming a non-quoting action, is a
684
+ * validator failure rather than a typestate one, so it is simply skipped here.
685
+ */
686
+ function quoteFacts(
687
+ instrument: FinancialInstrument,
688
+ options: FinanceOptions,
689
+ ): readonly QuoteFacts[] {
690
+ const facts: QuoteFacts[] = [];
691
+ for (const [quoting, action] of Object.entries(instrument.actions)) {
692
+ const quote = action.quote;
693
+ if (!quote) continue;
694
+ const commit = Object.entries(instrument.actions).find(
695
+ ([, candidate]) => candidate.commit === quoting,
696
+ )?.[0];
697
+ if (commit === undefined) continue;
698
+ const chargePath = `refs.${quote.chargeRef}`;
699
+ const declaresChargePayout = Object.values(instrument.actions).some(
700
+ (candidate) =>
701
+ (candidate.moves ?? []).some((move) =>
702
+ Object.values(move.bind).some(
703
+ (binding) =>
704
+ binding.from === "instance" && binding.path === chargePath,
705
+ ),
706
+ ),
707
+ );
708
+ facts.push({
709
+ baseField: quote.baseField,
710
+ chargeCanBeNonzero:
711
+ options.penaltyMayBeNonzero === true ||
712
+ declaresChargePayout ||
713
+ quote.charges.some((tier) => tier.bps > 0),
714
+ chargeRef: quote.chargeRef,
715
+ commit,
716
+ netRef: quote.netRef,
717
+ quoting,
718
+ });
719
+ }
720
+ return facts;
721
+ }
722
+
723
+ /**
724
+ * The quoted charge is the exact remainder the commit leaves behind, so it may
725
+ * fund one transfer, out of the same account the refund left, in its own
726
+ * one-way action directly after the commit.
727
+ */
728
+ function validateChargePayout(
729
+ instrument: FinancialInstrument,
730
+ quote: QuoteFacts,
639
731
  refundSource: string | undefined,
640
- required: boolean,
641
732
  add: (path: readonly PropertyKey[], message: string) => void,
642
733
  ): 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
- : [],
734
+ const chargePath = `refs.${quote.chargeRef}`;
735
+ const uses = Object.entries(instrument.actions).flatMap(
736
+ ([actionName, action]) =>
737
+ (action.moves ?? []).flatMap((step, stepIndex) =>
738
+ Object.entries(step.bind).flatMap(([target, binding]) =>
739
+ binding.from === "instance" && binding.path === chargePath
740
+ ? [{ binding, step, stepIndex, target, action, actionName }]
741
+ : [],
742
+ ),
649
743
  ),
650
- ),
651
744
  );
652
- if (required && uses.length !== 1) {
745
+ if (quote.chargeCanBeNonzero && uses.length !== 1) {
653
746
  add(
654
- ["verbs"],
655
- `refs.unwindPenalty is consumed ${uses.length} times; a nonzero penalty schedule requires exactly one payout`,
747
+ ["actions"],
748
+ `${chargePath} is consumed ${uses.length} times; a nonzero charge schedule requires exactly one payout`,
656
749
  );
657
750
  }
658
751
  if (uses.length === 0) return;
659
752
  if (uses.length !== 1) {
660
- if (!required) {
753
+ if (!quote.chargeCanBeNonzero) {
661
754
  add(
662
- ["verbs"],
663
- `refs.unwindPenalty is consumed ${uses.length} times; it may fund at most one payout`,
755
+ ["actions"],
756
+ `${chargePath} is consumed ${uses.length} times; it may fund at most one payout`,
664
757
  );
665
758
  }
666
759
  return;
667
760
  }
668
761
 
669
762
  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);
763
+ const payoutTransition = instrument.lifecycle.transitions[use.actionName];
764
+ const commitTarget = instrument.lifecycle.transitions[quote.commit]?.to;
765
+ const source = canonicalAccount(instrument, use.step.bind.sourceAccountId);
673
766
  const destination = canonicalAccount(
674
- noun,
767
+ instrument,
675
768
  use.step.bind.destinationAccountId,
676
769
  );
677
- const directAfterConfirm =
678
- confirmTarget !== undefined &&
770
+ const directAfterCommit =
771
+ commitTarget !== undefined &&
679
772
  payoutTransition?.from.length === 1 &&
680
- payoutTransition.from[0] === confirmTarget;
773
+ payoutTransition.from[0] === commitTarget;
681
774
  const oneWay =
682
775
  payoutTransition !== undefined &&
683
- payoutTransition.to !== confirmTarget &&
684
- !stateCanReach(noun, payoutTransition.to, confirmTarget);
776
+ payoutTransition.to !== commitTarget &&
777
+ !stateCanReach(instrument, payoutTransition.to, commitTarget);
685
778
  const valid =
686
779
  use.target === "amount" &&
687
780
  use.step.operation === "internal_transfer.create" &&
688
- (use.verb.moves?.length ?? 0) === 1 &&
689
- directAfterConfirm &&
781
+ (use.action.moves?.length ?? 0) === 1 &&
782
+ directAfterCommit &&
690
783
  oneWay &&
691
784
  source !== undefined &&
692
785
  source === refundSource &&
@@ -694,14 +787,14 @@ function validatePenaltyPayout(
694
787
  !accountsMayAlias(source, destination);
695
788
  if (!valid) {
696
789
  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}`,
790
+ ["actions", use.actionName, "moves", use.stepIndex, "bind", use.target],
791
+ `${chargePath} must be the amount of one internal transfer from the quoted refund source to a different account, in its own one-way action directly after ${quote.commit}`,
699
792
  );
700
793
  }
701
794
  }
702
795
 
703
796
  function canonicalAccount(
704
- noun: FinancialNoun,
797
+ instrument: FinancialInstrument,
705
798
  binding: UdlBinding | undefined,
706
799
  ): string | undefined {
707
800
  if (binding?.from !== "instance") return undefined;
@@ -711,7 +804,9 @@ function canonicalAccount(
711
804
  if (root === "refs") return `ref:${key}`;
712
805
  if (root === "party") {
713
806
  const field =
714
- noun.parties?.[key as keyof NonNullable<FinancialNoun["parties"]>];
807
+ instrument.parties?.[
808
+ key as keyof NonNullable<FinancialInstrument["parties"]>
809
+ ];
715
810
  return field ? `field:${field}` : undefined;
716
811
  }
717
812
  return undefined;
@@ -741,7 +836,7 @@ function boundTransferRefKey(step: FinancialStep): string | undefined {
741
836
  }
742
837
 
743
838
  function stateCanReach(
744
- noun: FinancialNoun,
839
+ instrument: FinancialInstrument,
745
840
  start: string,
746
841
  target: string | undefined,
747
842
  ): boolean {
@@ -751,7 +846,7 @@ function stateCanReach(
751
846
  while (pending.length > 0) {
752
847
  const state = pending.shift() as string;
753
848
  if (state === target) return true;
754
- for (const transition of Object.values(noun.lifecycle.transitions)) {
849
+ for (const transition of Object.values(instrument.lifecycle.transitions)) {
755
850
  if (!transition.from.includes(state) || seen.has(transition.to)) continue;
756
851
  seen.add(transition.to);
757
852
  pending.push(transition.to);