@hyperscale0/udl 1.0.0-alpha.4 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/README.md +22 -20
- package/dist/evolution.d.ts +17 -0
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +54 -0
- package/dist/evolution.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +207 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +205 -1
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +6 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +323 -14
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/spec/README.md +10 -7
- package/spec/udl.schema.json +289 -0
- package/src/evolution.ts +99 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +238 -1
- package/src/validation.ts +504 -19
package/src/validation.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function validateUdl(value: unknown): UdlValidationResult {
|
|
|
75
75
|
issues: [
|
|
76
76
|
{
|
|
77
77
|
code: "resource_limit",
|
|
78
|
-
message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns or
|
|
78
|
+
message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns, reference gates, or payout intents`,
|
|
79
79
|
path: "$.nouns",
|
|
80
80
|
},
|
|
81
81
|
],
|
|
@@ -130,6 +130,31 @@ export function validateUdlSchemaValue(
|
|
|
130
130
|
return { errors, valid: errors.length === 0 };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Validates one schema document against the sealed UDL JSON Schema subset
|
|
135
|
+
* without applying it to a value. Frozen tenant contracts use this at their
|
|
136
|
+
* persistence boundary so an unreadable schema cannot become immutable state.
|
|
137
|
+
*/
|
|
138
|
+
export function validateUdlJsonSchema(schema: unknown): readonly UdlIssue[] {
|
|
139
|
+
const admissionIssue = structuralBudgetIssue(schema);
|
|
140
|
+
if (admissionIssue) return [admissionIssue];
|
|
141
|
+
if (!isRecord(schema)) {
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
code: "invalid_shape",
|
|
145
|
+
message: "JSON Schema must be an object",
|
|
146
|
+
path: "$",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const issues: UdlIssue[] = [];
|
|
152
|
+
validateJsonSchema(schema, [], (path, message) => {
|
|
153
|
+
issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
|
|
154
|
+
});
|
|
155
|
+
return issues;
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
function admissionValidationResult(
|
|
134
159
|
issue: UdlIssue,
|
|
135
160
|
invalidKeyword: string,
|
|
@@ -152,6 +177,7 @@ type AddIssue = (path: readonly PropertyKey[], message: string) => void;
|
|
|
152
177
|
|
|
153
178
|
const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
|
|
154
179
|
const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
|
|
180
|
+
const currencyPattern = "^[A-Z]{3}$";
|
|
155
181
|
const sealedDateTimeFormat = "hyperscale-date-time";
|
|
156
182
|
const jsonSchemaFormats = new Set([
|
|
157
183
|
"hyperscale-date",
|
|
@@ -902,26 +928,37 @@ function validateSetsAt(
|
|
|
902
928
|
base: readonly PropertyKey[],
|
|
903
929
|
add: AddIssue,
|
|
904
930
|
): void {
|
|
905
|
-
const writers = new Map<string, string>();
|
|
931
|
+
const writers = new Map<string, string[]>();
|
|
932
|
+
const markerFields = new Set<string>();
|
|
906
933
|
for (const [verbName, verb] of Object.entries(noun.verbs)) {
|
|
907
934
|
if (!verb.setsAt) continue;
|
|
908
935
|
const path = [...base, "verbs", verbName, "setsAt"] as const;
|
|
909
|
-
const { field, offset } = verb.setsAt;
|
|
936
|
+
const { field, marker, offset } = verb.setsAt;
|
|
937
|
+
if (marker) markerFields.add(field);
|
|
910
938
|
if (verbName === "create") {
|
|
911
939
|
add(
|
|
912
940
|
path,
|
|
913
941
|
"setsAt requires a lifecycle transition and cannot run on create",
|
|
914
942
|
);
|
|
915
943
|
}
|
|
916
|
-
const
|
|
917
|
-
if (
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
944
|
+
const fieldWriters = writers.get(field) ?? [];
|
|
945
|
+
if (fieldWriters.length > 0) {
|
|
946
|
+
const alternatives = [...fieldWriters, verbName];
|
|
947
|
+
const targets = new Set(
|
|
948
|
+
alternatives.map((writer) => noun.lifecycle.transitions[writer]?.to),
|
|
921
949
|
);
|
|
922
|
-
|
|
923
|
-
|
|
950
|
+
const reentrant = alternatives.some((writer) => {
|
|
951
|
+
const transition = noun.lifecycle.transitions[writer];
|
|
952
|
+
return transition?.from.includes(transition.to) === true;
|
|
953
|
+
});
|
|
954
|
+
if (targets.size !== 1 || targets.has(undefined) || reentrant) {
|
|
955
|
+
add(
|
|
956
|
+
[...path, "field"],
|
|
957
|
+
`${field} has multiple writers without one shared one-way destination`,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
924
960
|
}
|
|
961
|
+
writers.set(field, [...fieldWriters, verbName]);
|
|
925
962
|
|
|
926
963
|
const schema = noun.fields[field];
|
|
927
964
|
if (!schema) {
|
|
@@ -947,6 +984,15 @@ function validateSetsAt(
|
|
|
947
984
|
([, candidate]) =>
|
|
948
985
|
candidate.due?.field === field || candidate.deadline?.field === field,
|
|
949
986
|
);
|
|
987
|
+
if (marker) {
|
|
988
|
+
if (readers.length > 0) {
|
|
989
|
+
add(
|
|
990
|
+
path,
|
|
991
|
+
`setsAt marker field ${field} cannot drive a due condition or deadline`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
950
996
|
if (readers.length === 0) {
|
|
951
997
|
add(
|
|
952
998
|
path,
|
|
@@ -954,28 +1000,33 @@ function validateSetsAt(
|
|
|
954
1000
|
);
|
|
955
1001
|
continue;
|
|
956
1002
|
}
|
|
1003
|
+
}
|
|
1004
|
+
for (const [field, fieldWriters] of writers) {
|
|
1005
|
+
if (markerFields.has(field)) continue;
|
|
1006
|
+
const readers = Object.entries(noun.verbs).filter(
|
|
1007
|
+
([, candidate]) =>
|
|
1008
|
+
candidate.due?.field === field || candidate.deadline?.field === field,
|
|
1009
|
+
);
|
|
957
1010
|
for (const [readerName] of readers) {
|
|
958
|
-
if (
|
|
959
|
-
readerName === verbName ||
|
|
960
|
-
!verbDominatesReader(noun, verbName, readerName)
|
|
961
|
-
) {
|
|
1011
|
+
if (!verbGroupDominatesReader(noun, fieldWriters, readerName)) {
|
|
962
1012
|
add(
|
|
963
1013
|
[...base, "verbs", readerName],
|
|
964
|
-
`verb ${readerName} can read ${field} before
|
|
1014
|
+
`verb ${readerName} can read ${field} before writers ${fieldWriters.join(" or ")}`,
|
|
965
1015
|
);
|
|
966
1016
|
}
|
|
967
1017
|
}
|
|
968
1018
|
}
|
|
969
1019
|
}
|
|
970
1020
|
|
|
971
|
-
function
|
|
1021
|
+
function verbGroupDominatesReader(
|
|
972
1022
|
noun: UdlNoun,
|
|
973
|
-
|
|
1023
|
+
writers: readonly string[],
|
|
974
1024
|
reader: string,
|
|
975
1025
|
): boolean {
|
|
976
|
-
if (
|
|
1026
|
+
if (writers.length === 0) return false;
|
|
977
1027
|
const readerTransition = noun.lifecycle.transitions[reader];
|
|
978
1028
|
if (!readerTransition) return false;
|
|
1029
|
+
const removed = new Set(writers);
|
|
979
1030
|
const reachable = new Set([noun.lifecycle.initial]);
|
|
980
1031
|
const pending = [noun.lifecycle.initial];
|
|
981
1032
|
while (pending.length > 0) {
|
|
@@ -984,7 +1035,7 @@ function verbDominatesReader(
|
|
|
984
1035
|
noun.lifecycle.transitions,
|
|
985
1036
|
)) {
|
|
986
1037
|
if (
|
|
987
|
-
verb
|
|
1038
|
+
removed.has(verb) ||
|
|
988
1039
|
!transition.from.includes(state) ||
|
|
989
1040
|
reachable.has(transition.to)
|
|
990
1041
|
) {
|
|
@@ -1112,6 +1163,7 @@ function validateVerbs(
|
|
|
1112
1163
|
references: ReferenceShapeBudget,
|
|
1113
1164
|
add: AddIssue,
|
|
1114
1165
|
): void {
|
|
1166
|
+
validatePayoutsAndSettlement(noun, base, references, add);
|
|
1115
1167
|
for (const [verb, definition] of Object.entries(noun.verbs)) {
|
|
1116
1168
|
const verbBase = [...base, "verbs", verb] as const;
|
|
1117
1169
|
if (verb === "create" && definition.input) {
|
|
@@ -1126,6 +1178,17 @@ function validateVerbs(
|
|
|
1126
1178
|
"verb input must declare an object-shaped JSON Schema",
|
|
1127
1179
|
);
|
|
1128
1180
|
}
|
|
1181
|
+
const inputFields = recordValue(definition.input?.properties);
|
|
1182
|
+
for (const [refKey, inputKey] of Object.entries(
|
|
1183
|
+
definition.captureInput ?? {},
|
|
1184
|
+
)) {
|
|
1185
|
+
if (!Object.hasOwn(inputFields, inputKey)) {
|
|
1186
|
+
add(
|
|
1187
|
+
[...verbBase, "captureInput", refKey],
|
|
1188
|
+
`captured receipt input references undeclared verb input field ${inputKey}`,
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1129
1192
|
|
|
1130
1193
|
addDuplicateIssues(
|
|
1131
1194
|
definition.moves.map((move) => move.key),
|
|
@@ -1229,7 +1292,178 @@ function validateVerbs(
|
|
|
1229
1292
|
}
|
|
1230
1293
|
}
|
|
1231
1294
|
|
|
1295
|
+
if (definition.signedSum) {
|
|
1296
|
+
const sumBase = [...verbBase, "signedSum"] as const;
|
|
1297
|
+
if (verb === "create") {
|
|
1298
|
+
add(
|
|
1299
|
+
sumBase,
|
|
1300
|
+
"signedSum cannot run on create: no parent row exists yet",
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
const generatedRefs = [
|
|
1304
|
+
{
|
|
1305
|
+
key: definition.signedSum.amountRef,
|
|
1306
|
+
path: [...sumBase, "amountRef"] as const,
|
|
1307
|
+
},
|
|
1308
|
+
...definition.signedSum.sources.map((source, sourceIndex) => ({
|
|
1309
|
+
key: source.subtotalRef,
|
|
1310
|
+
path: [...sumBase, "sources", sourceIndex, "subtotalRef"] as const,
|
|
1311
|
+
})),
|
|
1312
|
+
];
|
|
1313
|
+
addDuplicateIssues(
|
|
1314
|
+
generatedRefs.map((ref) => ref.key),
|
|
1315
|
+
sumBase,
|
|
1316
|
+
"signed sum ref",
|
|
1317
|
+
add,
|
|
1318
|
+
);
|
|
1319
|
+
const existingRefs = new Set([
|
|
1320
|
+
...Object.entries(noun.verbs).flatMap(([candidateVerb, candidate]) => [
|
|
1321
|
+
...Object.keys(candidate.captureInput ?? {}),
|
|
1322
|
+
...[...candidate.steps, ...candidate.moves].flatMap((step) =>
|
|
1323
|
+
Object.keys(step.capture ?? {}),
|
|
1324
|
+
),
|
|
1325
|
+
...(candidateVerb !== verb && candidate.signedSum
|
|
1326
|
+
? [
|
|
1327
|
+
candidate.signedSum.amountRef,
|
|
1328
|
+
...candidate.signedSum.sources.map(
|
|
1329
|
+
(source) => source.subtotalRef,
|
|
1330
|
+
),
|
|
1331
|
+
]
|
|
1332
|
+
: []),
|
|
1333
|
+
]),
|
|
1334
|
+
...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
|
|
1335
|
+
...(noun.subject ? ["subject"] : []),
|
|
1336
|
+
]);
|
|
1337
|
+
for (const ref of generatedRefs) {
|
|
1338
|
+
if (existingRefs.has(ref.key)) {
|
|
1339
|
+
add(
|
|
1340
|
+
ref.path,
|
|
1341
|
+
`signed sum ref ${ref.key} collides with an existing noun ref key`,
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (
|
|
1346
|
+
!definition.signedSum.sources.some((source) => source.sign === "add")
|
|
1347
|
+
) {
|
|
1348
|
+
add([...sumBase, "sources"], "signedSum needs at least one add source");
|
|
1349
|
+
}
|
|
1350
|
+
const parentCurrencies = Object.entries(noun.fields).filter(
|
|
1351
|
+
([, schema]) => isCurrencySchema(schema),
|
|
1352
|
+
);
|
|
1353
|
+
if (parentCurrencies.length !== 1) {
|
|
1354
|
+
add(
|
|
1355
|
+
sumBase,
|
|
1356
|
+
`signedSum parent ${noun.id} needs exactly one currency field`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
const sourceKeys = definition.signedSum.sources.map(
|
|
1360
|
+
(source) =>
|
|
1361
|
+
`${source.nounId}:${source.refField}:${source.amountField}:${source.sign}:${[...source.statuses].sort().join(",")}`,
|
|
1362
|
+
);
|
|
1363
|
+
addDuplicateIssues(
|
|
1364
|
+
sourceKeys,
|
|
1365
|
+
[...sumBase, "sources"],
|
|
1366
|
+
"signed sum source",
|
|
1367
|
+
add,
|
|
1368
|
+
);
|
|
1369
|
+
definition.signedSum.sources.forEach((source, sourceIndex) => {
|
|
1370
|
+
for (let priorIndex = 0; priorIndex < sourceIndex; priorIndex += 1) {
|
|
1371
|
+
const prior = definition.signedSum?.sources[priorIndex];
|
|
1372
|
+
if (
|
|
1373
|
+
!prior ||
|
|
1374
|
+
prior.nounId !== source.nounId ||
|
|
1375
|
+
prior.refField !== source.refField ||
|
|
1376
|
+
prior.amountField !== source.amountField ||
|
|
1377
|
+
prior.sign !== source.sign
|
|
1378
|
+
) {
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const priorStatuses = new Set(prior.statuses);
|
|
1382
|
+
const overlap = source.statuses.filter((status) =>
|
|
1383
|
+
priorStatuses.has(status),
|
|
1384
|
+
);
|
|
1385
|
+
if (overlap.length === 0) continue;
|
|
1386
|
+
add(
|
|
1387
|
+
[...sumBase, "sources", sourceIndex, "statuses"],
|
|
1388
|
+
`signed sum source overlaps source ${priorIndex} on statuses ${overlap.join(", ")}`,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
definition.signedSum.sources.forEach((source, sourceIndex) => {
|
|
1393
|
+
const sourceBase = [...sumBase, "sources", sourceIndex] as const;
|
|
1394
|
+
const child = nouns.get(source.nounId);
|
|
1395
|
+
if (!child) {
|
|
1396
|
+
add(
|
|
1397
|
+
[...sourceBase, "nounId"],
|
|
1398
|
+
`signedSum references unknown noun ${source.nounId}`,
|
|
1399
|
+
);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const refSchema = child.fields[source.refField];
|
|
1403
|
+
if (!refSchema || !references.accepts(refSchema, noun.idPrefix)) {
|
|
1404
|
+
add(
|
|
1405
|
+
[...sourceBase, "refField"],
|
|
1406
|
+
`${source.nounId}.${source.refField} must reference ${noun.id}`,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
const amountSchema = child.fields[source.amountField];
|
|
1410
|
+
if (!amountSchema || !isMoneySchema(amountSchema)) {
|
|
1411
|
+
add(
|
|
1412
|
+
[...sourceBase, "amountField"],
|
|
1413
|
+
`${source.nounId}.${source.amountField} must be a money field`,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
const childCurrencies = Object.entries(child.fields).filter(
|
|
1417
|
+
([, schema]) => isCurrencySchema(schema),
|
|
1418
|
+
);
|
|
1419
|
+
if (childCurrencies.length !== 1) {
|
|
1420
|
+
add(
|
|
1421
|
+
[...sourceBase, "nounId"],
|
|
1422
|
+
`signedSum source ${source.nounId} needs exactly one currency field`,
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
addDuplicateIssues(
|
|
1426
|
+
source.statuses,
|
|
1427
|
+
[...sourceBase, "statuses"],
|
|
1428
|
+
"signed sum status",
|
|
1429
|
+
add,
|
|
1430
|
+
);
|
|
1431
|
+
source.statuses.forEach((status, statusIndex) => {
|
|
1432
|
+
if (!child.lifecycle.states.includes(status)) {
|
|
1433
|
+
add(
|
|
1434
|
+
[...sourceBase, "statuses", statusIndex],
|
|
1435
|
+
`signedSum status ${status} is not declared by ${source.nounId}`,
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
const amountPath = `refs.${definition.signedSum.amountRef}`;
|
|
1441
|
+
const payoutMoves = Object.values(noun.verbs).flatMap((candidate) =>
|
|
1442
|
+
candidate.moves.filter(
|
|
1443
|
+
(move) =>
|
|
1444
|
+
move.operation === "internal_transfer.create" &&
|
|
1445
|
+
move.bind.amount?.from === "instance" &&
|
|
1446
|
+
move.bind.amount.path === amountPath,
|
|
1447
|
+
),
|
|
1448
|
+
);
|
|
1449
|
+
const payouts = Object.values(noun.verbs).filter(
|
|
1450
|
+
(candidate) => candidate.payout?.amount === amountPath,
|
|
1451
|
+
);
|
|
1452
|
+
if (payoutMoves.length + payouts.length !== 1) {
|
|
1453
|
+
add(
|
|
1454
|
+
sumBase,
|
|
1455
|
+
`signedSum requires exactly one payout or noun transfer whose amount is ${amountPath}`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1232
1460
|
if (definition.due) {
|
|
1461
|
+
if (definition.publicIntent) {
|
|
1462
|
+
add(
|
|
1463
|
+
[...verbBase, "publicIntent"],
|
|
1464
|
+
"a system due verb cannot declare a public intent",
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1233
1467
|
const transition = noun.lifecycle.transitions[verb];
|
|
1234
1468
|
if (definition.due.every) {
|
|
1235
1469
|
// A recurring due verb is stationary: each occurrence fires in place
|
|
@@ -1321,6 +1555,236 @@ function validateVerbs(
|
|
|
1321
1555
|
}
|
|
1322
1556
|
}
|
|
1323
1557
|
|
|
1558
|
+
function validatePayoutsAndSettlement(
|
|
1559
|
+
noun: UdlNoun,
|
|
1560
|
+
base: readonly PropertyKey[],
|
|
1561
|
+
references: ReferenceShapeBudget,
|
|
1562
|
+
add: AddIssue,
|
|
1563
|
+
): void {
|
|
1564
|
+
const payoutEntries = Object.entries(noun.verbs).filter(
|
|
1565
|
+
([, definition]) => definition.payout !== undefined,
|
|
1566
|
+
);
|
|
1567
|
+
const settlementEntries = Object.entries(noun.verbs).filter(
|
|
1568
|
+
([, definition]) => definition.requiresSettlement !== undefined,
|
|
1569
|
+
);
|
|
1570
|
+
if (payoutEntries.length > 0 && settlementEntries.length !== 1) {
|
|
1571
|
+
add(
|
|
1572
|
+
[...base, "verbs"],
|
|
1573
|
+
`payout-owning noun must declare exactly one settlement gate; found ${settlementEntries.length}`,
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
const settlementRequirement =
|
|
1577
|
+
settlementEntries.length === 1
|
|
1578
|
+
? settlementEntries[0]?.[1].requiresSettlement
|
|
1579
|
+
: undefined;
|
|
1580
|
+
if (settlementRequirement) {
|
|
1581
|
+
for (const [verb, definition] of payoutEntries) {
|
|
1582
|
+
if (definition.payout?.capture === settlementRequirement.payoutRef) {
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
add(
|
|
1586
|
+
[...base, "verbs", verb, "payout", "capture"],
|
|
1587
|
+
`verb ${verb} payout capture ${definition.payout?.capture} is not covered by settlement payoutRef ${settlementRequirement.payoutRef}`,
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const reservedRefs = new Set([
|
|
1593
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1594
|
+
Object.keys(verb.captureInput ?? {}),
|
|
1595
|
+
),
|
|
1596
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1597
|
+
[...verb.steps, ...verb.moves].flatMap((step) =>
|
|
1598
|
+
Object.keys(step.capture ?? {}),
|
|
1599
|
+
),
|
|
1600
|
+
),
|
|
1601
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1602
|
+
verb.signedSum
|
|
1603
|
+
? [
|
|
1604
|
+
verb.signedSum.amountRef,
|
|
1605
|
+
...verb.signedSum.sources.map((source) => source.subtotalRef),
|
|
1606
|
+
]
|
|
1607
|
+
: [],
|
|
1608
|
+
),
|
|
1609
|
+
...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
|
|
1610
|
+
...(noun.subject ? ["subject"] : []),
|
|
1611
|
+
]);
|
|
1612
|
+
const moneyWriters = moneyRefWriters(noun);
|
|
1613
|
+
const payoutWriters = new Map<string, string[]>();
|
|
1614
|
+
for (const [verb, definition] of Object.entries(noun.verbs)) {
|
|
1615
|
+
if (!definition.payout) continue;
|
|
1616
|
+
const payout = definition.payout;
|
|
1617
|
+
const payoutBase = [...base, "verbs", verb, "payout"] as const;
|
|
1618
|
+
if (verb === "create") {
|
|
1619
|
+
add(payoutBase, "create cannot declare a payout intent");
|
|
1620
|
+
}
|
|
1621
|
+
if (definition.steps.length > 0 || definition.moves.length > 0) {
|
|
1622
|
+
add(
|
|
1623
|
+
payoutBase,
|
|
1624
|
+
`verb ${verb} payout intent cannot combine with kernel steps or moves`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
const prior = payoutWriters.get(payout.capture) ?? [];
|
|
1628
|
+
if (reservedRefs.has(payout.capture) || prior.length > 0) {
|
|
1629
|
+
add(
|
|
1630
|
+
[...payoutBase, "capture"],
|
|
1631
|
+
`payout capture ${payout.capture} collides with an existing noun ref key`,
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
payoutWriters.set(payout.capture, [...prior, verb]);
|
|
1635
|
+
|
|
1636
|
+
const amount = payout.amount;
|
|
1637
|
+
const [scope, key] = amount.split(".") as [string, string];
|
|
1638
|
+
const amountWriters = moneyWriters.get(key) ?? [];
|
|
1639
|
+
const amountDeclared =
|
|
1640
|
+
scope === "fields"
|
|
1641
|
+
? isMoneySchema(noun.fields[key] ?? {})
|
|
1642
|
+
: scope === "refs" && amountWriters.length > 0;
|
|
1643
|
+
if (!amountDeclared) {
|
|
1644
|
+
add(
|
|
1645
|
+
[...payoutBase, "amount"],
|
|
1646
|
+
`payout amount ${amount} must name a declared money field or money ref`,
|
|
1647
|
+
);
|
|
1648
|
+
} else if (
|
|
1649
|
+
scope === "refs" &&
|
|
1650
|
+
!amountWriters.includes(verb) &&
|
|
1651
|
+
!amountWriters.includes("create") &&
|
|
1652
|
+
!verbGroupDominatesReader(noun, amountWriters, verb)
|
|
1653
|
+
) {
|
|
1654
|
+
add(
|
|
1655
|
+
[...payoutBase, "amount"],
|
|
1656
|
+
`payout amount ${amount} can be read before money writers ${amountWriters.join(" or ")}`,
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
const currency = noun.fields[payout.currencyField];
|
|
1661
|
+
if (!currency || !isCurrencySchema(currency)) {
|
|
1662
|
+
add(
|
|
1663
|
+
[...payoutBase, "currencyField"],
|
|
1664
|
+
`payout currencyField ${payout.currencyField} must name a currency field`,
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
const source = noun.fields[payout.sourceAccountField];
|
|
1668
|
+
if (!source || !references.accepts(source, "acct")) {
|
|
1669
|
+
add(
|
|
1670
|
+
[...payoutBase, "sourceAccountField"],
|
|
1671
|
+
`payout sourceAccountField ${payout.sourceAccountField} must name an account-id field`,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
const beneficiary = noun.fields[payout.beneficiaryField];
|
|
1675
|
+
if (!beneficiary || !references.accepts(beneficiary, "ben")) {
|
|
1676
|
+
add(
|
|
1677
|
+
[...payoutBase, "beneficiaryField"],
|
|
1678
|
+
`payout beneficiaryField ${payout.beneficiaryField} must name a beneficiary-id field`,
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
const beneficiaryPartyField = noun.parties?.beneficiary;
|
|
1682
|
+
if (!beneficiaryPartyField) {
|
|
1683
|
+
add(
|
|
1684
|
+
[...payoutBase, "beneficiaryPartyField"],
|
|
1685
|
+
"payout requires parties.beneficiary to bind its destination party",
|
|
1686
|
+
);
|
|
1687
|
+
} else if (payout.beneficiaryPartyField !== beneficiaryPartyField) {
|
|
1688
|
+
add(
|
|
1689
|
+
[...payoutBase, "beneficiaryPartyField"],
|
|
1690
|
+
`payout beneficiaryPartyField ${payout.beneficiaryPartyField} must equal parties.beneficiary ${beneficiaryPartyField}`,
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const settlementCaptures = new Set<string>();
|
|
1696
|
+
for (const [verb, definition] of Object.entries(noun.verbs)) {
|
|
1697
|
+
if (!definition.requiresSettlement) continue;
|
|
1698
|
+
const requirement = definition.requiresSettlement;
|
|
1699
|
+
const requirementBase = [
|
|
1700
|
+
...base,
|
|
1701
|
+
"verbs",
|
|
1702
|
+
verb,
|
|
1703
|
+
"requiresSettlement",
|
|
1704
|
+
] as const;
|
|
1705
|
+
if (
|
|
1706
|
+
reservedRefs.has(requirement.capture) ||
|
|
1707
|
+
payoutWriters.has(requirement.capture) ||
|
|
1708
|
+
settlementCaptures.has(requirement.capture)
|
|
1709
|
+
) {
|
|
1710
|
+
add(
|
|
1711
|
+
[...requirementBase, "capture"],
|
|
1712
|
+
`settlement capture ${requirement.capture} collides with an existing noun ref key`,
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
settlementCaptures.add(requirement.capture);
|
|
1716
|
+
|
|
1717
|
+
const writers = payoutWriters.get(requirement.payoutRef) ?? [];
|
|
1718
|
+
if (writers.length === 0) {
|
|
1719
|
+
add(
|
|
1720
|
+
[...requirementBase, "payoutRef"],
|
|
1721
|
+
`requiresSettlement payoutRef ${requirement.payoutRef} is not captured by a payout intent`,
|
|
1722
|
+
);
|
|
1723
|
+
} else if (!verbGroupDominatesReader(noun, writers, verb)) {
|
|
1724
|
+
add(
|
|
1725
|
+
requirementBase,
|
|
1726
|
+
`requiresSettlement can read ${requirement.payoutRef} before payout writers ${writers.join(" or ")}`,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (!noun.lifecycle.transitions[verb]) {
|
|
1731
|
+
add(
|
|
1732
|
+
requirementBase,
|
|
1733
|
+
"requiresSettlement needs a lifecycle transition and cannot run on create",
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
const callerFacets = [
|
|
1737
|
+
definition.port ? "port" : undefined,
|
|
1738
|
+
definition.publicIntent ? "publicIntent" : undefined,
|
|
1739
|
+
definition.input ? "input" : undefined,
|
|
1740
|
+
definition.captureInput ? "captureInput" : undefined,
|
|
1741
|
+
definition.due ? "due" : undefined,
|
|
1742
|
+
definition.deadline ? "deadline" : undefined,
|
|
1743
|
+
].filter((facet): facet is string => facet !== undefined);
|
|
1744
|
+
for (const facet of callerFacets) {
|
|
1745
|
+
add(
|
|
1746
|
+
[...base, "verbs", verb, facet],
|
|
1747
|
+
`requiresSettlement is system-only and cannot declare ${facet}`,
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (definition.steps.length > 0) {
|
|
1751
|
+
add(
|
|
1752
|
+
[...base, "verbs", verb, "steps"],
|
|
1753
|
+
"requiresSettlement cannot add kernel steps",
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
if (definition.moves.length > 0) {
|
|
1757
|
+
add(
|
|
1758
|
+
[...base, "verbs", verb, "moves"],
|
|
1759
|
+
"requiresSettlement cannot move money",
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function moneyRefWriters(noun: UdlNoun): ReadonlyMap<string, string[]> {
|
|
1766
|
+
const writers = new Map<string, string[]>();
|
|
1767
|
+
for (const [verbName, verb] of Object.entries(noun.verbs)) {
|
|
1768
|
+
const refs = [
|
|
1769
|
+
...(verb.signedSum
|
|
1770
|
+
? [
|
|
1771
|
+
verb.signedSum.amountRef,
|
|
1772
|
+
...verb.signedSum.sources.map((source) => source.subtotalRef),
|
|
1773
|
+
]
|
|
1774
|
+
: []),
|
|
1775
|
+
...[...verb.steps, ...verb.moves].flatMap((step) =>
|
|
1776
|
+
Object.entries(step.capture ?? {}).flatMap(([ref, output]) =>
|
|
1777
|
+
output === "postedAmount" ? [ref] : [],
|
|
1778
|
+
),
|
|
1779
|
+
),
|
|
1780
|
+
];
|
|
1781
|
+
for (const ref of refs) {
|
|
1782
|
+
writers.set(ref, [...(writers.get(ref) ?? []), verbName]);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return writers;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1324
1788
|
/**
|
|
1325
1789
|
* Shape rules for one resolved reference gate: derived bindings and the
|
|
1326
1790
|
* uniqueness claim are create-only, bind keys must be declared immutable
|
|
@@ -1415,11 +1879,28 @@ function validateGateShape(
|
|
|
1415
1879
|
/** Every `refs.<key>` a noun's instances can legitimately carry. */
|
|
1416
1880
|
function declaredRefKeys(noun: UdlNoun): ReadonlySet<string> {
|
|
1417
1881
|
return new Set([
|
|
1882
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1883
|
+
verb.payout ? [verb.payout.capture] : [],
|
|
1884
|
+
),
|
|
1885
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1886
|
+
verb.requiresSettlement ? [verb.requiresSettlement.capture] : [],
|
|
1887
|
+
),
|
|
1888
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1889
|
+
Object.keys(verb.captureInput ?? {}),
|
|
1890
|
+
),
|
|
1418
1891
|
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1419
1892
|
[...verb.steps, ...verb.moves].flatMap((step) =>
|
|
1420
1893
|
Object.keys(step.capture ?? {}),
|
|
1421
1894
|
),
|
|
1422
1895
|
),
|
|
1896
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1897
|
+
verb.signedSum
|
|
1898
|
+
? [
|
|
1899
|
+
verb.signedSum.amountRef,
|
|
1900
|
+
...verb.signedSum.sources.map((source) => source.subtotalRef),
|
|
1901
|
+
]
|
|
1902
|
+
: [],
|
|
1903
|
+
),
|
|
1423
1904
|
...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
|
|
1424
1905
|
...(noun.subject ? ["subject"] : []),
|
|
1425
1906
|
]);
|
|
@@ -1837,6 +2318,10 @@ function isMoneySchema(schema: Readonly<Record<string, unknown>>): boolean {
|
|
|
1837
2318
|
);
|
|
1838
2319
|
}
|
|
1839
2320
|
|
|
2321
|
+
function isCurrencySchema(schema: Readonly<Record<string, unknown>>): boolean {
|
|
2322
|
+
return schema.pattern === currencyPattern;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
1840
2325
|
/**
|
|
1841
2326
|
* One budget's worth of reference-shape classification: the memo of the answers
|
|
1842
2327
|
* already bought and what is left of the probe budget. What is guaranteed is
|