@hyperscale0/udl 2.2.0 → 2.4.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.
- package/CHANGELOG.md +38 -0
- package/README.md +5 -1
- package/conformance/invalid/invalid-journeys.expected.json +3 -19
- package/conformance/invalid/invalid-journeys.udl +47 -49
- package/conformance/valid/attested.expected.json +6 -0
- package/conformance/valid/attested.udl +251 -0
- package/conformance/valid/hand-edited.expected.json +1 -1
- package/conformance/valid/hand-edited.udl +1 -1
- package/conformance/valid/minimal.expected.json +1 -1
- package/conformance/valid/minimal.udl +0 -15
- package/conformance/valid/vocabulary.expected.json +6 -0
- package/conformance/valid/vocabulary.udl +1999 -0
- package/dist/allocation.d.ts +60 -0
- package/dist/allocation.d.ts.map +1 -0
- package/dist/allocation.js +177 -0
- package/dist/allocation.js.map +1 -0
- package/dist/diagnostics.d.ts +1 -31
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +0 -30
- package/dist/diagnostics.js.map +1 -1
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +49 -0
- package/dist/distribution.js.map +1 -0
- package/dist/effects.d.ts +0 -6
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +84 -22
- package/dist/effects.js.map +1 -1
- package/dist/evolution.d.ts +12 -0
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +42 -1
- package/dist/evolution.js.map +1 -1
- package/dist/finance.d.ts +18 -1
- package/dist/finance.d.ts.map +1 -1
- package/dist/finance.js +158 -27
- package/dist/finance.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/instrument-references.d.ts +5 -0
- package/dist/instrument-references.d.ts.map +1 -0
- package/dist/instrument-references.js +69 -0
- package/dist/instrument-references.js.map +1 -0
- package/dist/limits.d.ts +3 -3
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +3 -7
- package/dist/limits.js.map +1 -1
- package/dist/reference.d.ts +3 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.js +28 -0
- package/dist/reference.js.map +1 -0
- package/dist/schema.d.ts +1232 -83
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +398 -60
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +27 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +327 -187
- package/dist/validation.js.map +1 -1
- package/dist/vocabulary.d.ts +23 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +965 -0
- package/dist/vocabulary.js.map +1 -0
- package/docs/README.md +5 -1
- package/docs/funding-custody.md +165 -0
- package/docs/guide/09-schedules-and-allocation.md +130 -0
- package/docs/llms-full.txt +623 -101
- package/docs/llms.txt +1 -1
- package/docs/piece-plans.md +148 -0
- package/docs/reference/clauses.md +451 -63
- package/docs/reference/cli.md +3 -1
- package/docs/reference/diagnostics.md +33 -38
- package/package.json +5 -6
- package/spec/udl.schema.json +1095 -118
- package/src/allocation.ts +259 -0
- package/src/diagnostics.ts +0 -32
- package/src/distribution.ts +61 -0
- package/src/effects.ts +116 -22
- package/src/evolution.ts +63 -3
- package/src/finance.ts +218 -24
- package/src/index.ts +31 -3
- package/src/instrument-references.ts +98 -0
- package/src/limits.ts +3 -7
- package/src/reference.ts +31 -0
- package/src/schema.ts +417 -66
- package/src/validation.ts +452 -243
- package/src/vocabulary.ts +1508 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
export type AllocationBucketKey = "principal" | "profit" | "cost" | "fine";
|
|
2
|
+
export interface AllocationBalance {
|
|
3
|
+
readonly key: AllocationBucketKey;
|
|
4
|
+
readonly assessmentId: string;
|
|
5
|
+
readonly destinationAccountId: string;
|
|
6
|
+
readonly amount: bigint;
|
|
7
|
+
readonly consumed: bigint;
|
|
8
|
+
}
|
|
9
|
+
export interface AllocationSlice {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly dueAt: string;
|
|
12
|
+
readonly position: number;
|
|
13
|
+
readonly paid: boolean;
|
|
14
|
+
readonly buckets: readonly AllocationBalance[];
|
|
15
|
+
}
|
|
16
|
+
export interface AllocationInput {
|
|
17
|
+
readonly asOf: string;
|
|
18
|
+
readonly earningRule: "per_slice_on_due" | "on_disbursement";
|
|
19
|
+
readonly mode: "payment" | "payoff" | "write_off";
|
|
20
|
+
readonly payment?: bigint | undefined;
|
|
21
|
+
readonly assessmentId?: string;
|
|
22
|
+
readonly slices: readonly AllocationSlice[];
|
|
23
|
+
}
|
|
24
|
+
export interface AllocationPosting {
|
|
25
|
+
readonly sliceId: string;
|
|
26
|
+
readonly assessmentId: string;
|
|
27
|
+
readonly bucket: AllocationBucketKey;
|
|
28
|
+
readonly destinationAccountId: string;
|
|
29
|
+
readonly amount: bigint;
|
|
30
|
+
}
|
|
31
|
+
export interface AllocationCancellation {
|
|
32
|
+
readonly sliceId: string;
|
|
33
|
+
readonly assessmentId: string;
|
|
34
|
+
readonly bucket: AllocationBucketKey;
|
|
35
|
+
readonly amount: bigint;
|
|
36
|
+
readonly reason: "unearned_profit" | "write_off";
|
|
37
|
+
}
|
|
38
|
+
function clock(value: string): number {
|
|
39
|
+
if (!/^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d+)?Z)?$/.test(value))
|
|
40
|
+
throw new Error("allocation needs an authoritative UTC time");
|
|
41
|
+
const result = Date.parse(value);
|
|
42
|
+
if (
|
|
43
|
+
!Number.isFinite(result) ||
|
|
44
|
+
new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) !==
|
|
45
|
+
value.slice(0, 10)
|
|
46
|
+
)
|
|
47
|
+
throw new Error("invalid allocation time");
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plan from locked balances. The engine owns identity claims and atomic posting. */
|
|
52
|
+
export function planAllocation(
|
|
53
|
+
contract: {
|
|
54
|
+
readonly buckets: readonly { readonly key: AllocationBucketKey }[];
|
|
55
|
+
},
|
|
56
|
+
input: AllocationInput,
|
|
57
|
+
) {
|
|
58
|
+
const priority = contract.buckets.map((bucket) => bucket.key);
|
|
59
|
+
const keys = new Set(priority);
|
|
60
|
+
if (
|
|
61
|
+
priority.length < 2 ||
|
|
62
|
+
priority.length > 4 ||
|
|
63
|
+
keys.size !== priority.length ||
|
|
64
|
+
!keys.has("principal") ||
|
|
65
|
+
!keys.has("profit") ||
|
|
66
|
+
priority.some(
|
|
67
|
+
(key) => !["principal", "profit", "cost", "fine"].includes(key),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
throw new Error("invalid allocation priority");
|
|
71
|
+
if (
|
|
72
|
+
!["per_slice_on_due", "on_disbursement"].includes(input.earningRule) ||
|
|
73
|
+
!["payment", "payoff", "write_off"].includes(input.mode)
|
|
74
|
+
)
|
|
75
|
+
throw new Error("invalid earning rule or allocation mode");
|
|
76
|
+
if (!input.slices.length || input.slices.length > 366)
|
|
77
|
+
throw new Error("allocation requires 1 to 366 slices");
|
|
78
|
+
if (
|
|
79
|
+
input.assessmentId !== undefined &&
|
|
80
|
+
(input.mode !== "payment" || !input.assessmentId)
|
|
81
|
+
)
|
|
82
|
+
throw new Error("assessment selection requires payment mode");
|
|
83
|
+
const asOf = clock(input.asOf);
|
|
84
|
+
if (
|
|
85
|
+
input.mode === "write_off"
|
|
86
|
+
? input.payment !== undefined
|
|
87
|
+
: input.payment === undefined
|
|
88
|
+
? input.mode !== "payoff" && input.assessmentId === undefined
|
|
89
|
+
: typeof input.payment !== "bigint" ||
|
|
90
|
+
input.payment < 0n ||
|
|
91
|
+
(input.mode === "payment" && input.payment === 0n)
|
|
92
|
+
)
|
|
93
|
+
throw new Error("invalid allocation payment");
|
|
94
|
+
const ids = new Set<string>();
|
|
95
|
+
const positions = new Set<number>();
|
|
96
|
+
const assessments = new Set<string>();
|
|
97
|
+
const slices = input.slices
|
|
98
|
+
.map((slice) => {
|
|
99
|
+
const due = clock(slice.dueAt);
|
|
100
|
+
if (
|
|
101
|
+
!slice.id ||
|
|
102
|
+
ids.has(slice.id) ||
|
|
103
|
+
!Number.isInteger(slice.position) ||
|
|
104
|
+
slice.position < 1 ||
|
|
105
|
+
positions.has(slice.position) ||
|
|
106
|
+
typeof slice.paid !== "boolean" ||
|
|
107
|
+
slice.buckets.length > 256
|
|
108
|
+
)
|
|
109
|
+
throw new Error("invalid or duplicate slice");
|
|
110
|
+
ids.add(slice.id);
|
|
111
|
+
positions.add(slice.position);
|
|
112
|
+
for (const balance of slice.buckets) {
|
|
113
|
+
if (
|
|
114
|
+
!keys.has(balance.key) ||
|
|
115
|
+
!balance.assessmentId ||
|
|
116
|
+
assessments.has(balance.assessmentId) ||
|
|
117
|
+
!balance.destinationAccountId ||
|
|
118
|
+
typeof balance.amount !== "bigint" ||
|
|
119
|
+
typeof balance.consumed !== "bigint" ||
|
|
120
|
+
balance.amount < 0n ||
|
|
121
|
+
balance.consumed < 0n ||
|
|
122
|
+
balance.consumed > balance.amount
|
|
123
|
+
)
|
|
124
|
+
throw new Error("invalid or duplicate assessment balance");
|
|
125
|
+
assessments.add(balance.assessmentId);
|
|
126
|
+
}
|
|
127
|
+
return { slice, due };
|
|
128
|
+
})
|
|
129
|
+
.sort((a, b) => a.due - b.due || a.slice.position - b.slice.position);
|
|
130
|
+
const candidates: AllocationPosting[] = [];
|
|
131
|
+
const cancellations: AllocationCancellation[] = [];
|
|
132
|
+
for (const { slice, due } of slices) {
|
|
133
|
+
const earned =
|
|
134
|
+
input.earningRule === "on_disbursement" || due <= asOf || slice.paid;
|
|
135
|
+
for (const key of priority) {
|
|
136
|
+
// Multiple assessed charges of one kind have a deterministic identity order.
|
|
137
|
+
const balances = slice.buckets
|
|
138
|
+
.filter((balance) => balance.key === key)
|
|
139
|
+
.sort((a, b) =>
|
|
140
|
+
a.assessmentId < b.assessmentId
|
|
141
|
+
? -1
|
|
142
|
+
: a.assessmentId > b.assessmentId
|
|
143
|
+
? 1
|
|
144
|
+
: 0,
|
|
145
|
+
);
|
|
146
|
+
for (const balance of balances) {
|
|
147
|
+
const amount = balance.amount - balance.consumed;
|
|
148
|
+
if (
|
|
149
|
+
input.assessmentId !== undefined &&
|
|
150
|
+
balance.assessmentId !== input.assessmentId
|
|
151
|
+
)
|
|
152
|
+
continue;
|
|
153
|
+
if (amount === 0n) continue;
|
|
154
|
+
const unearned = key === "profit" && !earned;
|
|
155
|
+
if (
|
|
156
|
+
input.mode === "write_off" ||
|
|
157
|
+
(input.mode === "payoff" && unearned)
|
|
158
|
+
) {
|
|
159
|
+
cancellations.push({
|
|
160
|
+
sliceId: slice.id,
|
|
161
|
+
assessmentId: balance.assessmentId,
|
|
162
|
+
bucket: key,
|
|
163
|
+
amount,
|
|
164
|
+
reason: unearned ? "unearned_profit" : "write_off",
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
candidates.push({
|
|
168
|
+
sliceId: slice.id,
|
|
169
|
+
assessmentId: balance.assessmentId,
|
|
170
|
+
bucket: key,
|
|
171
|
+
destinationAccountId: balance.destinationAccountId,
|
|
172
|
+
amount,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const outstanding = candidates.reduce((sum, row) => sum + row.amount, 0n);
|
|
179
|
+
if (input.mode === "write_off")
|
|
180
|
+
return { postings: [] as AllocationPosting[], cancellations, amount: 0n };
|
|
181
|
+
if (input.assessmentId !== undefined && !assessments.has(input.assessmentId))
|
|
182
|
+
throw new Error("assessment is not in the allocation");
|
|
183
|
+
const payment = input.payment ?? outstanding;
|
|
184
|
+
if (input.mode === "payment" && payment === 0n)
|
|
185
|
+
throw new Error("assessment already consumed");
|
|
186
|
+
if (
|
|
187
|
+
payment > outstanding ||
|
|
188
|
+
(input.mode === "payoff" && payment !== outstanding)
|
|
189
|
+
)
|
|
190
|
+
throw new Error("payment does not match outstanding allocation");
|
|
191
|
+
let remaining = payment;
|
|
192
|
+
const postings: AllocationPosting[] = [];
|
|
193
|
+
for (const candidate of candidates) {
|
|
194
|
+
const amount = candidate.amount < remaining ? candidate.amount : remaining;
|
|
195
|
+
if (amount > 0n) postings.push({ ...candidate, amount });
|
|
196
|
+
remaining -= amount;
|
|
197
|
+
if (remaining === 0n) break;
|
|
198
|
+
}
|
|
199
|
+
return { postings, cancellations, amount: payment };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reverse recorded postings once, without reopening the consumed assessment. */
|
|
203
|
+
export function planAllocationRefund(
|
|
204
|
+
postings: readonly AllocationPosting[],
|
|
205
|
+
originalPayer: string,
|
|
206
|
+
refundedAssessments: readonly string[],
|
|
207
|
+
assessmentId?: string,
|
|
208
|
+
) {
|
|
209
|
+
if (!originalPayer || postings.length > 366 * 256)
|
|
210
|
+
throw new Error("invalid allocation receipt");
|
|
211
|
+
const selected = postings.filter(
|
|
212
|
+
(row) => assessmentId === undefined || row.assessmentId === assessmentId,
|
|
213
|
+
);
|
|
214
|
+
if (!selected.length)
|
|
215
|
+
throw new Error("assessment has no recorded allocation");
|
|
216
|
+
const seen = new Set<string>();
|
|
217
|
+
return selected.map((row) => {
|
|
218
|
+
if (
|
|
219
|
+
row.amount <= 0n ||
|
|
220
|
+
!row.destinationAccountId ||
|
|
221
|
+
!row.assessmentId ||
|
|
222
|
+
seen.has(row.assessmentId) ||
|
|
223
|
+
refundedAssessments.includes(row.assessmentId)
|
|
224
|
+
)
|
|
225
|
+
throw new Error("assessment already refunded or invalid receipt");
|
|
226
|
+
seen.add(row.assessmentId);
|
|
227
|
+
return {
|
|
228
|
+
assessmentId: row.assessmentId,
|
|
229
|
+
amount: row.amount,
|
|
230
|
+
sourceAccountId: row.destinationAccountId,
|
|
231
|
+
destinationAccountId: originalPayer,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Evaluate a slice gate from the same locked consumption balances used to allocate. */
|
|
237
|
+
export function matchesAllocationConsumption(
|
|
238
|
+
clause: {
|
|
239
|
+
readonly buckets: readonly AllocationBucketKey[];
|
|
240
|
+
readonly check: "settled" | "outstanding";
|
|
241
|
+
},
|
|
242
|
+
slice: AllocationSlice,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (
|
|
245
|
+
!clause.buckets.length ||
|
|
246
|
+
new Set(clause.buckets).size !== clause.buckets.length
|
|
247
|
+
)
|
|
248
|
+
throw new Error("allocation gate needs distinct buckets");
|
|
249
|
+
const settled = clause.buckets
|
|
250
|
+
.map((key) => {
|
|
251
|
+
const rows = slice.buckets.filter((bucket) => bucket.key === key);
|
|
252
|
+
for (const row of rows)
|
|
253
|
+
if (row.consumed < 0n || row.consumed > row.amount || row.amount < 0n)
|
|
254
|
+
throw new Error("invalid consumption");
|
|
255
|
+
return rows.every((row) => row.consumed === row.amount);
|
|
256
|
+
})
|
|
257
|
+
.every(Boolean);
|
|
258
|
+
return clause.check === "settled" ? settled : !settled;
|
|
259
|
+
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -12,7 +12,6 @@ export type UdlDiagnosticFamily =
|
|
|
12
12
|
| "evolution"
|
|
13
13
|
| "finance"
|
|
14
14
|
| "gates"
|
|
15
|
-
| "journey"
|
|
16
15
|
| "lifecycle"
|
|
17
16
|
| "schema";
|
|
18
17
|
|
|
@@ -191,37 +190,6 @@ const diagnosticDefinitions = {
|
|
|
191
190
|
fix: "Use only the sealed UDL JSON Schema subset.",
|
|
192
191
|
},
|
|
193
192
|
|
|
194
|
-
journey_unknown_operation: {
|
|
195
|
-
category: "invalid_semantics",
|
|
196
|
-
family: "journey",
|
|
197
|
-
title: "Journey operation is not in the composition",
|
|
198
|
-
fix: "Name an operation in the composition closure.",
|
|
199
|
-
},
|
|
200
|
-
journey_unknown_example: {
|
|
201
|
-
category: "invalid_semantics",
|
|
202
|
-
family: "journey",
|
|
203
|
-
title: "Journey example does not exist",
|
|
204
|
-
fix: "Name an authored example on the journey operation.",
|
|
205
|
-
},
|
|
206
|
-
journey_invalid_transition: {
|
|
207
|
-
category: "invalid_semantics",
|
|
208
|
-
family: "journey",
|
|
209
|
-
title: "Journey lifecycle transition is invalid",
|
|
210
|
-
fix: "Order the steps so each action starts from the state produced by earlier steps.",
|
|
211
|
-
},
|
|
212
|
-
journey_unbound_reference: {
|
|
213
|
-
category: "invalid_semantics",
|
|
214
|
-
family: "journey",
|
|
215
|
-
title: "Journey reference is unbound or has the wrong kind",
|
|
216
|
-
fix: "Bind every reference input to an earlier step that creates the required kind.",
|
|
217
|
-
},
|
|
218
|
-
journey_duplicate_step_id: {
|
|
219
|
-
category: "invalid_semantics",
|
|
220
|
-
family: "journey",
|
|
221
|
-
title: "Journey step id is duplicated",
|
|
222
|
-
fix: "Give every named step in the journey a unique id.",
|
|
223
|
-
},
|
|
224
|
-
|
|
225
193
|
UDL7001: {
|
|
226
194
|
category: "invalid_evolution",
|
|
227
195
|
family: "evolution",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { UdlAction } from "./schema.js";
|
|
2
|
+
|
|
3
|
+
/** Arithmetic only. The host resolves and consumes the receipt and snapshot under locks. */
|
|
4
|
+
export function distributeReceiptAmounts(
|
|
5
|
+
clause: NonNullable<UdlAction["receiptDistribution"]>,
|
|
6
|
+
principal: bigint,
|
|
7
|
+
profit: bigint,
|
|
8
|
+
tickets: readonly { readonly id: string; readonly weight: bigint }[],
|
|
9
|
+
) {
|
|
10
|
+
if (principal < 0n || profit < 0n || tickets.length === 0)
|
|
11
|
+
throw new Error(
|
|
12
|
+
"distribution requires nonnegative receipt amounts and tickets",
|
|
13
|
+
);
|
|
14
|
+
if (
|
|
15
|
+
new Set(tickets.map((ticket) => ticket.id)).size !== tickets.length ||
|
|
16
|
+
tickets.some((ticket) => ticket.weight <= 0n)
|
|
17
|
+
)
|
|
18
|
+
throw new Error(
|
|
19
|
+
"snapshot tickets must have unique identities and positive weights",
|
|
20
|
+
);
|
|
21
|
+
const loss = clause.mode === "loss";
|
|
22
|
+
if (loss && (profit !== 0n || clause.feeBps !== 0 || clause.vatBps !== 0))
|
|
23
|
+
throw new Error("loss allocation contains only principal and no fees");
|
|
24
|
+
const totalWeight = tickets.reduce((sum, ticket) => sum + ticket.weight, 0n);
|
|
25
|
+
const fee = (profit * BigInt(clause.feeBps)) / 10000n;
|
|
26
|
+
const vat = (fee * BigInt(clause.vatBps)) / 10000n;
|
|
27
|
+
const amount = principal + profit - fee - vat;
|
|
28
|
+
if (amount < 0n) throw new Error("fee and VAT exceed the receipt");
|
|
29
|
+
const shares = tickets.map((ticket) => ({
|
|
30
|
+
id: ticket.id,
|
|
31
|
+
amount: (amount * ticket.weight) / totalWeight,
|
|
32
|
+
}));
|
|
33
|
+
let residual = amount - shares.reduce((sum, share) => sum + share.amount, 0n);
|
|
34
|
+
if (loss) {
|
|
35
|
+
// A loss cannot be paid to a residual cash beneficiary. Largest remainder
|
|
36
|
+
// assigns every lost minor unit to an investor, with identity breaking ties.
|
|
37
|
+
const ordered = tickets
|
|
38
|
+
.map((ticket, index) => ({
|
|
39
|
+
index,
|
|
40
|
+
id: ticket.id,
|
|
41
|
+
remainder: (amount * ticket.weight) % totalWeight,
|
|
42
|
+
}))
|
|
43
|
+
.sort((a, b) =>
|
|
44
|
+
a.remainder === b.remainder
|
|
45
|
+
? a.id < b.id
|
|
46
|
+
? -1
|
|
47
|
+
: a.id > b.id
|
|
48
|
+
? 1
|
|
49
|
+
: 0
|
|
50
|
+
: a.remainder > b.remainder
|
|
51
|
+
? -1
|
|
52
|
+
: 1,
|
|
53
|
+
);
|
|
54
|
+
for (const ticket of ordered) {
|
|
55
|
+
if (residual === 0n) break;
|
|
56
|
+
shares[ticket.index]!.amount += 1n;
|
|
57
|
+
residual -= 1n;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { fee, vat, residual, shares };
|
|
61
|
+
}
|
package/src/effects.ts
CHANGED
|
@@ -66,6 +66,26 @@ function boundPath(move: Movement, endpoint: string): string | undefined {
|
|
|
66
66
|
* External collection, deposit, and payout operations carry their role in the
|
|
67
67
|
* operation family because their remote endpoint is not a UDL account binding.
|
|
68
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* The typed kind of an instance ref a call binding may name. Refs exist only
|
|
71
|
+
* through step and move captures on the same instrument; a capture of a leaf's
|
|
72
|
+
* `accountId` output is an account, every other capture is opaque text.
|
|
73
|
+
*/
|
|
74
|
+
function capturedRefKind(
|
|
75
|
+
instrument: UdlInstrument,
|
|
76
|
+
refName: string,
|
|
77
|
+
): "account" | "text" | undefined {
|
|
78
|
+
for (const action of Object.values(instrument.actions)) {
|
|
79
|
+
for (const step of [...action.steps, ...action.moves]) {
|
|
80
|
+
const output = step.capture?.[refName];
|
|
81
|
+
if (output !== undefined) {
|
|
82
|
+
return output === "accountId" ? "account" : "text";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export function movementClass(move: Movement): UdlMovementClass {
|
|
70
90
|
if (move.operation.startsWith("internal_transfer.")) {
|
|
71
91
|
const source = boundPath(move, "sourceAccountId");
|
|
@@ -113,6 +133,13 @@ export function deriveUdlActionEffects(
|
|
|
113
133
|
) {
|
|
114
134
|
continue;
|
|
115
135
|
}
|
|
136
|
+
if (
|
|
137
|
+
descriptor.kind === "moves" &&
|
|
138
|
+
((clause.target === "allocate" && object?.mode === "write_off") ||
|
|
139
|
+
(clause.target === "receiptDistribution" &&
|
|
140
|
+
object?.mode === "loss"))
|
|
141
|
+
)
|
|
142
|
+
continue;
|
|
116
143
|
const suffix = effectSignatureSuffix(descriptor, object);
|
|
117
144
|
if (!suffix) continue;
|
|
118
145
|
(effects[descriptor.kind] ??= []).push({
|
|
@@ -139,7 +166,26 @@ function actionClauseValue(
|
|
|
139
166
|
): unknown {
|
|
140
167
|
const [head, tail] = target.split(".");
|
|
141
168
|
if (!head) return undefined;
|
|
142
|
-
const value =
|
|
169
|
+
const value =
|
|
170
|
+
head === "transitionsRefs"
|
|
171
|
+
? [
|
|
172
|
+
...(Array.isArray(action.transitionsRefs)
|
|
173
|
+
? action.transitionsRefs
|
|
174
|
+
: []),
|
|
175
|
+
...(Array.isArray(action.requiresRefs)
|
|
176
|
+
? action.requiresRefs
|
|
177
|
+
: []
|
|
178
|
+
).flatMap((gate) => {
|
|
179
|
+
const requirement = recordValue(gate);
|
|
180
|
+
const attests = recordValue(requirement?.attests);
|
|
181
|
+
return attests
|
|
182
|
+
? [{ field: requirement?.field, action: attests.consume }]
|
|
183
|
+
: [];
|
|
184
|
+
}),
|
|
185
|
+
]
|
|
186
|
+
: action[head];
|
|
187
|
+
if (head === "transitionsRefs" && Array.isArray(value) && value.length === 0)
|
|
188
|
+
return undefined;
|
|
143
189
|
if (!tail) return value;
|
|
144
190
|
return recordValue(value)?.[tail];
|
|
145
191
|
}
|
|
@@ -231,6 +277,35 @@ function encodeOriginPathKey(originPath: readonly string[]): string {
|
|
|
231
277
|
return `k_${parts.join("_")}`;
|
|
232
278
|
}
|
|
233
279
|
|
|
280
|
+
function sameEffectSignatures(
|
|
281
|
+
left: readonly { readonly kind: UdlEffectKind; readonly signature: string }[],
|
|
282
|
+
right: readonly {
|
|
283
|
+
readonly kind: UdlEffectKind;
|
|
284
|
+
readonly signature: string;
|
|
285
|
+
}[],
|
|
286
|
+
): boolean {
|
|
287
|
+
const count = (
|
|
288
|
+
effects: readonly {
|
|
289
|
+
readonly kind: UdlEffectKind;
|
|
290
|
+
readonly signature: string;
|
|
291
|
+
}[],
|
|
292
|
+
) => {
|
|
293
|
+
const counts = new Map<string, number>();
|
|
294
|
+
for (const eff of effects) {
|
|
295
|
+
const key = `${eff.kind}:${eff.signature}`;
|
|
296
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
297
|
+
}
|
|
298
|
+
return counts;
|
|
299
|
+
};
|
|
300
|
+
const leftCounts = count(left);
|
|
301
|
+
const rightCounts = count(right);
|
|
302
|
+
if (leftCounts.size !== rightCounts.size) return false;
|
|
303
|
+
for (const [key, n] of leftCounts) {
|
|
304
|
+
if (rightCounts.get(key) !== n) return false;
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
234
309
|
function expectedLeafEffects(
|
|
235
310
|
step: UdlStep | UdlMove,
|
|
236
311
|
): readonly { readonly kind: UdlEffectKind; readonly signature: string }[] {
|
|
@@ -925,6 +1000,35 @@ export function resolveUdlActionPlans(
|
|
|
925
1000
|
path: parts[1],
|
|
926
1001
|
};
|
|
927
1002
|
}
|
|
1003
|
+
if (parts[1] === "refs") {
|
|
1004
|
+
if (parts.length !== 3) {
|
|
1005
|
+
issues.push(
|
|
1006
|
+
issue(
|
|
1007
|
+
"UDL2011",
|
|
1008
|
+
leafPath,
|
|
1009
|
+
`invalid trailing member access on instance ref: ${rawBind}`,
|
|
1010
|
+
),
|
|
1011
|
+
);
|
|
1012
|
+
return undefined;
|
|
1013
|
+
}
|
|
1014
|
+
const refName = parts[2]!;
|
|
1015
|
+
const captured = capturedRefKind(instrument, refName);
|
|
1016
|
+
if (!captured) {
|
|
1017
|
+
issues.push(
|
|
1018
|
+
issue(
|
|
1019
|
+
"UDL2011",
|
|
1020
|
+
leafPath,
|
|
1021
|
+
`referenced ref ${refName} is not captured by any step or move on instrument`,
|
|
1022
|
+
),
|
|
1023
|
+
);
|
|
1024
|
+
return undefined;
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
binding: { from: "instance", path: `refs.${refName}` },
|
|
1028
|
+
kind: captured,
|
|
1029
|
+
path: `refs.${refName}`,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
928
1032
|
issues.push(
|
|
929
1033
|
issue(
|
|
930
1034
|
"UDL2011",
|
|
@@ -1275,27 +1379,17 @@ export function resolveUdlActionPlans(
|
|
|
1275
1379
|
consumedSources.add(holdKey);
|
|
1276
1380
|
}
|
|
1277
1381
|
|
|
1382
|
+
// A library leaf is written once and expanded under several callers,
|
|
1383
|
+
// so its declaration names the operation's generic class (the class
|
|
1384
|
+
// with no bindings). Each expansion then carries the class its
|
|
1385
|
+
// resolved bindings select, exactly as an inline move would: a buyer
|
|
1386
|
+
// funding escrow is a pay-in, the same leaf paying out is internal.
|
|
1278
1387
|
const expectedEffects = expectedLeafEffects(step);
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const expectedCounts = new Map<string, number>();
|
|
1285
|
-
for (const eff of expectedEffects) {
|
|
1286
|
-
const key = `${eff.kind}:${eff.signature}`;
|
|
1287
|
-
expectedCounts.set(key, (expectedCounts.get(key) ?? 0) + 1);
|
|
1288
|
-
}
|
|
1289
|
-
let effectsMatch = actualCounts.size === expectedCounts.size;
|
|
1290
|
-
if (effectsMatch) {
|
|
1291
|
-
for (const [k, count] of actualCounts.entries()) {
|
|
1292
|
-
if (expectedCounts.get(k) !== count) {
|
|
1293
|
-
effectsMatch = false;
|
|
1294
|
-
break;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
if (!effectsMatch) {
|
|
1388
|
+
const genericEffects = expectedLeafEffects({ ...step, bind: {} });
|
|
1389
|
+
if (
|
|
1390
|
+
!sameEffectSignatures(leaf.effects, expectedEffects) &&
|
|
1391
|
+
!sameEffectSignatures(leaf.effects, genericEffects)
|
|
1392
|
+
) {
|
|
1299
1393
|
issues.push(
|
|
1300
1394
|
issue(
|
|
1301
1395
|
"UDL2013",
|
|
@@ -1306,7 +1400,7 @@ export function resolveUdlActionPlans(
|
|
|
1306
1400
|
}
|
|
1307
1401
|
|
|
1308
1402
|
expandedLeaves.push({
|
|
1309
|
-
effects:
|
|
1403
|
+
effects: expectedEffects,
|
|
1310
1404
|
evidence: leaf.evidence,
|
|
1311
1405
|
originPath,
|
|
1312
1406
|
step,
|
package/src/evolution.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface EvolutionMoveSnapshot extends EvolutionStepSnapshot {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface EvolutionActionSnapshot {
|
|
29
|
+
readonly requiresInput?: unknown;
|
|
30
|
+
readonly engineOwned?: unknown;
|
|
31
|
+
readonly captureEngine?: unknown;
|
|
32
|
+
readonly requiresAllocation?: unknown;
|
|
33
|
+
readonly allocate?: unknown;
|
|
34
|
+
readonly contributionStage?: unknown;
|
|
35
|
+
readonly transitionsRefs?: unknown;
|
|
29
36
|
/** Missing on snapshots written before receipt-input capture existed. */
|
|
30
37
|
readonly captureInput?: unknown;
|
|
31
38
|
readonly deadline: unknown;
|
|
@@ -66,6 +73,7 @@ export interface EvolutionActionSnapshot {
|
|
|
66
73
|
readonly requiresDrainedAccount: unknown;
|
|
67
74
|
/** Missing on snapshots written before exposure gates entered open UDL. */
|
|
68
75
|
readonly requiresExposure?: unknown;
|
|
76
|
+
readonly unique?: unknown;
|
|
69
77
|
readonly requiresRefs: unknown;
|
|
70
78
|
/** Missing on snapshots written before reconcile expectations existed. */
|
|
71
79
|
readonly reconcile?: unknown;
|
|
@@ -83,6 +91,10 @@ export interface EvolutionTransitionSnapshot {
|
|
|
83
91
|
|
|
84
92
|
/** The complete serializable algebra protected by append-only evolution. */
|
|
85
93
|
export interface InstrumentEvolutionSnapshot {
|
|
94
|
+
readonly templateBinding?: unknown;
|
|
95
|
+
readonly allocation?: unknown;
|
|
96
|
+
readonly contributions?: unknown;
|
|
97
|
+
readonly dateOrder?: unknown;
|
|
86
98
|
/** Missing on snapshots written before UDL carried authored action order. */
|
|
87
99
|
readonly actionOrder?: readonly string[];
|
|
88
100
|
readonly aggregateInvariants: readonly string[];
|
|
@@ -126,14 +138,19 @@ export function snapshotUdlInstrument(
|
|
|
126
138
|
): InstrumentEvolutionSnapshot {
|
|
127
139
|
const required = new Set(instrument.required);
|
|
128
140
|
return {
|
|
141
|
+
templateBinding: instrument.templateBinding ?? null,
|
|
142
|
+
allocation: instrument.allocation ?? null,
|
|
143
|
+
contributions: instrument.contributions ?? null,
|
|
144
|
+
dateOrder: instrument.dateOrder ?? [],
|
|
129
145
|
actionOrder: [...instrument.actionOrder],
|
|
130
146
|
aggregateInvariants: (instrument.aggregateInvariants ?? []).map(
|
|
131
147
|
aggregateInvariantKey,
|
|
132
148
|
),
|
|
133
149
|
callerParkedStates: instrument.callerParkedStates ?? {},
|
|
134
|
-
derivedAmounts: (instrument.derivedAmounts ?? []).map(
|
|
135
|
-
|
|
136
|
-
`${amount.field}=
|
|
150
|
+
derivedAmounts: (instrument.derivedAmounts ?? []).map((amount) =>
|
|
151
|
+
amount.rule.kind === "minimum"
|
|
152
|
+
? `${amount.field}=min(${amount.sourceField},${amount.rule.capField})`
|
|
153
|
+
: `${amount.field}=floor(${amount.sourceField}*${typeof amount.rule.bps === "number" ? amount.rule.bps : `fields.${amount.rule.bps.field}`}/10000)`,
|
|
137
154
|
),
|
|
138
155
|
dials: instrument.dials ?? [],
|
|
139
156
|
...(instrument.distinctParties ? { distinctParties: true as const } : {}),
|
|
@@ -372,6 +389,18 @@ function diffInstrumentEvolutionMessages(
|
|
|
372
389
|
}
|
|
373
390
|
violations.push(...diffActions(previous.actions, next.actions));
|
|
374
391
|
violations.push(...diffParties(previous.parties, next.parties));
|
|
392
|
+
for (const key of [
|
|
393
|
+
"allocation",
|
|
394
|
+
"contributions",
|
|
395
|
+
"dateOrder",
|
|
396
|
+
"templateBinding",
|
|
397
|
+
] as const) {
|
|
398
|
+
if (
|
|
399
|
+
stableStringify(previous[key] ?? (key === "dateOrder" ? [] : null)) !==
|
|
400
|
+
stableStringify(next[key] ?? (key === "dateOrder" ? [] : null))
|
|
401
|
+
)
|
|
402
|
+
violations.push(`${key} contract changed`);
|
|
403
|
+
}
|
|
375
404
|
violations.push(...diffAggregates(previous, next));
|
|
376
405
|
if (
|
|
377
406
|
stableStringify(Object.keys(previous.callerParkedStates ?? {}).sort()) !==
|
|
@@ -517,6 +546,13 @@ function evolutionIssue(message: string, instrumentBase: string): UdlIssue {
|
|
|
517
546
|
|
|
518
547
|
function snapshotUdlAction(definition: UdlAction): EvolutionActionSnapshot {
|
|
519
548
|
return {
|
|
549
|
+
requiresInput: definition.requiresInput ?? null,
|
|
550
|
+
engineOwned: definition.engineOwned ?? null,
|
|
551
|
+
captureEngine: definition.captureEngine ?? null,
|
|
552
|
+
requiresAllocation: definition.requiresAllocation ?? null,
|
|
553
|
+
allocate: definition.allocate ?? null,
|
|
554
|
+
contributionStage: definition.contributionStage ?? null,
|
|
555
|
+
transitionsRefs: definition.transitionsRefs ?? [],
|
|
520
556
|
captureInput: definition.captureInput ?? null,
|
|
521
557
|
commit: definition.commit ?? null,
|
|
522
558
|
deadline: definition.deadline ?? null,
|
|
@@ -551,6 +587,7 @@ function snapshotUdlAction(definition: UdlAction): EvolutionActionSnapshot {
|
|
|
551
587
|
requiresAggregate: definition.requiresAggregate ?? [],
|
|
552
588
|
requiresDrainedAccount: definition.requiresDrainedAccount ?? null,
|
|
553
589
|
requiresExposure: definition.requiresExposure ?? [],
|
|
590
|
+
unique: definition.unique ?? null,
|
|
554
591
|
requiresRefs: definition.requiresRefs ?? [],
|
|
555
592
|
reconcile: definition.reconcile ?? null,
|
|
556
593
|
steps: definition.steps.map((step) => ({
|
|
@@ -716,6 +753,12 @@ function diffActions(
|
|
|
716
753
|
) {
|
|
717
754
|
violations.push(`action ${action} changed its drained-account gate`);
|
|
718
755
|
}
|
|
756
|
+
if (
|
|
757
|
+
stableStringify(descriptor.unique ?? null) !==
|
|
758
|
+
stableStringify(current.unique ?? null)
|
|
759
|
+
) {
|
|
760
|
+
violations.push(`action ${action} changed its subject uniqueness claim`);
|
|
761
|
+
}
|
|
719
762
|
if (
|
|
720
763
|
descriptor.requiresExposure !== undefined &&
|
|
721
764
|
stableStringify(descriptor.requiresExposure) !==
|
|
@@ -736,6 +779,23 @@ function diffActions(
|
|
|
736
779
|
) {
|
|
737
780
|
violations.push(`action ${action} changed its payout intent`);
|
|
738
781
|
}
|
|
782
|
+
for (const key of [
|
|
783
|
+
"requiresInput",
|
|
784
|
+
"engineOwned",
|
|
785
|
+
"captureEngine",
|
|
786
|
+
"requiresAllocation",
|
|
787
|
+
"allocate",
|
|
788
|
+
"contributionStage",
|
|
789
|
+
"transitionsRefs",
|
|
790
|
+
] as const) {
|
|
791
|
+
if (
|
|
792
|
+
stableStringify(
|
|
793
|
+
descriptor[key] ?? (key === "transitionsRefs" ? [] : null),
|
|
794
|
+
) !==
|
|
795
|
+
stableStringify(current[key] ?? (key === "transitionsRefs" ? [] : null))
|
|
796
|
+
)
|
|
797
|
+
violations.push(`action ${action} changed its ${key} contract`);
|
|
798
|
+
}
|
|
739
799
|
if (descriptor.earnable !== current.earnable) {
|
|
740
800
|
violations.push(`action ${action} changed its earnable flag`);
|
|
741
801
|
}
|