@hyperscale0/udl 2.6.1 → 3.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 (213) hide show
  1. package/CHANGELOG.md +2 -330
  2. package/LICENSING.md +1 -2
  3. package/README.md +3 -114
  4. package/TRADEMARKS.md +2 -2
  5. package/dist/diagnostics.d.ts +21 -201
  6. package/dist/diagnostics.d.ts.map +1 -1
  7. package/dist/diagnostics.js +29 -193
  8. package/dist/diagnostics.js.map +1 -1
  9. package/dist/evolution.d.ts +3 -132
  10. package/dist/evolution.d.ts.map +1 -1
  11. package/dist/evolution.js +29 -633
  12. package/dist/evolution.js.map +1 -1
  13. package/dist/finance.d.ts +5 -72
  14. package/dist/finance.d.ts.map +1 -1
  15. package/dist/finance.js +233 -737
  16. package/dist/finance.js.map +1 -1
  17. package/dist/index.d.ts +7 -18
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -12
  20. package/dist/index.js.map +1 -1
  21. package/dist/instrument-references.d.ts.map +1 -1
  22. package/dist/instrument-references.js +3 -2
  23. package/dist/instrument-references.js.map +1 -1
  24. package/dist/schema.d.ts +2079 -4564
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +270 -1609
  27. package/dist/schema.js.map +1 -1
  28. package/dist/validation.d.ts +8 -104
  29. package/dist/validation.d.ts.map +1 -1
  30. package/dist/validation.js +653 -3271
  31. package/dist/validation.js.map +1 -1
  32. package/docs/README.md +147 -14
  33. package/package.json +7 -11
  34. package/spec/README.md +151 -164
  35. package/spec/darb.udl.json +249 -0
  36. package/spec/udl.schema.json +4104 -3202
  37. package/src/diagnostics.ts +51 -246
  38. package/src/evolution.ts +39 -1045
  39. package/src/finance.ts +287 -1127
  40. package/src/index.ts +18 -142
  41. package/src/instrument-references.ts +3 -2
  42. package/src/schema.ts +285 -1843
  43. package/src/validation.ts +870 -5611
  44. package/conformance/README.md +0 -82
  45. package/conformance/evolution/action-contract.expected.json +0 -10
  46. package/conformance/evolution/action-contract.live.udl +0 -44
  47. package/conformance/evolution/action-contract.next.udl +0 -45
  48. package/conformance/evolution/product-identity.expected.json +0 -10
  49. package/conformance/evolution/product-identity.live.udl +0 -55
  50. package/conformance/evolution/product-identity.next.udl +0 -55
  51. package/conformance/evolution/version-required.expected.json +0 -10
  52. package/conformance/evolution/version-required.live.udl +0 -55
  53. package/conformance/evolution/version-required.next.udl +0 -58
  54. package/conformance/invalid/action-without-transition.expected.json +0 -10
  55. package/conformance/invalid/action-without-transition.udl +0 -60
  56. package/conformance/invalid/agent-description-too-long.expected.json +0 -10
  57. package/conformance/invalid/agent-description-too-long.udl +0 -57
  58. package/conformance/invalid/blank-title.expected.json +0 -10
  59. package/conformance/invalid/blank-title.udl +0 -54
  60. package/conformance/invalid/call-binds-results.expected.json +0 -10
  61. package/conformance/invalid/call-binds-results.udl +0 -314
  62. package/conformance/invalid/call-unknown-action.expected.json +0 -10
  63. package/conformance/invalid/call-unknown-action.udl +0 -314
  64. package/conformance/invalid/composition-dial-duplicate-key.expected.json +0 -10
  65. package/conformance/invalid/composition-dial-duplicate-key.udl +0 -73
  66. package/conformance/invalid/depth-budget.expected.json +0 -10
  67. package/conformance/invalid/depth-budget.udl +0 -49
  68. package/conformance/invalid/duplicate-subject.expected.json +0 -10
  69. package/conformance/invalid/duplicate-subject.udl +0 -230
  70. package/conformance/invalid/forged-effects.expected.json +0 -10
  71. package/conformance/invalid/forged-effects.udl +0 -76
  72. package/conformance/invalid/format-version.expected.json +0 -10
  73. package/conformance/invalid/format-version.udl +0 -54
  74. package/conformance/invalid/instrument-id-not-snake-case.expected.json +0 -10
  75. package/conformance/invalid/instrument-id-not-snake-case.udl +0 -54
  76. package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +0 -10
  77. package/conformance/invalid/invalid-aggregate-gate-shape.udl +0 -1819
  78. package/conformance/invalid/invalid-check-duration.expected.json +0 -10
  79. package/conformance/invalid/invalid-check-duration.udl +0 -219
  80. package/conformance/invalid/invalid-dial-anchor.expected.json +0 -10
  81. package/conformance/invalid/invalid-dial-anchor.udl +0 -219
  82. package/conformance/invalid/invalid-exception-parent-ref.expected.json +0 -10
  83. package/conformance/invalid/invalid-exception-parent-ref.udl +0 -2487
  84. package/conformance/invalid/invalid-exposure-shape.expected.json +0 -10
  85. package/conformance/invalid/invalid-exposure-shape.udl +0 -1819
  86. package/conformance/invalid/invalid-journeys.expected.json +0 -10
  87. package/conformance/invalid/invalid-journeys.udl +0 -77
  88. package/conformance/invalid/invalid-remainder.expected.json +0 -10
  89. package/conformance/invalid/invalid-remainder.udl +0 -220
  90. package/conformance/invalid/invalid-schema-keyword.expected.json +0 -10
  91. package/conformance/invalid/invalid-schema-keyword.udl +0 -220
  92. package/conformance/invalid/invalid-utf8.expected.json +0 -10
  93. package/conformance/invalid/invalid-utf8.udl +0 -1
  94. package/conformance/invalid/leaf-effect-mismatch.expected.json +0 -10
  95. package/conformance/invalid/leaf-effect-mismatch.udl +0 -314
  96. package/conformance/invalid/malformed-json.expected.json +0 -10
  97. package/conformance/invalid/malformed-json.udl +0 -1
  98. package/conformance/invalid/missing-create-action.expected.json +0 -10
  99. package/conformance/invalid/missing-create-action.udl +0 -48
  100. package/conformance/invalid/missing-exception-amount-field.expected.json +0 -10
  101. package/conformance/invalid/missing-exception-amount-field.udl +0 -2487
  102. package/conformance/invalid/missing-exception-contract.expected.json +0 -14
  103. package/conformance/invalid/missing-exception-contract.udl +0 -2523
  104. package/conformance/invalid/missing-exception-reason-field.expected.json +0 -10
  105. package/conformance/invalid/missing-exception-reason-field.udl +0 -2487
  106. package/conformance/invalid/not-an-object.expected.json +0 -10
  107. package/conformance/invalid/not-an-object.udl +0 -1
  108. package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +0 -10
  109. package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +0 -259
  110. package/conformance/invalid/piece-plan-without-partition.expected.json +0 -10
  111. package/conformance/invalid/piece-plan-without-partition.udl +0 -305
  112. package/conformance/invalid/private-action-independent-approval.expected.json +0 -10
  113. package/conformance/invalid/private-action-independent-approval.udl +0 -314
  114. package/conformance/invalid/quote-freeze-set-incomplete.expected.json +0 -10
  115. package/conformance/invalid/quote-freeze-set-incomplete.udl +0 -261
  116. package/conformance/invalid/quote-named-reference-gate.expected.json +0 -10
  117. package/conformance/invalid/quote-named-reference-gate.udl +0 -50
  118. package/conformance/invalid/reconcile-named-reference-gate.expected.json +0 -10
  119. package/conformance/invalid/reconcile-named-reference-gate.udl +0 -50
  120. package/conformance/invalid/unfund-order-not-reversed.expected.json +0 -10
  121. package/conformance/invalid/unfund-order-not-reversed.udl +0 -314
  122. package/conformance/invalid/unknown-key.expected.json +0 -10
  123. package/conformance/invalid/unknown-key.udl +0 -55
  124. package/conformance/invalid/unknown-reference-gate-field.expected.json +0 -10
  125. package/conformance/invalid/unknown-reference-gate-field.udl +0 -1819
  126. package/conformance/invalid/unknown-required-field.expected.json +0 -10
  127. package/conformance/invalid/unknown-required-field.udl +0 -220
  128. package/conformance/invalid/unreachable-state.expected.json +0 -10
  129. package/conformance/invalid/unreachable-state.udl +0 -55
  130. package/conformance/invalid/wrong-exception-amount-field.expected.json +0 -10
  131. package/conformance/invalid/wrong-exception-amount-field.udl +0 -2487
  132. package/conformance/invalid/wrong-exception-reason-field.expected.json +0 -10
  133. package/conformance/invalid/wrong-exception-reason-field.udl +0 -2487
  134. package/conformance/valid/agent-description.expected.json +0 -6
  135. package/conformance/valid/agent-description.udl +0 -66
  136. package/conformance/valid/attested.expected.json +0 -6
  137. package/conformance/valid/attested.udl +0 -251
  138. package/conformance/valid/cards.expected.json +0 -6
  139. package/conformance/valid/cards.udl +0 -1579
  140. package/conformance/valid/commerce-escrow.expected.json +0 -6
  141. package/conformance/valid/commerce-escrow.udl +0 -1512
  142. package/conformance/valid/compiled-crowdfunding.expected.json +0 -6
  143. package/conformance/valid/compiled-crowdfunding.udl +0 -1843
  144. package/conformance/valid/compiled-watch-club.expected.json +0 -6
  145. package/conformance/valid/compiled-watch-club.udl +0 -2486
  146. package/conformance/valid/complete-contract.expected.json +0 -6
  147. package/conformance/valid/complete-contract.udl +0 -218
  148. package/conformance/valid/effect-signatures.expected.json +0 -6
  149. package/conformance/valid/effect-signatures.udl +0 -75
  150. package/conformance/valid/hand-edited.expected.json +0 -6
  151. package/conformance/valid/hand-edited.udl +0 -1
  152. package/conformance/valid/insured-car-marketplace.expected.json +0 -6
  153. package/conformance/valid/insured-car-marketplace.udl +0 -1050
  154. package/conformance/valid/insured-travel.expected.json +0 -6
  155. package/conformance/valid/insured-travel.udl +0 -3469
  156. package/conformance/valid/minimal.expected.json +0 -6
  157. package/conformance/valid/minimal.udl +0 -62
  158. package/conformance/valid/piece-plan-calls.expected.json +0 -6
  159. package/conformance/valid/piece-plan-calls.udl +0 -314
  160. package/conformance/valid/protection.expected.json +0 -6
  161. package/conformance/valid/protection.udl +0 -1551
  162. package/conformance/valid/string-escaping.expected.json +0 -6
  163. package/conformance/valid/string-escaping.udl +0 -54
  164. package/conformance/valid/vocabulary.expected.json +0 -6
  165. package/conformance/valid/vocabulary.udl +0 -2012
  166. package/dist/allocation.d.ts +0 -60
  167. package/dist/allocation.d.ts.map +0 -1
  168. package/dist/allocation.js +0 -177
  169. package/dist/allocation.js.map +0 -1
  170. package/dist/check-profiles.d.ts +0 -57
  171. package/dist/check-profiles.d.ts.map +0 -1
  172. package/dist/check-profiles.js +0 -62
  173. package/dist/check-profiles.js.map +0 -1
  174. package/dist/distribution.d.ts +0 -15
  175. package/dist/distribution.d.ts.map +0 -1
  176. package/dist/distribution.js +0 -49
  177. package/dist/distribution.js.map +0 -1
  178. package/dist/effects.d.ts +0 -58
  179. package/dist/effects.d.ts.map +0 -1
  180. package/dist/effects.js +0 -1126
  181. package/dist/effects.js.map +0 -1
  182. package/dist/reference.d.ts +0 -3
  183. package/dist/reference.d.ts.map +0 -1
  184. package/dist/reference.js +0 -28
  185. package/dist/reference.js.map +0 -1
  186. package/dist/vocabulary.d.ts +0 -23
  187. package/dist/vocabulary.d.ts.map +0 -1
  188. package/dist/vocabulary.js +0 -1052
  189. package/dist/vocabulary.js.map +0 -1
  190. package/docs/funding-custody.md +0 -165
  191. package/docs/guide/01-a-document.md +0 -37
  192. package/docs/guide/02-money-steps.md +0 -23
  193. package/docs/guide/03-laws.md +0 -18
  194. package/docs/guide/04-fees-and-remainder.md +0 -36
  195. package/docs/guide/05-checks-updates-dials.md +0 -7
  196. package/docs/guide/06-effects.md +0 -11
  197. package/docs/guide/07-evolution.md +0 -11
  198. package/docs/guide/08-implementing.md +0 -13
  199. package/docs/guide/09-schedules-and-allocation.md +0 -132
  200. package/docs/llms-full.txt +0 -2008
  201. package/docs/llms.txt +0 -14
  202. package/docs/piece-plans.md +0 -148
  203. package/docs/reference/canonical.md +0 -16
  204. package/docs/reference/clauses.md +0 -1585
  205. package/docs/reference/cli.md +0 -24
  206. package/docs/reference/diagnostics.md +0 -38
  207. package/skills/udl/SKILL.md +0 -28
  208. package/src/allocation.ts +0 -259
  209. package/src/check-profiles.ts +0 -80
  210. package/src/distribution.ts +0 -61
  211. package/src/effects.ts +0 -1920
  212. package/src/reference.ts +0 -31
  213. package/src/vocabulary.ts +0 -1635
package/src/finance.ts CHANGED
@@ -1,1164 +1,324 @@
1
- import type { UdlBinding } from "./schema.js";
2
- import { UDL_LIMITS } from "./limits.js";
3
- import type { UdlIssueCode } from "./diagnostics.js";
1
+ import type {
2
+ UdlAction,
3
+ UdlDocument,
4
+ UdlInstrument,
5
+ UdlValue,
6
+ } from "./schema.js";
4
7
 
5
8
  export interface FinanceIssue {
6
- readonly code: UdlIssueCode;
7
- readonly message: string;
8
- readonly path: readonly PropertyKey[];
9
- }
10
-
11
- export interface FinanceOptions {
12
- readonly penaltyMayBeNonzero?: boolean;
13
- }
14
-
15
- interface FinancialStep {
16
- readonly operation: string;
17
- readonly bind: Readonly<Record<string, UdlBinding>>;
18
- readonly capture?: Readonly<Record<string, string>> | undefined;
19
- }
20
-
21
- interface FinancialMove extends FinancialStep {
22
- readonly key: string;
23
- }
24
-
25
- export interface FinancialPartition {
26
- readonly totalField: string;
27
- readonly pieceFields: readonly string[];
28
- }
29
-
30
- export interface FinancialContribution {
31
- readonly field: string;
32
- readonly amountKey: string;
33
- readonly accountKey: string;
34
- readonly totalField: string;
35
- }
36
-
37
- export interface FinancialContributionStage {
38
- readonly stage: "fund" | "refund";
39
- readonly accountPath: string;
40
- }
41
-
42
- export interface FinancialInstrument {
43
- readonly lifecycle: {
44
- readonly initial: string;
45
- readonly states: readonly string[];
46
- readonly transitions: Readonly<
47
- Record<string, { readonly from: readonly string[]; readonly to: string }>
48
- >;
49
- };
50
- readonly parties?:
51
- | {
52
- readonly beneficiary?: string | undefined;
53
- readonly payer?: string | undefined;
54
- readonly subjectHolder?: string | undefined;
55
- }
56
- | undefined;
57
- readonly partitions?: readonly FinancialPartition[] | undefined;
58
- readonly contributions?: FinancialContribution | undefined;
59
- readonly actions: Readonly<
60
- Record<
61
- string,
62
- {
63
- readonly commit?: string | undefined;
64
- readonly moves?: readonly FinancialMove[];
65
- readonly quote?:
66
- | {
67
- readonly baseField: string;
68
- readonly chargeRef: string;
69
- readonly chargeRetainedBy?:
70
- | "payer"
71
- | "beneficiary"
72
- | "subjectHolder"
73
- | undefined;
74
- readonly charges: readonly { readonly bps: number }[];
75
- readonly netRef: string;
76
- }
77
- | undefined;
78
- readonly steps: readonly FinancialStep[];
79
- readonly contributionStage?: FinancialContributionStage | undefined;
80
- }
81
- >
82
- >;
83
- }
84
-
85
- /**
86
- * One quote-commit pair, flattened for the typestate. The quoting action prices
87
- * `baseField` into the charge and the net; the committing action spends the net
88
- * out of the account that funded the base.
89
- */
90
- interface QuoteFacts {
91
- readonly baseField: string;
92
- readonly chargeRef: string;
93
- readonly chargeRetainedBy?:
94
- | "payer"
95
- | "beneficiary"
96
- | "subjectHolder"
97
- | undefined;
98
- readonly chargeCanBeNonzero: boolean;
99
- readonly commit: string;
100
- readonly netRef: string;
101
- readonly quoting: string;
102
- }
103
-
104
- type Balance =
105
- | { readonly kind: "empty" }
106
- | { readonly kind: "funded"; readonly amounts: readonly string[] }
107
- | { readonly kind: "penalty" }
108
- | { readonly kind: "unknown" };
109
-
110
- interface AccountState {
111
- readonly balance: Balance;
112
- readonly holds: Readonly<Record<string, Balance>>;
113
- readonly exchanged?: boolean | undefined;
114
- }
115
-
116
- interface Reservation {
117
- readonly amount: string;
118
- readonly destination?: string;
119
- readonly source?: string;
120
- }
121
-
122
- interface Effect {
123
- readonly amount: string;
124
- /** The quoted base a refund effect returns; absent on every other kind. */
125
- readonly refundable?: string;
126
- readonly kind:
127
- | "credit"
128
- | "debit"
129
- | "dynamic_debit"
130
- | "incoming_reserve"
131
- | "outgoing_reserve"
132
- | "penalty_debit"
133
- | "post"
134
- | "refund"
135
- | "void";
136
- readonly path: readonly PropertyKey[];
137
- readonly reservation?: string;
138
- }
139
-
140
- const EMPTY: Balance = { kind: "empty" };
141
-
142
- const UNKNOWN: Balance = { kind: "unknown" };
143
-
144
- // A funded balance is the exact set of amount identities currently held.
145
- // Distinct identities stack (a partitioned funding); re-crediting an identity
146
- // already present degrades to unknown, which keeps the state space finite.
147
- function funded(amounts: readonly string[]): Balance {
148
- if (amounts.length === 0) return EMPTY;
149
- return { kind: "funded", amounts: [...amounts].sort() };
150
- }
151
-
152
- function withoutPiece(
153
- amounts: readonly string[],
154
- amount: string,
155
- ): readonly string[] {
156
- const index = amounts.indexOf(amount);
157
- return amounts.filter((_, position) => position !== index);
158
- }
159
-
160
- /**
161
- * One financial typestate owner for canonical UDL and static instrument contracts.
162
- * It proves a tracked escrow account holds an exact set of funded amounts,
163
- * models held transfers through reserve/post/void, consumes every direct
164
- * debit piece by piece, and makes the quoted refund's sole remainder an
165
- * exactly-once penalty payout.
166
- */
167
- export function analyzeInstrumentFinance(
168
- instrument: FinancialInstrument,
169
- options: FinanceOptions = {},
170
- ): readonly FinanceIssue[] {
171
- const issues: FinanceIssue[] = [];
172
- const seenIssues = new Set<string>();
173
- const add = (
174
- path: readonly PropertyKey[],
175
- message: string,
176
- code: UdlIssueCode = "UDL4001",
177
- ): void => {
178
- const key = `${path.join(".")}\0${message}`;
179
- if (seenIssues.has(key)) return;
180
- seenIssues.add(key);
181
- issues.push({ code, message, path });
182
- };
183
-
184
- const admissionProblem = financeAdmissionProblem(instrument);
185
- if (admissionProblem) {
186
- add(["lifecycle"], admissionProblem, "UDL1004");
187
- return issues;
188
- }
189
-
190
- const trackedAccounts = productEscrowAccounts(instrument);
191
- const quotes = quoteFacts(instrument, options);
192
- const refundSources = new Map<string, string>();
193
- for (const quote of quotes) {
194
- const commitTransfer = instrument.actions[quote.commit]?.moves?.filter(
195
- (move) => move.operation === "internal_transfer.create",
196
- )[0];
197
- const source = canonicalAccount(
198
- instrument,
199
- commitTransfer?.bind.sourceAccountId,
200
- );
201
- if (!source) continue;
202
- refundSources.set(quote.commit, source);
203
- trackedAccounts.add(source);
204
- }
205
- const refundSourceAccounts = new Set(refundSources.values());
206
- for (const quote of quotes) {
207
- validateChargePayout(
208
- instrument,
209
- quote,
210
- refundSources.get(quote.commit),
211
- add,
212
- );
213
- }
214
- if (trackedAccounts.size === 0) return issues;
215
- if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
216
- add(
217
- ["actions"],
218
- `financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`,
219
- "UDL1004",
220
- );
221
- return issues;
9
+ path: string;
10
+ message: string;
11
+ }
12
+ type Sum = Map<string, bigint>;
13
+ type Balance = Sum | null;
14
+ interface Hold {
15
+ from: string;
16
+ to: string;
17
+ amount: Balance;
18
+ }
19
+ interface State {
20
+ status: string;
21
+ balances: Map<string, Balance>;
22
+ holds: Map<string, Hold>;
23
+ }
24
+ const add = (a: Balance, b: Balance, sign = 1n): Balance => {
25
+ if (a === null || b === null) return null;
26
+ const result = new Map(a);
27
+ for (const [key, value] of b) {
28
+ const next = (result.get(key) ?? 0n) + value * sign;
29
+ if (next) result.set(key, next);
30
+ else result.delete(key);
222
31
  }
223
-
224
- const reservations = reservationsByKey(instrument);
225
-
226
- let pathVariants = 0;
227
- let work = 0;
228
- for (const account of trackedAccounts) {
229
- const effects = effectsByAction(instrument, account, reservations, quotes);
230
- const createEffects = effects.get("create") ?? [];
231
- work += 1 + createEffects.length;
232
- if (work > UDL_LIMITS.financeWork) {
233
- add(
234
- ["lifecycle"],
235
- `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
236
- "UDL1004",
32
+ return result;
33
+ };
34
+ const show = (sum: Balance): string =>
35
+ sum === null
36
+ ? "unknown"
37
+ : JSON.stringify(
38
+ [...sum]
39
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
40
+ .map(([key, value]) => [key, String(value)]),
237
41
  );
238
- return issues;
239
- }
240
- const initial = applyEffects(
241
- instrument,
242
- account,
243
- "create",
244
- { balance: EMPTY, holds: {} },
245
- createEffects,
246
- reservations,
247
- refundSourceAccounts.has(account),
248
- add,
249
- );
250
- const states = new Map<string, Map<string, AccountState>>([
251
- [instrument.lifecycle.initial, new Map([[stateKey(initial), initial]])],
252
- ]);
253
- pathVariants += 1;
254
- const pending = [instrument.lifecycle.initial];
255
- while (pending.length > 0) {
256
- const from = pending.shift() as string;
257
- const sourceStates = states.get(from);
258
- if (!sourceStates) continue;
259
- for (const [action, transition] of Object.entries(
260
- instrument.lifecycle.transitions,
261
- )) {
262
- if (!transition.from.includes(from)) continue;
263
- const targetStates = states.get(transition.to) ?? new Map();
264
- states.set(transition.to, targetStates);
265
- let grew = false;
266
- for (const sourceState of sourceStates.values()) {
267
- const transitionEffects = effects.get(action) ?? [];
268
- work += 1 + transitionEffects.length;
269
- if (work > UDL_LIMITS.financeWork) {
270
- add(
271
- ["lifecycle"],
272
- `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
273
- "UDL1004",
274
- );
275
- return issues;
276
- }
277
- const targetState = applyEffects(
278
- instrument,
279
- account,
280
- action,
281
- sourceState,
282
- transitionEffects,
283
- reservations,
284
- refundSourceAccounts.has(account),
285
- add,
286
- );
287
- const key = stateKey(targetState);
288
- if (targetStates.has(key)) continue;
289
- targetStates.set(key, targetState);
290
- pathVariants += 1;
291
- if (pathVariants > UDL_LIMITS.financePathVariants) {
292
- add(
293
- ["lifecycle"],
294
- `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`,
295
- "UDL1004",
296
- );
297
- return issues;
298
- }
299
- grew = true;
300
- }
301
- if (grew && !pending.includes(transition.to))
302
- pending.push(transition.to);
303
- }
304
- }
305
- const nonterminalStates = new Set(
306
- Object.values(instrument.lifecycle.transitions).flatMap(
307
- (transition) => transition.from,
308
- ),
309
- );
310
- if (account.startsWith("ref:")) {
311
- for (const [state, variants] of states) {
312
- if (nonterminalStates.has(state)) continue;
313
- for (const variant of variants.values()) {
314
- if (
315
- variant.balance.kind === "empty" &&
316
- Object.keys(variant.holds).length === 0
317
- ) {
318
- continue;
319
- }
320
- add(
321
- ["lifecycle", "states", instrument.lifecycle.states.indexOf(state)],
322
- `terminal state ${state} can strand value in ${formatAccount(account)}`,
323
- );
324
- }
325
- }
326
- }
327
- }
328
-
329
- return issues;
330
- }
42
+ const same = (a: Balance, b: Balance) => show(a) === show(b);
331
43
 
332
- export function financeAdmissionProblem(
333
- instrument: FinancialInstrument,
334
- ): string | undefined {
335
- if (instrument.lifecycle.states.length > UDL_LIMITS.financeStates) {
336
- return `financial analysis exceeds ${UDL_LIMITS.financeStates} lifecycle states`;
337
- }
338
- let transitionCount = 0;
339
- let transitionEdges = 0;
340
- for (const name in instrument.lifecycle.transitions) {
341
- if (!Object.hasOwn(instrument.lifecycle.transitions, name)) continue;
342
- transitionCount += 1;
343
- if (transitionCount > UDL_LIMITS.financeTransitions) {
344
- return `financial analysis exceeds ${UDL_LIMITS.financeTransitions} lifecycle transitions`;
345
- }
346
- transitionEdges += instrument.lifecycle.transitions[name]?.from.length ?? 0;
347
- if (transitionEdges > UDL_LIMITS.financeTransitionEdges) {
348
- return `financial analysis exceeds ${UDL_LIMITS.financeTransitionEdges} lifecycle transition edges`;
349
- }
44
+ /** Join loop balances conservatively; a balance equality proves an unknown balance empty. */
45
+ export function analyzeInstrumentFinance(
46
+ instrument: UdlInstrument,
47
+ document?: UdlDocument,
48
+ ): FinanceIssue[] {
49
+ const owned = new Set(
50
+ instrument.fields
51
+ .filter((f) => f.type === "account" && f.owner === "self")
52
+ .map((f) => `self.${f.name}`),
53
+ );
54
+ const shared = new Set<string>();
55
+ for (const owner of document?.instruments ?? []) {
56
+ if (owner.id === instrument.id) continue;
57
+ for (const action of Object.values(owner.actions))
58
+ for (const move of action.moves)
59
+ if ("amount" in move)
60
+ for (const endpoint of [move.from, move.to]) {
61
+ const parts = endpoint.split(".");
62
+ if (parts.shift() !== "self") continue;
63
+ let targets = [owner];
64
+ while (parts.length > 1) {
65
+ const key = parts.shift();
66
+ targets = targets.flatMap((target) => {
67
+ const reference = target.fields.find(
68
+ (field) => field.name === key,
69
+ );
70
+ if (reference?.type !== "ref") return [];
71
+ const ids =
72
+ typeof reference.target === "string"
73
+ ? [reference.target]
74
+ : reference.target;
75
+ return (
76
+ document?.instruments.filter((candidate) =>
77
+ ids.includes(candidate.id),
78
+ ) ?? []
79
+ );
80
+ });
81
+ }
82
+ if (
83
+ parts.length === 1 &&
84
+ targets.some((target) => target.id === instrument.id) &&
85
+ owned.has(`self.${parts[0]}`)
86
+ )
87
+ shared.add(`self.${parts[0]}`);
88
+ }
350
89
  }
351
- let actionCount = 0;
352
- let effectCount = 0;
353
- for (const name in instrument.actions) {
354
- if (!Object.hasOwn(instrument.actions, name)) continue;
355
- actionCount += 1;
356
- if (actionCount > UDL_LIMITS.financeActions) {
357
- return `financial analysis exceeds ${UDL_LIMITS.financeActions} actions`;
358
- }
359
- const action = instrument.actions[name];
360
- effectCount += (action?.steps.length ?? 0) + (action?.moves?.length ?? 0);
361
- if (effectCount > UDL_LIMITS.financeEffects) {
362
- return `financial analysis exceeds ${UDL_LIMITS.financeEffects} kernel effects`;
90
+ const problems: FinanceIssue[] = [];
91
+ const report = (message: string) =>
92
+ problems.push({ path: ".actions", message });
93
+ const equations = new Map<string, Sum>();
94
+ for (const field of instrument.fields)
95
+ if (field.type === "money" && field.value !== undefined)
96
+ equations.set(
97
+ `self.${field.name}`,
98
+ new Map([["#", BigInt(field.value)]]),
99
+ );
100
+ for (const calc of instrument.calculate) {
101
+ if (calc.op === "sum" || calc.op === "subtract") {
102
+ const terms =
103
+ calc.op === "sum"
104
+ ? calc.values.map((v) => [v, 1n] as const)
105
+ : [
106
+ [calc.base, 1n] as const,
107
+ ...calc.subtract.map((v) => [v, -1n] as const),
108
+ ];
109
+ let result: Balance = new Map();
110
+ for (const [value, sign] of terms)
111
+ result = add(result, raw(value, "create"), sign);
112
+ equations.set(`self.${calc.target}`, result!);
363
113
  }
364
114
  }
365
- return undefined;
366
- }
367
-
368
- function productEscrowAccounts(instrument: FinancialInstrument): Set<string> {
369
- const accounts = new Set<string>();
370
- for (const action of Object.values(instrument.actions)) {
371
- for (const step of action.steps) {
372
- // This operation fixes the escrow role; an omitted redundant binding
373
- // must not exempt its captured account from the conservation proof.
374
- if (step.operation !== "account.escrow.provision") continue;
375
- for (const [key, result] of Object.entries(step.capture ?? {})) {
376
- if (result === "accountId") accounts.add(`ref:${key}`);
377
- }
378
- }
115
+ function raw(value: UdlValue, invocation: string): Sum {
116
+ return "field" in value
117
+ ? new Map([
118
+ [
119
+ value.field.startsWith("input.")
120
+ ? `${invocation}:${value.field}`
121
+ : value.field,
122
+ 1n,
123
+ ],
124
+ ])
125
+ : new Map([["#", BigInt(String(value.literal))]]);
379
126
  }
380
-
381
- // `product_escrow` also backs open-ended balance products such as wallets.
382
- // Only instance-bound amounts claim static conservation; caller-sized flows
383
- // remain runtime balance checks. Commit sources are added separately above.
384
- return new Set(
385
- [...accounts].filter((account) =>
386
- Object.values(instrument.actions).some((action) => {
387
- if (
388
- action.contributionStage &&
389
- canonicalAccountPath(
390
- instrument,
391
- action.contributionStage.accountPath,
392
- ) === account
393
- ) {
394
- return true;
395
- }
396
- return (action.moves ?? []).some((step) => {
397
- if (
398
- (step.operation !== "internal_transfer.create" &&
399
- step.operation !== "internal_transfer.reserve") ||
400
- step.bind.amount?.from !== "instance"
401
- ) {
402
- return false;
403
- }
404
- return (
405
- canonicalAccount(instrument, step.bind.sourceAccountId) ===
406
- account ||
407
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
408
- account
409
- );
410
- });
411
- }),
412
- ),
413
- );
414
- }
415
-
416
- function reservationsByKey(
417
- instrument: FinancialInstrument,
418
- ): ReadonlyMap<string, Reservation> {
419
- const reservations = new Map<string, Reservation>();
420
- for (const action of Object.values(instrument.actions)) {
421
- for (const step of action.moves ?? []) {
422
- if (step.operation !== "internal_transfer.reserve") continue;
423
- const source = canonicalAccount(instrument, step.bind.sourceAccountId);
424
- const destination = canonicalAccount(
425
- instrument,
426
- step.bind.destinationAccountId,
127
+ function expand(sum: Sum, seen = new Set<string>()): Sum {
128
+ let result: Balance = new Map();
129
+ for (const [key, coefficient] of sum) {
130
+ const equation = equations.get(key);
131
+ result = add(
132
+ result,
133
+ equation && !seen.has(key)
134
+ ? expand(equation, new Set([...seen, key]))
135
+ : new Map([[key, 1n]]),
136
+ coefficient,
427
137
  );
428
- const amount = amountIdentity(step.bind.amount);
429
- for (const [key, result] of Object.entries(step.capture ?? {})) {
430
- if (result === "transferId") {
431
- reservations.set(key, {
432
- amount,
433
- ...(destination ? { destination } : {}),
434
- ...(source ? { source } : {}),
435
- });
436
- }
437
- }
438
138
  }
139
+ return result!;
439
140
  }
440
- return reservations;
441
- }
442
-
443
- function effectsByAction(
444
- instrument: FinancialInstrument,
445
- account: string,
446
- reservations: ReadonlyMap<string, Reservation>,
447
- quotes: readonly QuoteFacts[],
448
- ): ReadonlyMap<string, readonly Effect[]> {
449
- const quoteByCommit = new Map(quotes.map((quote) => [quote.commit, quote]));
450
- const chargePaths = new Map(
451
- quotes.map((quote) => [`refs.${quote.chargeRef}`, quote]),
452
- );
453
- return new Map(
454
- Object.entries(instrument.actions).map(([actionName, action]) => {
455
- const stageEffects: Effect[] = [];
456
- if (action.contributionStage && instrument.contributions) {
457
- const stage = action.contributionStage;
458
- const stageAccount = canonicalAccountPath(
459
- instrument,
460
- stage.accountPath,
461
- );
141
+ let invocation = 0;
142
+ const apply = (state: State, action: UdlAction, name: string): void => {
143
+ const scope = `${name}#${invocation++}`;
144
+ const calculated = new Map<string, Balance>();
145
+ const local = new Map(
146
+ (action.calculate ?? []).map((c) => [`self.${c.target}`, c]),
147
+ );
148
+ const amount = (value: UdlValue, seen = new Set<string>()): Balance => {
149
+ if ("field" in value) {
150
+ if (calculated.has(value.field)) return calculated.get(value.field)!;
462
151
  if (
463
- stageAccount &&
464
- accountsMayAlias(instrument, stageAccount, account)
465
- ) {
466
- const path = ["actions", actionName, "contributionStage"] as const;
467
- const totalAmount = `fields.${instrument.contributions.totalField}`;
468
- if (stage.stage === "fund") {
469
- stageEffects.push({
470
- amount: totalAmount,
471
- kind: "credit",
472
- path,
473
- });
474
- } else if (stage.stage === "refund") {
475
- stageEffects.push({
476
- amount: totalAmount,
477
- kind: "debit",
478
- path,
479
- });
480
- }
152
+ value.field.endsWith(".balance") &&
153
+ owned.has(value.field.slice(0, -8))
154
+ )
155
+ return state.balances.get(value.field.slice(0, -8)) ?? null;
156
+ const calculation = local.get(value.field);
157
+ if (calculation && !seen.has(value.field)) {
158
+ const next = new Set([...seen, value.field]);
159
+ if (calculation.op === "sum")
160
+ return calculation.values.reduce<Balance>(
161
+ (sum, value) => add(sum, amount(value, next)),
162
+ new Map(),
163
+ );
164
+ if (calculation.op === "subtract")
165
+ return calculation.subtract.reduce<Balance>(
166
+ (sum, value) => add(sum, amount(value, next), -1n),
167
+ amount(calculation.base, next),
168
+ );
169
+ return new Map([[`${scope}:${value.field}`, 1n]]);
481
170
  }
482
171
  }
483
-
484
- const moveEffects = (action.moves ?? []).flatMap((step, stepIndex) => {
485
- const path = [
486
- "actions",
487
- actionName,
488
- "moves",
489
- stepIndex,
490
- "bind",
491
- ] as const;
492
- if (step.operation === "internal_transfer.create") {
493
- const effects: Effect[] = [];
494
- const amount = amountIdentity(step.bind.amount);
495
- if (
496
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
497
- account
498
- ) {
499
- effects.push({ amount, kind: "credit", path });
500
- }
501
- const source = canonicalAccount(
502
- instrument,
503
- step.bind.sourceAccountId,
504
- );
505
- if (source && accountsMayAlias(instrument, source, account)) {
506
- const committed = quoteByCommit.get(actionName);
507
- const amountPath =
508
- step.bind.amount?.from === "instance"
509
- ? step.bind.amount.path
510
- : undefined;
511
- const refunded =
512
- committed && amountPath === `refs.${committed.netRef}`
513
- ? committed
514
- : undefined;
515
- const charged = amountPath
516
- ? chargePaths.get(amountPath)
517
- : undefined;
518
- const kind = refunded
519
- ? "refund"
520
- : charged
521
- ? "penalty_debit"
522
- : amountPath !== undefined
523
- ? "debit"
524
- : "dynamic_debit";
525
- effects.push({
526
- amount:
527
- refunded && !refunded.chargeCanBeNonzero
528
- ? "refund_without_penalty"
529
- : amount,
530
- kind,
531
- path,
532
- ...(refunded ? { refundable: refunded.baseField } : {}),
533
- });
534
- }
535
- return effects;
536
- }
537
- if (step.operation === "internal_transfer.reserve") {
538
- const reservation =
539
- transferRefKey(step) ?? `${actionName}:${stepIndex}`;
540
- const effects: Effect[] = [];
541
- const source = canonicalAccount(
542
- instrument,
543
- step.bind.sourceAccountId,
544
- );
545
- if (source && accountsMayAlias(instrument, source, account)) {
546
- effects.push({
547
- amount: amountIdentity(step.bind.amount),
548
- kind: "outgoing_reserve",
549
- path,
550
- reservation,
551
- });
552
- }
553
- if (
554
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
555
- account
556
- ) {
557
- effects.push({
558
- amount: amountIdentity(step.bind.amount),
559
- kind: "incoming_reserve",
560
- path,
561
- reservation,
562
- });
563
- }
564
- return effects;
565
- }
566
- if (
567
- step.operation === "internal_transfer.post" ||
568
- step.operation === "internal_transfer.void"
569
- ) {
570
- const reservation = boundTransferRefKey(step);
571
- if (!reservation) return [];
572
- const reserved = reservations.get(reservation);
573
- if (
574
- !reserved ||
575
- (!(
576
- reserved.source &&
577
- accountsMayAlias(instrument, reserved.source, account)
578
- ) &&
579
- reserved.destination !== account)
580
- ) {
581
- return [];
172
+ return expand(raw(value, scope));
173
+ };
174
+ for (const requirement of action.requires)
175
+ if (requirement.kind === "compare" && requirement.operator === "==") {
176
+ for (const [left, right] of [
177
+ [requirement.left, requirement.right],
178
+ [requirement.right, requirement.left],
179
+ ] as const) {
180
+ if ("field" in left && left.field.endsWith(".balance")) {
181
+ const account = left.field.slice(0, -8);
182
+ if (owned.has(account)) state.balances.set(account, amount(right));
582
183
  }
583
- return [
584
- {
585
- amount: reserved.amount,
586
- kind:
587
- step.operation === "internal_transfer.post" ? "post" : "void",
588
- path,
589
- reservation,
590
- } satisfies Effect,
591
- ];
592
- }
593
- return [];
594
- });
595
- return [actionName, [...stageEffects, ...moveEffects]];
596
- }),
597
- );
598
- }
599
-
600
- interface NormalizedPartition {
601
- readonly total: string;
602
- readonly pieces: readonly string[];
603
- }
604
-
605
- function getInstrumentPartitions(
606
- instrument: FinancialInstrument,
607
- ): readonly NormalizedPartition[] {
608
- if (!instrument.partitions) return [];
609
- const partitions: NormalizedPartition[] = [];
610
- for (const p of instrument.partitions) {
611
- if (!p.totalField || !p.pieceFields || p.pieceFields.length === 0) continue;
612
- const total = `fields.${p.totalField}`;
613
- const pieces = p.pieceFields.map((f) => `fields.${f}`);
614
- if (new Set(pieces).size !== pieces.length) continue;
615
- if (pieces.includes(total) && pieces.length > 1) continue;
616
- partitions.push({ total, pieces });
617
- }
618
- return partitions;
619
- }
620
-
621
- function tryPartitionSubstitution(
622
- instrument: FinancialInstrument,
623
- balance: Balance,
624
- holds: Readonly<Record<string, Balance>>,
625
- exchanged: boolean,
626
- targetAmount: string,
627
- ): Balance | null {
628
- if (exchanged) return null;
629
- if (balance.kind !== "funded") return null;
630
- if (Object.keys(holds).length > 0) return null;
631
-
632
- const declaredPartitions = getInstrumentPartitions(instrument);
633
- if (declaredPartitions.length === 0) return null;
634
-
635
- for (const pOut of declaredPartitions) {
636
- const total = pOut.total;
637
-
638
- let candidateOutPieces: readonly string[] | null = null;
639
- if (pOut.pieces.includes(targetAmount)) {
640
- candidateOutPieces = pOut.pieces;
641
- } else if (targetAmount === total) {
642
- candidateOutPieces = [total];
643
- }
644
- if (!candidateOutPieces) continue;
645
-
646
- if (candidateOutPieces.length === 0) continue;
647
- if (new Set(candidateOutPieces).size !== candidateOutPieces.length)
648
- continue;
649
-
650
- const incomingCandidates: (readonly string[])[] = [];
651
-
652
- if (balance.amounts.includes(total)) {
653
- incomingCandidates.push([total]);
654
- }
655
-
656
- for (const pIn of declaredPartitions) {
657
- if (pIn.total !== total) continue;
658
- if (pIn.pieces.every((piece) => balance.amounts.includes(piece))) {
659
- incomingCandidates.push(pIn.pieces);
660
- }
661
- }
662
-
663
- for (const candidateInPieces of incomingCandidates) {
664
- if (
665
- candidateInPieces.length === candidateOutPieces.length &&
666
- candidateInPieces.every((piece) => candidateOutPieces!.includes(piece))
667
- ) {
668
- continue;
669
- }
670
-
671
- if (
672
- balance.amounts.length === candidateInPieces.length &&
673
- candidateInPieces.every((piece) => balance.amounts.includes(piece))
674
- ) {
675
- return funded(candidateOutPieces);
676
- }
677
- }
678
- }
679
-
680
- return null;
681
- }
682
-
683
- function applyEffects(
684
- instrument: FinancialInstrument,
685
- account: string,
686
- action: string,
687
- input: AccountState,
688
- effects: readonly Effect[],
689
- reservations: ReadonlyMap<string, Reservation>,
690
- exactBalance: boolean,
691
- add: (path: readonly PropertyKey[], message: string) => void,
692
- ): AccountState {
693
- let balance = input.balance;
694
- const holds = { ...input.holds };
695
- let exchanged = input.exchanged ?? false;
696
- const accountLabel = formatAccount(account);
697
- for (const effect of effects) {
698
- const fail = (message: string): void =>
699
- add([...effect.path, "sourceAccountId"], `action ${action} ${message}`);
700
- if (effect.kind === "credit") {
701
- if (
702
- exactBalance &&
703
- (balance.kind !== "empty" || Object.keys(holds).length > 0)
704
- ) {
705
- add(
706
- [...effect.path, "destinationAccountId"],
707
- `action ${action} funds ${accountLabel} while earlier value may remain; quoted funding must establish exactly one refundable balance`,
708
- );
709
- balance = UNKNOWN;
710
- continue;
711
- }
712
- const pieces = balance.kind === "funded" ? balance.amounts : [];
713
- const stacks =
714
- (balance.kind === "empty" || balance.kind === "funded") &&
715
- !pieces.includes(effect.amount);
716
- balance = stacks ? funded([...pieces, effect.amount]) : UNKNOWN;
717
- continue;
718
- }
719
- if (effect.kind === "incoming_reserve") continue;
720
- if (effect.kind === "outgoing_reserve") {
721
- let held =
722
- balance.kind === "funded" && balance.amounts.includes(effect.amount);
723
- if (!held && balance.kind === "funded") {
724
- const substituted = tryPartitionSubstitution(
725
- instrument,
726
- balance,
727
- holds,
728
- exchanged,
729
- effect.amount,
730
- );
731
- if (substituted !== null) {
732
- exchanged = true;
733
- balance = substituted;
734
- held =
735
- balance.kind === "funded" &&
736
- balance.amounts.includes(effect.amount);
737
184
  }
738
185
  }
739
- if (!held) {
740
- fail(
741
- `cannot reserve ${effect.amount} from ${accountLabel}; that exact balance is not guaranteed`,
186
+ for (const key of local.keys()) calculated.set(key, amount({ field: key }));
187
+ const debit = (account: string, value: Balance) => {
188
+ if (owned.has(account))
189
+ state.balances.set(
190
+ account,
191
+ add(
192
+ state.balances.has(account)
193
+ ? state.balances.get(account)!
194
+ : new Map(),
195
+ value,
196
+ -1n,
197
+ ),
742
198
  );
743
- }
744
- holds[effect.reservation as string] = held
745
- ? { kind: "funded", amounts: [effect.amount] }
746
- : UNKNOWN;
747
- balance =
748
- held && balance.kind === "funded"
749
- ? funded(withoutPiece(balance.amounts, effect.amount))
750
- : EMPTY;
751
- continue;
752
- }
753
- if (effect.kind === "post" || effect.kind === "void") {
754
- const key = effect.reservation as string;
755
- const reservation = reservations.get(key);
756
- if (
757
- reservation?.source &&
758
- accountsMayAlias(instrument, reservation.source, account)
759
- ) {
760
- const held = holds[key];
761
- delete holds[key];
762
- if (effect.kind === "void" && held) {
763
- const pieces = balance.kind === "funded" ? balance.amounts : [];
764
- const restorable =
765
- held.kind === "funded" &&
766
- (balance.kind === "empty" ||
767
- (balance.kind === "funded" &&
768
- held.amounts.every((piece) => !pieces.includes(piece))));
769
- if (restorable && held.kind === "funded") {
770
- balance = funded([...pieces, ...held.amounts]);
771
- } else if (balance.kind === "empty") {
772
- balance = held;
773
- } else {
774
- add(
775
- [...effect.path, "transferId"],
776
- `action ${action} restores a held ${accountLabel} balance on top of existing value`,
777
- );
778
- balance = UNKNOWN;
779
- }
780
- }
781
- }
782
- if (effect.kind === "post" && reservation?.destination === account) {
783
- if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
199
+ };
200
+ const credit = (account: string, value: Balance) => {
201
+ if (owned.has(account))
202
+ state.balances.set(
203
+ account,
784
204
  add(
785
- [...effect.path, "transferId"],
786
- `action ${action} posts funding into ${accountLabel} while earlier value may remain`,
787
- );
788
- balance = UNKNOWN;
789
- } else {
790
- balance = { kind: "funded", amounts: [effect.amount] };
791
- }
792
- }
793
- continue;
794
- }
795
- if (effect.kind === "refund") {
796
- const refundable = `fields.${effect.refundable ?? ""}`;
797
- if (
798
- balance.kind !== "funded" ||
799
- balance.amounts.length !== 1 ||
800
- balance.amounts[0] !== refundable
801
- ) {
802
- fail(
803
- `cannot refund ${refundable} from ${accountLabel}; that exact balance is not guaranteed`,
205
+ state.balances.has(account)
206
+ ? state.balances.get(account)!
207
+ : new Map(),
208
+ value,
209
+ ),
804
210
  );
805
- balance = UNKNOWN;
211
+ };
212
+ for (const move of action.moves) {
213
+ if ("amount" in move) {
214
+ const value = amount(move.amount);
215
+ debit(move.from, value);
216
+ if (move.operation === "internal_transfer.reserve") {
217
+ const key = `self.${move.capture}`;
218
+ if (state.holds.has(key))
219
+ report(`${move.capture} overwrites a live reservation`);
220
+ state.holds.set(key, { from: move.from, to: move.to, amount: value });
221
+ } else credit(move.to, value);
806
222
  } else {
807
- balance =
808
- effect.amount === "refund_without_penalty"
809
- ? EMPTY
810
- : { kind: "penalty" };
811
- }
812
- continue;
813
- }
814
- if (effect.kind === "penalty_debit") {
815
- if (balance.kind !== "penalty") {
816
- fail(
817
- `cannot pay the quoted charge from ${accountLabel} before the refund leaves that exact remainder`,
223
+ const hold = state.holds.get(move.transfer);
224
+ if (!hold) {
225
+ report(`${move.key} consumes a missing reservation ${move.transfer}`);
226
+ continue;
227
+ }
228
+ credit(
229
+ move.operation === "internal_transfer.post" ? hold.to : hold.from,
230
+ hold.amount,
818
231
  );
232
+ state.holds.delete(move.transfer);
819
233
  }
820
- balance = EMPTY;
821
- continue;
822
234
  }
823
- if (effect.kind === "dynamic_debit") {
824
- if (balance.kind === "empty") {
825
- fail(`can debit unfunded ${accountLabel}`);
826
- } else if (balance.kind === "penalty") {
827
- fail(
828
- `can debit ${accountLabel} after its refund left only the quoted charge`,
829
- );
830
- }
831
- balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
832
- continue;
235
+ if (
236
+ Object.keys(action.set ?? {}).some((key) =>
237
+ instrument.fields.some((f) => f.name === key && f.type === "money"),
238
+ )
239
+ ) {
240
+ for (const account of owned) state.balances.set(account, null);
833
241
  }
834
- if (balance.kind === "funded") {
835
- if (balance.amounts.includes(effect.amount)) {
836
- balance = funded(withoutPiece(balance.amounts, effect.amount));
837
- continue;
838
- }
839
- const substituted = tryPartitionSubstitution(
840
- instrument,
841
- balance,
842
- holds,
843
- exchanged,
844
- effect.amount,
845
- );
846
- if (substituted !== null) {
847
- exchanged = true;
848
- balance = substituted;
849
- if (
850
- balance.kind === "funded" &&
851
- balance.amounts.includes(effect.amount)
852
- ) {
853
- balance = funded(withoutPiece(balance.amounts, effect.amount));
854
- continue;
242
+ };
243
+ const initial: State = {
244
+ status: instrument.lifecycle.initial,
245
+ balances: new Map([...owned].map((key) => [key, new Map()])),
246
+ holds: new Map(),
247
+ };
248
+ if (instrument.actions.create)
249
+ apply(initial, instrument.actions.create, "create");
250
+ const pending = [initial];
251
+ const reached = new Map<string, State>();
252
+ const copy = (state: State): State => ({
253
+ status: state.status,
254
+ balances: new Map(state.balances),
255
+ holds: new Map([...state.holds].map(([key, hold]) => [key, { ...hold }])),
256
+ });
257
+ let work = 0;
258
+ while (pending.length && !problems.length) {
259
+ if (++work > 4096) {
260
+ report("owned proof exceeds its finite work budget");
261
+ break;
262
+ }
263
+ let state = pending.pop()!;
264
+ const key = JSON.stringify([state.status, [...state.holds.keys()].sort()]);
265
+ const previous = reached.get(key);
266
+ if (previous) {
267
+ const joined = copy(previous);
268
+ let changed = false;
269
+ for (const account of owned) {
270
+ const before = previous.balances.has(account)
271
+ ? previous.balances.get(account)!
272
+ : new Map();
273
+ const after = state.balances.has(account)
274
+ ? state.balances.get(account)!
275
+ : new Map();
276
+ if (before !== null && !same(before, after)) {
277
+ joined.balances.set(account, null);
278
+ changed = true;
855
279
  }
856
280
  }
857
- }
858
- if (balance.kind === "empty") {
859
- fail(
860
- exchanged
861
- ? `cannot prove ${effect.amount} is available after the substituted partition was spent from ${accountLabel}`
862
- : `can debit unfunded ${accountLabel}`,
863
- );
864
- } else if (balance.kind === "penalty") {
865
- fail(
866
- `can debit ${accountLabel} after its refund left only the quoted charge`,
867
- );
868
- } else if (balance.kind === "funded" || exactBalance) {
869
- fail(
870
- `cannot prove ${effect.amount} is the exact available balance of ${accountLabel}`,
871
- );
872
- }
873
- balance = EMPTY;
874
- }
875
- // A held account permits at most one equal-partition substitution per funded
876
- // balance, preventing cycles, duplicate minting, and double spending. When an
877
- // account is completely drained to empty with no active holds, the exchange
878
- // record resets for any distinct subsequent funding.
879
- if (balance.kind === "empty" && Object.keys(holds).length === 0) {
880
- exchanged = false;
881
- }
882
- return { balance, holds, exchanged };
883
- }
884
-
885
- /**
886
- * Flattens every quote-commit pair the instrument declares. A quoting action
887
- * with no committing action, or a commit naming a non-quoting action, is a
888
- * validator failure rather than a typestate one, so it is simply skipped here.
889
- */
890
- function quoteFacts(
891
- instrument: FinancialInstrument,
892
- options: FinanceOptions,
893
- ): readonly QuoteFacts[] {
894
- const facts: QuoteFacts[] = [];
895
- for (const [quoting, action] of Object.entries(instrument.actions)) {
896
- const quote = action.quote;
897
- if (!quote) continue;
898
- const commit = Object.entries(instrument.actions).find(
899
- ([, candidate]) => candidate.commit === quoting,
900
- )?.[0];
901
- if (commit === undefined) continue;
902
- const chargePath = `refs.${quote.chargeRef}`;
903
- const declaresChargePayout = Object.values(instrument.actions).some(
904
- (candidate) =>
905
- (candidate.moves ?? []).some((move) =>
906
- Object.values(move.bind).some(
907
- (binding) =>
908
- binding.from === "instance" && binding.path === chargePath,
909
- ),
910
- ),
911
- );
912
- facts.push({
913
- baseField: quote.baseField,
914
- chargeCanBeNonzero:
915
- options.penaltyMayBeNonzero === true ||
916
- declaresChargePayout ||
917
- quote.charges.some((tier) => tier.bps > 0),
918
- chargeRef: quote.chargeRef,
919
- chargeRetainedBy: quote.chargeRetainedBy,
920
- commit,
921
- netRef: quote.netRef,
922
- quoting,
923
- });
924
- }
925
- return facts;
926
- }
927
-
928
- /**
929
- * The quoted charge is the exact remainder the commit leaves behind, so it may
930
- * fund one transfer, out of the same account the refund left, in its own
931
- * one-way action directly after the commit.
932
- */
933
- function validateChargePayout(
934
- instrument: FinancialInstrument,
935
- quote: QuoteFacts,
936
- refundSource: string | undefined,
937
- add: (path: readonly PropertyKey[], message: string) => void,
938
- ): void {
939
- const chargePath = `refs.${quote.chargeRef}`;
940
- const uses = Object.entries(instrument.actions).flatMap(
941
- ([actionName, action]) =>
942
- (action.moves ?? []).flatMap((step, stepIndex) =>
943
- Object.entries(step.bind).flatMap(([target, binding]) =>
944
- binding.from === "instance" && binding.path === chargePath
945
- ? [{ binding, step, stepIndex, target, action, actionName }]
946
- : [],
947
- ),
948
- ),
949
- );
950
-
951
- if (quote.chargeRetainedBy !== undefined) {
952
- const role = quote.chargeRetainedBy;
953
- const partyField = Object.hasOwn(instrument.parties ?? {}, role)
954
- ? instrument.parties?.[
955
- role as keyof NonNullable<FinancialInstrument["parties"]>
956
- ]
957
- : undefined;
958
- if (!partyField) {
959
- add(
960
- ["actions", quote.quoting, "quote", "chargeRetainedBy"],
961
- `quoting action ${quote.quoting} retains charge by undeclared party role ${role}`,
962
- );
963
- } else {
964
- const expectedSource = `field:${partyField}`;
965
- if (refundSource === undefined) {
966
- add(
967
- ["actions", quote.commit, "moves"],
968
- `commit action ${quote.commit} refund source cannot be resolved to charge-retaining party field ${partyField}`,
969
- );
970
- } else if (refundSource !== expectedSource) {
971
- if (refundSource.startsWith("ref:")) {
972
- add(
973
- ["actions", quote.commit, "moves"],
974
- `commit action ${quote.commit} refund source cannot be a product escrow ref for a retained quote`,
975
- );
976
- } else {
977
- add(
978
- ["actions", quote.commit, "moves"],
979
- `commit action ${quote.commit} refund source ${refundSource} does not match charge-retaining party field ${partyField}`,
980
- );
281
+ for (const [name, hold] of joined.holds) {
282
+ const next = state.holds.get(name)!;
283
+ if (hold.from !== next.from || hold.to !== next.to) {
284
+ report(`${name} changes reservation endpoints across paths`);
285
+ break;
286
+ }
287
+ if (hold.amount !== null && !same(hold.amount, next.amount)) {
288
+ hold.amount = null;
289
+ changed = true;
981
290
  }
982
291
  }
292
+ if (!changed) continue;
293
+ state = joined;
983
294
  }
984
- if (uses.length > 0) {
985
- add(
986
- ["actions"],
987
- `${chargePath} is retained by ${role} and cannot be consumed by an action`,
988
- );
989
- for (const use of uses) {
990
- add(
991
- [
992
- "actions",
993
- use.actionName,
994
- "moves",
995
- use.stepIndex,
996
- "bind",
997
- use.target,
998
- ],
999
- `${chargePath} is retained by ${role} and cannot be consumed by an action`,
1000
- );
1001
- }
295
+ if (reached.size >= 256 && !previous) {
296
+ report("owned proof exceeds 256 state and reservation combinations");
297
+ break;
1002
298
  }
1003
- return;
1004
- }
1005
-
1006
- if (quote.chargeCanBeNonzero && uses.length !== 1) {
1007
- add(
1008
- ["actions"],
1009
- `${chargePath} is consumed ${uses.length} times; a nonzero charge schedule requires exactly one payout`,
299
+ reached.set(key, copy(state));
300
+ const edges = Object.entries(instrument.lifecycle.transitions).filter(
301
+ ([, edge]) => edge.from.includes(state.status),
1010
302
  );
1011
- }
1012
- if (uses.length === 0) return;
1013
- if (uses.length !== 1) {
1014
- if (!quote.chargeCanBeNonzero) {
1015
- add(
1016
- ["actions"],
1017
- `${chargePath} is consumed ${uses.length} times; it may fund at most one payout`,
303
+ if (
304
+ !edges.length &&
305
+ ([...state.balances].some(
306
+ ([account, balance]) =>
307
+ !shared.has(account) && (balance === null || balance.size > 0),
308
+ ) ||
309
+ state.holds.size)
310
+ )
311
+ report(
312
+ `terminal state ${state.status} may strand owned money or an open reservation`,
1018
313
  );
1019
- }
1020
- return;
1021
- }
1022
-
1023
- const use = uses[0] as (typeof uses)[number];
1024
- const payoutTransition = instrument.lifecycle.transitions[use.actionName];
1025
- const commitTarget = instrument.lifecycle.transitions[quote.commit]?.to;
1026
- const source = canonicalAccount(instrument, use.step.bind.sourceAccountId);
1027
- const destination = canonicalAccount(
1028
- instrument,
1029
- use.step.bind.destinationAccountId,
1030
- );
1031
- const directAfterCommit =
1032
- commitTarget !== undefined &&
1033
- payoutTransition?.from.length === 1 &&
1034
- payoutTransition.from[0] === commitTarget;
1035
- const oneWay =
1036
- payoutTransition !== undefined &&
1037
- payoutTransition.to !== commitTarget &&
1038
- !stateCanReach(instrument, payoutTransition.to, commitTarget);
1039
- const valid =
1040
- use.target === "amount" &&
1041
- use.step.operation === "internal_transfer.create" &&
1042
- (use.action.moves?.length ?? 0) === 1 &&
1043
- directAfterCommit &&
1044
- oneWay &&
1045
- source !== undefined &&
1046
- source === refundSource &&
1047
- destination !== undefined &&
1048
- !accountsMayAlias(instrument, source, destination);
1049
- if (!valid) {
1050
- add(
1051
- ["actions", use.actionName, "moves", use.stepIndex, "bind", use.target],
1052
- `${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}`,
1053
- );
1054
- }
1055
- }
1056
-
1057
- function canonicalAccount(
1058
- instrument: FinancialInstrument,
1059
- binding: UdlBinding | undefined,
1060
- ): string | undefined {
1061
- if (binding?.from !== "instance") return undefined;
1062
- return canonicalAccountPath(instrument, binding.path);
1063
- }
1064
-
1065
- function canonicalAccountPath(
1066
- instrument: FinancialInstrument,
1067
- path: string,
1068
- ): string | undefined {
1069
- const [root, key, extra] = path.split(".");
1070
- if (!key || extra !== undefined) return undefined;
1071
- if (root === "fields") return `field:${key}`;
1072
- if (root === "refs") return `ref:${key}`;
1073
- if (root === "party") {
1074
- const field =
1075
- instrument.parties?.[
1076
- key as keyof NonNullable<FinancialInstrument["parties"]>
1077
- ];
1078
- return field ? `field:${field}` : undefined;
1079
- }
1080
- return undefined;
1081
- }
1082
-
1083
- function accountsMayAlias(
1084
- instrument: FinancialInstrument,
1085
- left: string,
1086
- right: string,
1087
- ): boolean {
1088
- if (left === right) return true;
1089
- if (left.startsWith("field:") && right.startsWith("field:")) {
1090
- const leftField = left.slice("field:".length);
1091
- const rightField = right.slice("field:".length);
1092
- if (leftField === rightField) return true;
1093
- if (instrument.parties) {
1094
- const leftParty = Object.entries(instrument.parties).find(
1095
- ([, field]) => field === leftField,
1096
- )?.[0];
1097
- const rightParty = Object.entries(instrument.parties).find(
1098
- ([, field]) => field === rightField,
1099
- )?.[0];
1100
- if (
1101
- leftParty !== undefined &&
1102
- rightParty !== undefined &&
1103
- leftParty !== rightParty
1104
- ) {
1105
- return false;
1106
- }
1107
- }
1108
- return true;
1109
- }
1110
- return false;
1111
- }
1112
-
1113
- function amountIdentity(binding: UdlBinding | undefined): string {
1114
- if (binding?.from === "instance") return binding.path;
1115
- return binding?.from === "const" ? `const:${binding.value}` : "dynamic";
1116
- }
1117
-
1118
- function transferRefKey(step: FinancialStep): string | undefined {
1119
- return Object.entries(step.capture ?? {}).find(
1120
- ([, result]) => result === "transferId",
1121
- )?.[0];
1122
- }
1123
-
1124
- function boundTransferRefKey(step: FinancialStep): string | undefined {
1125
- const binding = step.bind.transferId;
1126
- return binding?.from === "instance" && binding.path.startsWith("refs.")
1127
- ? binding.path.slice("refs.".length)
1128
- : undefined;
1129
- }
1130
-
1131
- function stateCanReach(
1132
- instrument: FinancialInstrument,
1133
- start: string,
1134
- target: string | undefined,
1135
- ): boolean {
1136
- if (!target) return false;
1137
- const seen = new Set([start]);
1138
- const pending = [start];
1139
- while (pending.length > 0) {
1140
- const state = pending.shift() as string;
1141
- if (state === target) return true;
1142
- for (const transition of Object.values(instrument.lifecycle.transitions)) {
1143
- if (!transition.from.includes(state) || seen.has(transition.to)) continue;
1144
- seen.add(transition.to);
1145
- pending.push(transition.to);
314
+ for (const [name, edge] of edges) {
315
+ const action = instrument.actions[name];
316
+ if (!action) continue;
317
+ const next = copy(state);
318
+ next.status = edge.to;
319
+ apply(next, action, name);
320
+ pending.push(next);
1146
321
  }
1147
322
  }
1148
- return false;
1149
- }
1150
-
1151
- function stateKey(state: AccountState): string {
1152
- return JSON.stringify({
1153
- balance: state.balance,
1154
- holds: Object.fromEntries(
1155
- Object.entries(state.holds).sort(([a], [b]) => a.localeCompare(b)),
1156
- ),
1157
- exchanged: state.exchanged ?? false,
1158
- });
1159
- }
1160
-
1161
- function formatAccount(account: string): string {
1162
- const [kind, key] = account.split(":");
1163
- return `${kind === "field" ? "fields" : "refs"}.${key}`;
323
+ return problems;
1164
324
  }