@hyperscale0/hsx 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +28 -4
  2. package/README.md +58 -38
  3. package/dist/src/ast.d.ts +4 -2
  4. package/dist/src/ast.d.ts.map +1 -1
  5. package/dist/src/cli.d.ts +1 -1
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/compile.d.ts +8 -2
  8. package/dist/src/compile.d.ts.map +1 -1
  9. package/dist/src/compile.js +8 -1
  10. package/dist/src/compile.js.map +1 -1
  11. package/dist/src/cost.d.ts +3 -1
  12. package/dist/src/cost.d.ts.map +1 -1
  13. package/dist/src/cost.js +124 -94
  14. package/dist/src/cost.js.map +1 -1
  15. package/dist/src/diagnostics.d.ts.map +1 -1
  16. package/dist/src/diagnostics.js +96 -5
  17. package/dist/src/diagnostics.js.map +1 -1
  18. package/dist/src/emit.d.ts +3 -1
  19. package/dist/src/emit.d.ts.map +1 -1
  20. package/dist/src/emit.js +27 -10
  21. package/dist/src/emit.js.map +1 -1
  22. package/dist/src/format.js.map +1 -1
  23. package/dist/src/index.d.ts +2 -2
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +1 -1
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/ir.d.ts.map +1 -1
  28. package/dist/src/lex.d.ts.map +1 -1
  29. package/dist/src/lsp/server.js.map +1 -1
  30. package/dist/src/modules.js +7 -3
  31. package/dist/src/modules.js.map +1 -1
  32. package/dist/src/parse.js +3 -3
  33. package/dist/src/parse.js.map +1 -1
  34. package/dist/src/std-bundle.js +41 -41
  35. package/dist/src/std-bundle.js.map +1 -1
  36. package/dist/src/std-library.js +2 -2
  37. package/dist/src/typecheck.d.ts.map +1 -1
  38. package/dist/src/typecheck.js +948 -157
  39. package/dist/src/typecheck.js.map +1 -1
  40. package/dist/src/version.d.ts +1 -1
  41. package/dist/src/version.js +1 -1
  42. package/docs/README.md +10 -1
  43. package/docs/assets/hsx.svg +18 -0
  44. package/docs/guide/01-first-program.md +1 -1
  45. package/docs/guide/02-money.md +1 -1
  46. package/docs/guide/03-instruments.md +44 -3
  47. package/docs/guide/04-lifecycles.md +18 -3
  48. package/docs/guide/05-fees-and-splits.md +2 -2
  49. package/docs/guide/06-schedules.md +1 -1
  50. package/docs/guide/07-composition.md +8 -2
  51. package/docs/guide/08-writing-a-module.md +13 -2
  52. package/docs/guide/09-cost.md +3 -3
  53. package/docs/guide/10-diagnostics.md +1 -1
  54. package/docs/llms-full.txt +473 -190
  55. package/docs/llms.txt +2 -2
  56. package/docs/playground.md +4 -3
  57. package/docs/reference/cli.md +2 -2
  58. package/docs/reference/diagnostics.md +243 -37
  59. package/docs/reference/grammar.md +2 -2
  60. package/docs/reference/std/advance.md +2 -2
  61. package/docs/reference/std/cancellable_booking.md +10 -10
  62. package/docs/reference/std/captured_payment.md +14 -14
  63. package/docs/reference/std/conditional_disbursement.md +3 -3
  64. package/docs/reference/std/credit_facility.md +2 -2
  65. package/docs/reference/std/held_payment.md +37 -33
  66. package/docs/reference/std/instant_transfer.md +8 -8
  67. package/docs/reference/std/metered.md +2 -2
  68. package/docs/reference/std/pooled_split.md +2 -2
  69. package/docs/reference/std/premium_forward.md +13 -13
  70. package/docs/reference/std/reconciled_payout.md +2 -2
  71. package/docs/reference/std/recurring_collection.md +2 -2
  72. package/docs/reference/std/rotating_pool.md +2 -2
  73. package/docs/reference/std/scheduled.md +18 -18
  74. package/docs/reference/std/security_deposit.md +14 -14
  75. package/docs/reference/std/settlement_batch.md +2 -2
  76. package/docs/reference/std/swap.md +18 -18
  77. package/docs/reference/std/threshold_pool.md +2 -2
  78. package/docs/reference/std/weighted_distribution.md +2 -2
  79. package/docs/reference/types.md +1 -1
  80. package/docs/reference/udl-output.md +1 -1
  81. package/examples/01-first-program/README.md +1 -1
  82. package/examples/01-first-program/tip-jar.hsx +1 -1
  83. package/examples/02-imports-and-archetypes/photo-booth.hsx +1 -1
  84. package/examples/03-diagnostics/corner-shop-fixed.hsx +1 -1
  85. package/examples/03-diagnostics/corner-shop.hsx +1 -1
  86. package/examples/04-complete-product/study-hall.hsx +1 -1
  87. package/examples/05-watch-club/README.md +0 -3
  88. package/examples/05-watch-club/watch-club.hsx +1 -1
  89. package/examples/README.md +35 -4
  90. package/examples/advance/advance.hsx +1 -1
  91. package/examples/advance/advance.udl +16 -11
  92. package/examples/cancellable_booking/cancellable_booking.hsx +1 -1
  93. package/examples/cancellable_booking/cancellable_booking.udl +13 -11
  94. package/examples/captured_payment/captured_payment.hsx +1 -1
  95. package/examples/captured_payment/captured_payment.udl +10 -7
  96. package/examples/conditional_disbursement/conditional_disbursement.hsx +1 -1
  97. package/examples/conditional_disbursement/conditional_disbursement.udl +7 -5
  98. package/examples/cost-table.json +1542 -382
  99. package/examples/credit_facility/credit_facility.hsx +1 -1
  100. package/examples/credit_facility/credit_facility.udl +26 -19
  101. package/examples/held_payment/held_payment.hsx +1 -1
  102. package/examples/held_payment/held_payment.udl +61 -15
  103. package/examples/instant_transfer/instant_transfer.hsx +1 -1
  104. package/examples/instant_transfer/instant_transfer.udl +17 -11
  105. package/examples/metered/metered.hsx +1 -1
  106. package/examples/metered/metered.udl +8 -6
  107. package/examples/pooled_split/pooled_split.hsx +1 -1
  108. package/examples/pooled_split/pooled_split.udl +16 -13
  109. package/examples/premium_forward/premium_forward.hsx +1 -1
  110. package/examples/premium_forward/premium_forward.udl +22 -16
  111. package/examples/reconciled_payout/reconciled_payout.hsx +1 -1
  112. package/examples/reconciled_payout/reconciled_payout.udl +4 -2
  113. package/examples/recurring_collection/recurring_collection.hsx +1 -1
  114. package/examples/recurring_collection/recurring_collection.udl +20 -15
  115. package/examples/rotating_pool/rotating_pool.hsx +1 -1
  116. package/examples/rotating_pool/rotating_pool.udl +92 -82
  117. package/examples/scheduled/scheduled.hsx +1 -1
  118. package/examples/scheduled/scheduled.udl +15 -10
  119. package/examples/security_deposit/security_deposit.hsx +1 -1
  120. package/examples/security_deposit/security_deposit.udl +19 -5
  121. package/examples/settlement_batch/settlement_batch.hsx +1 -1
  122. package/examples/swap/swap.hsx +1 -1
  123. package/examples/swap/swap.udl +34 -28
  124. package/examples/threshold_pool/threshold_pool.hsx +1 -1
  125. package/examples/threshold_pool/threshold_pool.udl +14 -12
  126. package/examples/weighted_distribution/weighted_distribution.hsx +1 -1
  127. package/examples/weighted_distribution/weighted_distribution.udl +6 -4
  128. package/package.json +6 -5
  129. package/skills/hsx/SKILL.md +26 -26
  130. package/src/ast.ts +4 -2
  131. package/src/cli.ts +4 -4
  132. package/src/compile.ts +14 -3
  133. package/src/cost.ts +176 -104
  134. package/src/diagnostics.ts +99 -4
  135. package/src/emit.ts +32 -18
  136. package/src/index.ts +2 -1
  137. package/src/modules.ts +9 -3
  138. package/src/parse.ts +3 -3
  139. package/src/std-bundle.ts +41 -41
  140. package/src/std-library.ts +2 -2
  141. package/src/typecheck.ts +1251 -235
  142. package/src/version.ts +1 -1
  143. package/std/SEMANTICS.md +12 -3
  144. package/std/{settlements → money_flows}/advance.hsx +1 -1
  145. package/std/{settlements → money_flows}/cancellable_booking.hsx +4 -3
  146. package/std/{settlements → money_flows}/captured_payment.hsx +35 -6
  147. package/std/{settlements → money_flows}/conditional_disbursement.hsx +17 -16
  148. package/std/{settlements → money_flows}/credit_facility.hsx +1 -2
  149. package/std/{settlements → money_flows}/held_payment.hsx +61 -25
  150. package/std/money_flows/index.hsx +3 -0
  151. package/std/{settlements → money_flows}/instant_transfer.hsx +2 -3
  152. package/std/{settlements → money_flows}/metered.hsx +1 -1
  153. package/std/{settlements → money_flows}/pooled_split.hsx +3 -3
  154. package/std/{settlements → money_flows}/premium_forward.hsx +22 -23
  155. package/std/{settlements → money_flows}/reconciled_payout.hsx +3 -3
  156. package/std/{settlements → money_flows}/recurring_collection.hsx +1 -1
  157. package/std/{settlements → money_flows}/rotating_pool.hsx +8 -6
  158. package/std/{settlements → money_flows}/scheduled.hsx +22 -27
  159. package/std/{settlements → money_flows}/security_deposit.hsx +38 -14
  160. package/std/{settlements → money_flows}/settlement_batch.hsx +3 -3
  161. package/std/{settlements → money_flows}/swap.hsx +41 -16
  162. package/std/{settlements → money_flows}/threshold_pool.hsx +5 -5
  163. package/std/{settlements → money_flows}/weighted_distribution.hsx +6 -6
  164. package/docs/sessions/2026-09-02-two-instruments.hsx +0 -21
  165. package/docs/sessions/2026-09-02-two-instruments.md +0 -41
  166. package/std/settlements/index.hsx +0 -3
@@ -5,6 +5,7 @@ const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
5
5
  const CURRENCY = /^[A-Z]{3}$/;
6
6
  const MONEY_PATTERN = "^[1-9][0-9]{0,17}$";
7
7
  const OPTIONAL_MONEY_PATTERN = "^(0|[1-9][0-9]{0,17})$";
8
+ const ACCOUNT_PATTERN = "^acct_(sandbox|live)_[a-z0-9]{8,64}$";
8
9
  const MAX_COMPREHENSION_EXPANSIONS = 256;
9
10
  const NONE_SENTINEL = "__hsx_none__";
10
11
  const actionClauseBySpelling = new Map(udlClauseVocabulary
@@ -43,6 +44,26 @@ export function checkGeneralProgram(program, options = {}) {
43
44
  const ports = new Map(program.decls
44
45
  .filter((decl) => decl.kind === "port")
45
46
  .map((decl) => [decl.name.name, decl]));
47
+ const checkedPorts = new Set();
48
+ const checkPortDeclarations = (declared, knownParties) => {
49
+ for (const port of declared.values()) {
50
+ if (checkedPorts.has(port))
51
+ continue;
52
+ checkedPorts.add(port);
53
+ const allowed = entry(port.body, "allowed")?.value;
54
+ for (const item of allowed?.kind === "list"
55
+ ? allowed.items
56
+ : allowed
57
+ ? [allowed]
58
+ : []) {
59
+ const party = nameText(item);
60
+ if (knownParties.has(party))
61
+ continue;
62
+ report("HSX1024", item.span, `decision port ${port.name.name} allows undeclared party ${party}; declare the party before using it`, `declare party ${party} with its party kind before using it in this port`);
63
+ }
64
+ }
65
+ };
66
+ checkPortDeclarations(ports, parties);
46
67
  const assetSubjects = program.decls
47
68
  .filter((decl) => decl.kind === "asset")
48
69
  .map((decl) => ({
@@ -89,16 +110,17 @@ export function checkGeneralProgram(program, options = {}) {
89
110
  });
90
111
  }
91
112
  const compositionDeclarations = program.decls.filter((declaration) => declaration.kind === "use" || declaration.kind === "expose");
92
- const authoredInstrumentDeclarations = program.decls.filter((declaration) => declaration.kind === "instrument" ||
93
- declaration.kind === "instrument_apply");
94
- if (options.publishedCatalog &&
95
- (compositionDeclarations.length > 0 ||
96
- authoredInstrumentDeclarations.length === 0)) {
97
- return checkPublishedProgram(program, options.publishedCatalog, header, subjects, diagnostics, report);
98
- }
99
- for (const declaration of compositionDeclarations) {
100
- report("HSX1018", declaration.span, "this compiler host did not supply a published instrument catalog", "pass the canonical UDL catalog as the publishedCatalog compile option");
113
+ if (!options.publishedCatalog) {
114
+ for (const declaration of compositionDeclarations) {
115
+ report("HSX1018", declaration.span, "this compiler host did not supply a published instrument catalog", "pass the canonical UDL catalog as the publishedCatalog compile option");
116
+ }
101
117
  }
118
+ const catalogById = options.publishedCatalog
119
+ ? new Map(options.publishedCatalog.instruments.map((instrument) => [
120
+ instrument.id,
121
+ instrument,
122
+ ]))
123
+ : undefined;
102
124
  const aliases = new Map(program.decls
103
125
  .filter((decl) => decl.kind === "type")
104
126
  .map((decl) => [decl.name.name, decl.value]));
@@ -115,6 +137,8 @@ export function checkGeneralProgram(program, options = {}) {
115
137
  templates.set(decl.name.name, decl);
116
138
  }
117
139
  const concrete = [];
140
+ const reachedPorts = new Set();
141
+ const droppedPorts = new Set();
118
142
  const scopedDeclarations = (declarationScope) => {
119
143
  const scopedAliases = new Map(aliases);
120
144
  const scopedConstants = resolveConstants([
@@ -135,6 +159,7 @@ export function checkGeneralProgram(program, options = {}) {
135
159
  scopedTemplates.set(local.name.name, local);
136
160
  }
137
161
  }
162
+ checkPortDeclarations(scopedPorts, scopedParties);
138
163
  checkConstants(declarationScope, scopedAliases, diagnostics);
139
164
  return {
140
165
  aliases: scopedAliases,
@@ -149,13 +174,25 @@ export function checkGeneralProgram(program, options = {}) {
149
174
  !decl.hasParameterList &&
150
175
  decl.typeParameters.length === 0) {
151
176
  const scoped = scopedDeclarations(decl.declarationScope ?? []);
152
- concrete.push({
177
+ const values = new Map(scoped.constants);
178
+ const dependencies = new Map();
179
+ const shapes = new Map();
180
+ for (const [name, port] of scoped.ports) {
181
+ const reference = {
182
+ kind: "port_ref",
183
+ name: port.name,
184
+ span: port.span,
185
+ };
186
+ values.set(name, reference);
187
+ bindPortCompileValues(values, name, reference, scoped.ports, diagnostics, scoped.aliases, shapes, dependencies);
188
+ }
189
+ const selected = selectCompileTimeRows(decl.body, values, diagnostics, dependencies);
190
+ const body = expandComprehensions(selected, diagnostics, values, dependencies);
191
+ concrete.push(...bindActionPorts(constructedInstruments(decl.name, body, diagnostics), scoped.ports, reachedPorts).map((candidate) => ({
192
+ ...candidate,
153
193
  aliases: scoped.aliases,
154
- body: substituteExpr(decl.body, scoped.constants),
155
- generatedPrefix: false,
156
- name: decl.name,
157
194
  parties: scoped.parties,
158
- });
195
+ })));
159
196
  }
160
197
  if (decl.kind !== "instrument_apply")
161
198
  continue;
@@ -174,18 +211,31 @@ export function checkGeneralProgram(program, options = {}) {
174
211
  report("HSX1001", application.callee.span, `instrument function ${callee} is not bound by this module graph`, `import ${callee} from its module or declare it before instantiation`);
175
212
  continue;
176
213
  }
177
- const body = instantiate(template, application, diagnostics, decl.name, scoped.ports, scoped.parties, scoped.aliases, scoped.constants);
214
+ const boundPorts = new Map();
215
+ const body = instantiate(template, application, diagnostics, decl.name, scoped.ports, scoped.parties, scoped.aliases, scoped.constants, boundPorts);
178
216
  if (body) {
217
+ const applicationPorts = new Set();
179
218
  const merged = decl.metadata
180
219
  ? mergeApplicationMetadata(body, decl.metadata, diagnostics)
181
220
  : body;
182
- concrete.push(...constructedInstruments(decl.name, merged, diagnostics).map((candidate) => ({
221
+ concrete.push(...bindActionPorts(constructedInstruments(decl.name, merged, diagnostics), boundPorts, applicationPorts).map((candidate) => ({
183
222
  ...candidate,
184
223
  aliases: scoped.aliases,
185
224
  parties: scoped.parties,
186
225
  })));
226
+ for (const port of boundPorts.values()) {
227
+ if (applicationPorts.has(port))
228
+ reachedPorts.add(port);
229
+ else
230
+ droppedPorts.add(port);
231
+ }
187
232
  }
188
233
  }
234
+ for (const port of checkedPorts) {
235
+ if (reachedPorts.has(port) && !droppedPorts.has(port))
236
+ continue;
237
+ report("HSX1024", port.name.span, `wired decision port ${port.name.name} reaches no action`, `carry ${port.name.name}'s condition into the action it authorizes`);
238
+ }
189
239
  const allocated = allocateGeneratedPrefixes(concrete);
190
240
  const instruments = [];
191
241
  const ids = new Set();
@@ -195,11 +245,18 @@ export function checkGeneralProgram(program, options = {}) {
195
245
  continue;
196
246
  }
197
247
  ids.add(candidate.name.name);
248
+ if (catalogById?.has(candidate.name.name)) {
249
+ report("HSX1022", candidate.name.span, `authored instrument ${candidate.name.name} collides with published catalog instrument`, "rename the authored instrument to avoid colliding with the published catalog");
250
+ continue;
251
+ }
198
252
  const expandedBody = expandComprehensions(candidate.body, diagnostics);
199
- const checked = checkInstrument(candidate.name, expandedBody, candidate.parties, candidate.aliases, diagnostics);
253
+ const checked = checkInstrument(candidate.name, expandedBody, candidate.parties, candidate.ports, candidate.aliases, diagnostics);
200
254
  if (checked)
201
255
  instruments.push(checked);
202
256
  }
257
+ if (options.publishedCatalog) {
258
+ return checkPublishedProgram(program, options.publishedCatalog, header, subjects, instruments, diagnostics, report);
259
+ }
203
260
  crossInstrumentReferenceDiagnostics(instruments, diagnostics);
204
261
  if (header &&
205
262
  instruments.length === 0 &&
@@ -220,25 +277,22 @@ export function checkGeneralProgram(program, options = {}) {
220
277
  };
221
278
  return { diagnostics, program: typed };
222
279
  }
223
- function checkPublishedProgram(program, catalog, header, authoredSubjects, diagnostics, report) {
224
- if (!header) {
225
- return { diagnostics };
226
- }
227
- if (!header.title || header.title.value.length === 0) {
228
- report("HSX1015", header.span, `composed program ${header.name.name} needs a product title`, 'write the title after the product id, like `program product_name "Product title"`');
229
- }
230
- if (header.name.name.length > 60) {
231
- report("HSX1015", header.name.span, "a composed program product id is at most 60 characters", "shorten the product id without changing its snake_case spelling");
232
- }
233
- if ((header.title?.value.length ?? 0) > 80) {
234
- report("HSX1015", header.title?.span ?? header.span, "a composed program title is at most 80 characters", "shorten the product title to 80 characters or fewer");
235
- }
236
- const authoredInstruments = program.decls.filter((decl) => decl.kind === "instrument" || decl.kind === "instrument_apply");
237
- for (const declaration of authoredInstruments) {
238
- report("HSX1022", declaration.span, "a composed program cannot author an instrument", "publish the instrument in the catalog, then select it with use");
280
+ function checkPublishedProgram(program, catalog, header, authoredSubjects, authoredInstruments, diagnostics, report) {
281
+ const compositionDeclarations = program.decls.filter((decl) => decl.kind === "use" || decl.kind === "expose");
282
+ if (header &&
283
+ (compositionDeclarations.length > 0 || authoredInstruments.length === 0)) {
284
+ if (!header.title || header.title.value.length === 0) {
285
+ report("HSX1015", header.span, `composed program ${header.name.name} needs a product title`, 'write the title after the product id, like `program product_name "Product title"`');
286
+ }
287
+ if (header.name.name.length > 60) {
288
+ report("HSX1015", header.name.span, "a composed program product id is at most 60 characters", "shorten the product id without changing its snake_case spelling");
289
+ }
290
+ if ((header.title?.value.length ?? 0) > 80) {
291
+ report("HSX1015", header.title?.span ?? header.span, "a composed program title is at most 80 characters", "shorten the product title to 80 characters or fewer");
292
+ }
239
293
  }
240
294
  const uses = program.decls.filter((decl) => decl.kind === "use");
241
- if (uses.length === 0) {
295
+ if (header && uses.length === 0 && authoredInstruments.length === 0) {
242
296
  report("HSX1016", header.span, `composed program ${header.name.name} uses no published instruments`, "add at least one `use published_instrument` declaration");
243
297
  }
244
298
  const catalogById = new Map(catalog.instruments.map((instrument) => [instrument.id, instrument]));
@@ -261,6 +315,7 @@ function checkPublishedProgram(program, catalog, header, authoredSubjects, diagn
261
315
  const selected = publishedInstrumentClosure(uses, catalogById);
262
316
  const selectedById = new Map(selected.map((entry) => [entry.instrument.id, entry]));
263
317
  const exposures = program.decls.filter((decl) => decl.kind === "expose");
318
+ const authoredById = new Map(authoredInstruments.map((instrument) => [instrument.id, instrument]));
264
319
  const publicNames = new Map();
265
320
  const exposedTargets = new Map();
266
321
  for (const exposure of exposures) {
@@ -268,8 +323,11 @@ function checkPublishedProgram(program, catalog, header, authoredSubjects, diagn
268
323
  const explicitlyUsed = useById.has(exposure.instrument.name);
269
324
  const instrument = selectedById.get(exposure.instrument.name)?.instrument;
270
325
  const action = instrument?.actions[exposure.action.name];
271
- if (!explicitlyUsed || !instrument || !action) {
272
- report("HSX1021", exposure.span, `${target} is not an action on an explicitly used published instrument`, "add the matching use declaration and choose one of that instrument's actions");
326
+ const authoredInstrument = authoredById.get(exposure.instrument.name);
327
+ const authoredAction = authoredInstrument?.actions.find((candidate) => candidate.name === exposure.action.name);
328
+ if ((!explicitlyUsed || !instrument || !action) &&
329
+ (!authoredInstrument || !authoredAction)) {
330
+ report("HSX1021", exposure.span, `${target} is not an action on an explicitly used published instrument or authored instrument`, "add the matching use declaration and choose one of that instrument's actions");
273
331
  continue;
274
332
  }
275
333
  if (!CAMEL_CASE.test(exposure.publicName.name)) {
@@ -287,10 +345,44 @@ function checkPublishedProgram(program, catalog, header, authoredSubjects, diagn
287
345
  publicNames.set(exposure.publicName.name, exposure);
288
346
  exposedTargets.set(target, exposure);
289
347
  }
348
+ const updatedAuthoredInstruments = authoredInstruments.map((authoredInstrument) => {
349
+ const instrumentExposures = exposures.filter((exposure) => exposure.instrument.name === authoredInstrument.id);
350
+ if (instrumentExposures.length === 0)
351
+ return authoredInstrument;
352
+ const exposureByAction = new Map(instrumentExposures.map((exposure) => [exposure.action.name, exposure]));
353
+ return {
354
+ ...authoredInstrument,
355
+ actions: authoredInstrument.actions.map((action) => {
356
+ const exposure = exposureByAction.get(action.name);
357
+ if (!exposure)
358
+ return action;
359
+ return {
360
+ ...action,
361
+ origin: exposure.span,
362
+ slots: {
363
+ ...action.slots,
364
+ publicAction: exposure.publicName.name,
365
+ },
366
+ };
367
+ }),
368
+ };
369
+ });
370
+ const publishedInstruments = selected.map(({ instrument, origin }) => {
371
+ const projected = projectedPublishedInstrument(instrument, exposures);
372
+ return typedPublishedInstrument(projected, origin, exposures.filter((exposure) => exposure.instrument.name === instrument.id), catalog.instruments);
373
+ });
374
+ const instruments = [...publishedInstruments, ...updatedAuthoredInstruments];
375
+ crossInstrumentReferenceDiagnostics(instruments, diagnostics);
376
+ if (header &&
377
+ instruments.length === 0 &&
378
+ !diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
379
+ report("HSX1502", header.span, `program ${header.name.name} emits no instruments`, "declare or instantiate at least one instrument");
380
+ }
290
381
  const referencedSubjectKinds = new Set(selected.flatMap(({ instrument }) => instrument.subject?.kinds ?? []));
382
+ const defaultSpan = uses[0]?.span ?? header?.span ?? program.span;
291
383
  const catalogSubjects = catalog.subjects
292
384
  .filter((subject) => referencedSubjectKinds.has(subject.kind))
293
- .map((subject) => typedPublishedSubject(subject, uses[0]?.span ?? header.span));
385
+ .map((subject) => typedPublishedSubject(subject, defaultSpan));
294
386
  const catalogSubjectKinds = new Set(catalogSubjects.map((subject) => subject.kind));
295
387
  for (const subject of authoredSubjects) {
296
388
  if (!catalogSubjectKinds.has(subject.kind))
@@ -300,19 +392,18 @@ function checkPublishedProgram(program, catalog, header, authoredSubjects, diagn
300
392
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
301
393
  return { diagnostics };
302
394
  }
395
+ const module = program.decls.find((decl) => decl.kind === "module");
396
+ const name = header?.name.name ?? module?.name.parts.at(-1)?.name ?? "module";
303
397
  const typed = {
304
- instruments: selected.map(({ instrument, origin }) => {
305
- const projected = projectedPublishedInstrument(instrument, exposures);
306
- return typedPublishedInstrument(projected, origin, exposures.filter((exposure) => exposure.instrument.name === instrument.id));
307
- }),
398
+ instruments,
308
399
  kind: "typed_program",
309
- name: header.name.name,
400
+ name,
310
401
  origin: program.span,
311
402
  subjects: [
312
403
  ...catalogSubjects,
313
404
  ...authoredSubjects.filter((subject) => !catalogSubjectKinds.has(subject.kind)),
314
405
  ],
315
- title: header.title?.value ?? sentenceCase(header.name.name),
406
+ title: header?.title?.value ?? sentenceCase(name),
316
407
  };
317
408
  return { diagnostics, program: typed };
318
409
  }
@@ -390,7 +481,7 @@ function publishedInstrumentDependencies(instrument, idByPrefix) {
390
481
  found.delete(instrument.id);
391
482
  return [...found];
392
483
  }
393
- function typedPublishedInstrument(source, origin, exposures) {
484
+ function typedPublishedInstrument(source, origin, exposures, catalog) {
394
485
  const exposureByAction = new Map(exposures.map((exposure) => [exposure.action.name, exposure]));
395
486
  const { actionOrder, actions, fields, id: _id, ...slots } = structuredClone(source);
396
487
  const requiredFields = new Set(source.required);
@@ -412,7 +503,7 @@ function typedPublishedInstrument(source, origin, exposures) {
412
503
  origin,
413
504
  required: requiredFields.has(name),
414
505
  schema,
415
- type: { kind: "unknown" },
506
+ type: publishedFieldType(schema, catalog),
416
507
  })),
417
508
  id: source.id,
418
509
  origin,
@@ -422,6 +513,25 @@ function typedPublishedInstrument(source, origin, exposures) {
422
513
  },
423
514
  };
424
515
  }
516
+ /** Recover the semantic types retained by UDL before checking mixed relations. */
517
+ function publishedFieldType(schema, catalog) {
518
+ if (schema.type === "integer")
519
+ return { kind: "integer" };
520
+ if (schema.type === "boolean")
521
+ return { kind: "boolean" };
522
+ if (schema.type !== "string")
523
+ return { kind: "unknown" };
524
+ if (schema.pattern === MONEY_PATTERN ||
525
+ schema.pattern === OPTIONAL_MONEY_PATTERN) {
526
+ const currency = schema["x-hyperscale-currency"];
527
+ return typeof currency === "string"
528
+ ? { currency, kind: "money" }
529
+ : { kind: "money" };
530
+ }
531
+ const target = catalog.find((instrument) => schema.pattern ===
532
+ `^${instrument.idPrefix}_(sandbox|live)_[a-z0-9]{8,64}$`);
533
+ return target ? { kind: "ref", target: target.id } : { kind: "text" };
534
+ }
425
535
  function typedPublishedSubject(subject, origin) {
426
536
  return {
427
537
  declaredValue: subject.declaredValue,
@@ -441,6 +551,7 @@ const applicationMetadataKeys = new Set([
441
551
  "action",
442
552
  "agent_description",
443
553
  "description",
554
+ "journeys",
444
555
  "nav",
445
556
  "navigation",
446
557
  "summary",
@@ -452,6 +563,7 @@ const applicationMetadataKeys = new Set([
452
563
  const applicationActionMetadataKeys = new Set([
453
564
  "agent_description",
454
565
  "description",
566
+ "examples",
455
567
  "public",
456
568
  "public_action",
457
569
  "summary",
@@ -532,6 +644,71 @@ function mergeApplicationMetadata(body, metadata, diagnostics) {
532
644
  }
533
645
  return { ...body, entries };
534
646
  }
647
+ function bindActionPorts(candidates, bindings, reached) {
648
+ const declarations = new Map([...bindings.values()].map((port) => [port.name.name, port]));
649
+ return candidates.map((candidate) => {
650
+ const ports = new Map();
651
+ const entries = candidate.body.entries.map((row) => {
652
+ if (row.key.name !== "action" || row.value.kind !== "block")
653
+ return row;
654
+ const action = row.qualifiers[0]?.name;
655
+ if (!action)
656
+ return row;
657
+ const declared = (row.conditionPorts ?? []).flatMap((name) => {
658
+ const port = declarations.get(name);
659
+ if (!port)
660
+ return [];
661
+ reached.add(port);
662
+ return [port];
663
+ });
664
+ if (!declared.length)
665
+ return row;
666
+ ports.set(action, declared);
667
+ if (entry(row.value, "port"))
668
+ return row;
669
+ const source = declared[0];
670
+ const allowed = entry(source.body, "allowed")?.value;
671
+ const span = source.span;
672
+ return {
673
+ ...row,
674
+ value: {
675
+ ...row.value,
676
+ entries: [
677
+ ...row.value.entries,
678
+ {
679
+ key: { kind: "ident", name: "port", span },
680
+ qualifiers: [],
681
+ span,
682
+ value: {
683
+ kind: "block",
684
+ span,
685
+ entries: [
686
+ {
687
+ key: {
688
+ kind: "ident",
689
+ name: "allowed_parties",
690
+ span,
691
+ },
692
+ qualifiers: [],
693
+ span,
694
+ value: allowed?.kind === "list"
695
+ ? allowed
696
+ : {
697
+ kind: "list",
698
+ span,
699
+ items: allowed ? [allowed] : [],
700
+ },
701
+ },
702
+ ],
703
+ },
704
+ },
705
+ ],
706
+ },
707
+ };
708
+ });
709
+ return { ...candidate, body: { ...candidate.body, entries }, ports };
710
+ });
711
+ }
535
712
  function constructedInstruments(rootName, body, diagnostics) {
536
713
  const expanded = expandComprehensions(body, diagnostics);
537
714
  const construction = entry(expanded, "instruments");
@@ -700,7 +877,9 @@ function validateMoneyLiteral(decl, diagnostics) {
700
877
  span: decl.value.span,
701
878
  });
702
879
  }
703
- function instantiate(template, application, diagnostics, instrumentName, ports, parties, aliases, constants) {
880
+ function instantiate(template, application, diagnostics, instrumentName, ports, parties, aliases, constants, boundPorts) {
881
+ const loweredPortShapes = new Map();
882
+ const portDependencies = new Map();
704
883
  const partyKeyedParameters = partyKeyedBlockParameters(template, aliases);
705
884
  const named = new Map();
706
885
  const positional = [];
@@ -768,9 +947,11 @@ function instantiate(template, application, diagnostics, instrumentName, ports,
768
947
  });
769
948
  for (const [name, value] of named)
770
949
  values.set(name, value);
950
+ const resolvedArgs = [];
771
951
  let position = 0;
772
952
  for (const parameter of template.parameters) {
773
953
  const value = named.get(parameter.name.name) ?? positional[position++];
954
+ resolvedArgs.push({ parameter, value });
774
955
  if (!value) {
775
956
  if (optionalInner(parameter.type)) {
776
957
  values.set(parameter.name.name, booleanExpr(false, parameter.span));
@@ -845,11 +1026,17 @@ function instantiate(template, application, diagnostics, instrumentName, ports,
845
1026
  else {
846
1027
  values.set(parameter.name.name, value);
847
1028
  }
848
- if (value.kind === "port_ref") {
849
- bindPortCompileValues(values, parameter.name.name, value, ports, diagnostics);
1029
+ }
1030
+ for (const { parameter, value } of resolvedArgs) {
1031
+ if (value?.kind === "port_ref") {
1032
+ const port = ports.get(value.name.name);
1033
+ if (port)
1034
+ boundPorts.set(parameter.name.name, port);
1035
+ bindPortCompileValues(values, parameter.name.name, value, ports, diagnostics, aliases, loweredPortShapes, portDependencies);
850
1036
  }
851
1037
  }
852
- const selected = selectCompileTimeRows(template.body, values, diagnostics);
1038
+ checkClaimAmountPortField(resolvedArgs, ports, aliases, diagnostics);
1039
+ const selected = selectCompileTimeRows(template.body, values, diagnostics, portDependencies);
853
1040
  const dependencyDiagnosticStart = diagnostics.length;
854
1041
  for (const [name, parameter] of missingOptional) {
855
1042
  if (!referencesIdentifier(selected, name, values))
@@ -867,7 +1054,7 @@ function instantiate(template, application, diagnostics, instrumentName, ports,
867
1054
  }
868
1055
  if (diagnostics.length > dependencyDiagnosticStart)
869
1056
  return undefined;
870
- const body = substituteExpr(selected, values);
1057
+ const body = expandComprehensions(selected, diagnostics, values, portDependencies);
871
1058
  return {
872
1059
  ...body,
873
1060
  entries: body.entries.map((row) => {
@@ -1039,7 +1226,231 @@ function partyKeyedBlockParameters(template, aliases) {
1039
1226
  nonPartyKeys.get(parameter) ?? new Set(),
1040
1227
  ]));
1041
1228
  }
1042
- function bindPortCompileValues(values, binding, value, ports, diagnostics) {
1229
+ function shapeTypeText(expr) {
1230
+ if (expr.kind === "ident")
1231
+ return expr.name;
1232
+ if (expr.kind === "block")
1233
+ return "block";
1234
+ if (expr.kind === "list")
1235
+ return "list";
1236
+ if (expr.kind === "number")
1237
+ return expr.raw;
1238
+ if (expr.kind === "string")
1239
+ return `"${expr.value}"`;
1240
+ if (expr.kind === "boolean")
1241
+ return String(expr.value);
1242
+ if (expr.kind === "call") {
1243
+ const args = expr.args.map(shapeTypeText).join(", ");
1244
+ return `${expr.callee.name}(${args})`;
1245
+ }
1246
+ if (expr.kind === "type_apply") {
1247
+ const args = expr.args.map(shapeTypeText).join(", ");
1248
+ return `${expr.callee.name}<${args}>`;
1249
+ }
1250
+ return nameText(expr);
1251
+ }
1252
+ function lowerPortShapeField(port, row, aliases, diagnostics) {
1253
+ const type = typeOf(row.value, aliases);
1254
+ if (![
1255
+ "text",
1256
+ "money",
1257
+ "date",
1258
+ "integer",
1259
+ "boolean",
1260
+ "account",
1261
+ "ref",
1262
+ "bps",
1263
+ "percent",
1264
+ ].includes(type.kind) ||
1265
+ ((type.kind === "money" || type.kind === "account") &&
1266
+ (!type.currency || !CURRENCY.test(type.currency)))) {
1267
+ diagnostics.push({
1268
+ code: "HSX1025",
1269
+ fix: `declare ${row.key.name} as text, money(CUR), date, integer, boolean, account(CUR), ref<instrument_id>, id(instrument_id), bps, or percent`,
1270
+ message: `decision port ${port.name.name} shape field ${row.key.name} has unknown type ${shapeTypeText(row.value)}`,
1271
+ severity: "error",
1272
+ span: row.value.span,
1273
+ });
1274
+ return undefined;
1275
+ }
1276
+ const extras = {};
1277
+ if (type.kind === "text") {
1278
+ extras.description =
1279
+ "Required decision reference retained in the operation receipt";
1280
+ extras.max_length = 180;
1281
+ extras.min_length = 1;
1282
+ }
1283
+ else if (type.kind === "money") {
1284
+ extras.description = `Decided amount in ${type.currency} minor units`;
1285
+ }
1286
+ else if (type.kind === "ref") {
1287
+ extras.max_length = 180;
1288
+ extras.min_length = 1;
1289
+ if (type.target)
1290
+ extras.pattern = `^${prefixFor(type.target)}_(sandbox|live)_[a-z0-9]{8,64}$`;
1291
+ }
1292
+ else if (type.kind === "account") {
1293
+ extras.pattern = ACCOUNT_PATTERN;
1294
+ }
1295
+ const scalarEntries = (values) => Object.entries(values).map(([name, value]) => ({
1296
+ key: { kind: "ident", name, span: row.span },
1297
+ qualifiers: [],
1298
+ span: row.span,
1299
+ value: typeof value === "number"
1300
+ ? { kind: "number", raw: String(value), span: row.span }
1301
+ : { kind: "string", value: String(value), span: row.span },
1302
+ }));
1303
+ // Port inputs require every key, but a caller-decided amount may be zero.
1304
+ const field = lowerField({
1305
+ ...row,
1306
+ value: {
1307
+ kind: "block",
1308
+ span: row.span,
1309
+ entries: [
1310
+ {
1311
+ key: { kind: "ident", name: "type", span: row.span },
1312
+ qualifiers: [],
1313
+ span: row.span,
1314
+ value: row.value,
1315
+ },
1316
+ {
1317
+ key: { kind: "ident", name: "optional", span: row.span },
1318
+ qualifiers: [],
1319
+ span: row.span,
1320
+ value: { kind: "boolean", value: true, span: row.span },
1321
+ },
1322
+ ...scalarEntries(extras),
1323
+ ],
1324
+ },
1325
+ }, aliases, diagnostics);
1326
+ const schema = type.kind === "text"
1327
+ ? {
1328
+ type: field.schema.type,
1329
+ description: field.schema.description,
1330
+ ...field.schema,
1331
+ }
1332
+ : field.schema;
1333
+ return {
1334
+ key: row.key,
1335
+ qualifiers: [],
1336
+ span: row.span,
1337
+ value: {
1338
+ kind: "block",
1339
+ span: row.span,
1340
+ entries: scalarEntries(schema),
1341
+ },
1342
+ };
1343
+ }
1344
+ function lowerPortShape(port, rawShape, aliases, diagnostics) {
1345
+ if (rawShape.kind !== "block") {
1346
+ diagnostics.push({
1347
+ code: "HSX1025",
1348
+ fix: "declare shape as a block of fields, like shape { note: text; }",
1349
+ message: `decision port ${port.name.name} shape must be a block`,
1350
+ severity: "error",
1351
+ span: rawShape.span,
1352
+ });
1353
+ return { entries: [], kind: "block", span: rawShape.span };
1354
+ }
1355
+ const entries = [];
1356
+ const names = new Set();
1357
+ for (const row of rawShape.entries) {
1358
+ if (names.has(row.key.name)) {
1359
+ diagnostics.push({
1360
+ code: "HSX1004",
1361
+ fix: `keep one ${row.key.name} field in decision port ${port.name.name}`,
1362
+ message: `decision port ${port.name.name} shape field ${row.key.name} is declared twice`,
1363
+ severity: "error",
1364
+ span: row.key.span,
1365
+ });
1366
+ continue;
1367
+ }
1368
+ names.add(row.key.name);
1369
+ const lowered = lowerPortShapeField(port, row, aliases, diagnostics);
1370
+ if (lowered)
1371
+ entries.push(lowered);
1372
+ }
1373
+ return {
1374
+ entries,
1375
+ kind: "block",
1376
+ span: rawShape.span,
1377
+ };
1378
+ }
1379
+ function checkClaimAmountPortField(resolvedArgs, ports, aliases, diagnostics) {
1380
+ for (const { value } of resolvedArgs) {
1381
+ if (value?.kind !== "block")
1382
+ continue;
1383
+ const fieldEntry = entry(value, "field");
1384
+ const boundEntry = entry(value, "bound");
1385
+ if (!fieldEntry || !boundEntry)
1386
+ continue;
1387
+ const claimFieldName = fieldEntry.value.kind === "ident"
1388
+ ? fieldEntry.value.name
1389
+ : fieldEntry.value.kind === "string"
1390
+ ? fieldEntry.value.value
1391
+ : undefined;
1392
+ const boundFieldName = boundEntry.value.kind === "ident"
1393
+ ? boundEntry.value.name
1394
+ : boundEntry.value.kind === "string"
1395
+ ? boundEntry.value.value
1396
+ : undefined;
1397
+ if (!claimFieldName || !boundFieldName)
1398
+ continue;
1399
+ const boundArg = resolvedArgs.find((arg) => (arg.value?.kind === "binding" &&
1400
+ arg.value.name.name === boundFieldName) ||
1401
+ arg.parameter.name.name === boundFieldName);
1402
+ const boundTypeExpr = boundArg?.value?.kind === "binding"
1403
+ ? boundArg.value.type
1404
+ : boundArg?.value;
1405
+ if (!boundTypeExpr)
1406
+ continue;
1407
+ const boundType = typeOf(boundTypeExpr, aliases);
1408
+ if (boundType.kind !== "money")
1409
+ continue;
1410
+ const portArg = resolvedArgs.find((arg) => arg.parameter.name.name === "claim");
1411
+ if (portArg?.value?.kind !== "port_ref")
1412
+ continue;
1413
+ const portDecl = ports.get(portArg.value.name.name);
1414
+ if (!portDecl)
1415
+ continue;
1416
+ const shapeExpr = entry(portDecl.body, "shape")?.value;
1417
+ if (shapeExpr?.kind !== "block")
1418
+ continue;
1419
+ const shapeField = entry(shapeExpr, claimFieldName);
1420
+ if (!shapeField) {
1421
+ diagnostics.push({
1422
+ code: "HSX1011",
1423
+ fix: `add ${claimFieldName}: money(${boundType.currency ?? "CUR"}) to the shape of decision port ${portDecl.name.name}`,
1424
+ message: `decision port ${portDecl.name.name} shape lacks claim field ${claimFieldName}`,
1425
+ severity: "error",
1426
+ span: portArg.value.span,
1427
+ });
1428
+ continue;
1429
+ }
1430
+ const actualType = typeOf(shapeField.value, aliases);
1431
+ if (actualType.kind !== "money") {
1432
+ diagnostics.push({
1433
+ code: "HSX1104",
1434
+ fix: `pass money<${boundType.currency ?? "CUR"}> to ${shapeField.key.name}`,
1435
+ message: `${shapeField.key.name} needs money<${boundType.currency ?? "CUR"}> but receives ${argumentWords(shapeField.value, aliases)}`,
1436
+ severity: "error",
1437
+ span: shapeField.value.span,
1438
+ });
1439
+ }
1440
+ else if (actualType.currency &&
1441
+ boundType.currency &&
1442
+ actualType.currency !== boundType.currency) {
1443
+ diagnostics.push({
1444
+ code: "HSX1101",
1445
+ fix: `pass money<${boundType.currency}> to ${shapeField.key.name}`,
1446
+ message: `${shapeField.key.name} needs money<${boundType.currency}> but receives money<${actualType.currency}>`,
1447
+ severity: "error",
1448
+ span: shapeField.value.span,
1449
+ });
1450
+ }
1451
+ }
1452
+ }
1453
+ function bindPortCompileValues(values, binding, value, ports, diagnostics, aliases = new Map(), loweredPortShapes = new Map(), dependencies = new Map()) {
1043
1454
  const port = ports.get(value.name.name);
1044
1455
  if (!port) {
1045
1456
  diagnostics.push({
@@ -1051,14 +1462,36 @@ function bindPortCompileValues(values, binding, value, ports, diagnostics) {
1051
1462
  });
1052
1463
  return;
1053
1464
  }
1054
- const allowed = port ? entry(port.body, "allowed")?.value : undefined;
1055
- const shape = entry(port.body, "shape")?.value ?? {
1056
- entries: [],
1057
- kind: "block",
1058
- span: port.body.span,
1059
- };
1060
- if (allowed)
1061
- values.set(`${binding}_allowed`, allowed);
1465
+ for (const key of [binding, `${binding}_allowed`, `${binding}_fields`]) {
1466
+ dependencies.set(key, [port.name.name]);
1467
+ }
1468
+ const allowed = entry(port.body, "allowed")?.value;
1469
+ let shape = loweredPortShapes.get(value.name.name);
1470
+ if (!shape) {
1471
+ const rawShape = entry(port.body, "shape")?.value ?? {
1472
+ entries: [],
1473
+ kind: "block",
1474
+ span: port.body.span,
1475
+ };
1476
+ const shapeDiagnostics = [];
1477
+ shape = lowerPortShape(port, rawShape, aliases, shapeDiagnostics);
1478
+ for (const diagnostic of shapeDiagnostics) {
1479
+ if (!diagnostics.some((prior) => prior.code === diagnostic.code &&
1480
+ prior.span === diagnostic.span &&
1481
+ prior.message === diagnostic.message))
1482
+ diagnostics.push(diagnostic);
1483
+ }
1484
+ loweredPortShapes.set(value.name.name, shape);
1485
+ }
1486
+ const normalizedAllowed = allowed?.kind === "ident"
1487
+ ? {
1488
+ items: [allowed],
1489
+ kind: "list",
1490
+ span: allowed.span,
1491
+ }
1492
+ : allowed;
1493
+ if (normalizedAllowed)
1494
+ values.set(`${binding}_allowed`, normalizedAllowed);
1062
1495
  values.set(`${binding}_fields`, shape);
1063
1496
  if (value.within)
1064
1497
  values.set(`${binding}_within`, value.within);
@@ -1264,14 +1697,54 @@ function argumentWords(expression, aliases) {
1264
1697
  function booleanExpr(value, span) {
1265
1698
  return { kind: "boolean", span, value };
1266
1699
  }
1267
- function selectCompileTimeRows(block, values, diagnostics) {
1700
+ function selectedControls(value) {
1701
+ if (value.kind !== "block")
1702
+ return [];
1703
+ return value.entries.flatMap((row) => [
1704
+ ...(row.conditionPorts ?? []),
1705
+ ...selectedControls(row.value),
1706
+ ]);
1707
+ }
1708
+ // Retain dependencies before compile-time evaluation erases identifiers.
1709
+ function expressionPorts(value, dependencies) {
1710
+ if (!value || typeof value !== "object")
1711
+ return [];
1712
+ if (Array.isArray(value))
1713
+ return [
1714
+ ...new Set(value.flatMap((item) => expressionPorts(item, dependencies))),
1715
+ ];
1716
+ const node = value;
1717
+ const found = new Set(node.conditionPorts ?? []);
1718
+ if (node.kind === "ident" && typeof node.name === "string") {
1719
+ for (const port of dependencies.get(node.name.replace(/^\[|\]$/g, "")) ??
1720
+ [])
1721
+ found.add(port);
1722
+ }
1723
+ if (node.kind === "string" && typeof node.value === "string") {
1724
+ for (const match of node.value.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)) {
1725
+ for (const port of dependencies.get(match[1]) ?? [])
1726
+ found.add(port);
1727
+ }
1728
+ }
1729
+ for (const [key, child] of Object.entries(node)) {
1730
+ if (key === "span" || key === "conditionPorts")
1731
+ continue;
1732
+ for (const port of expressionPorts(child, dependencies))
1733
+ found.add(port);
1734
+ }
1735
+ return [...found];
1736
+ }
1737
+ function selectCompileTimeRows(block, values, diagnostics, dependencies = new Map(), controls = []) {
1268
1738
  const entries = [];
1269
1739
  const locals = new Map(values);
1740
+ const localDependencies = new Map(dependencies);
1270
1741
  for (const row of block.entries) {
1271
1742
  if (row.key.name === "let") {
1272
1743
  const binding = row.qualifiers[0];
1273
- if (binding)
1744
+ if (binding) {
1745
+ localDependencies.set(binding.name, expressionPorts(row.value, localDependencies));
1274
1746
  locals.set(binding.name, substituteExpr(row.value, locals));
1747
+ }
1275
1748
  entries.push(row);
1276
1749
  continue;
1277
1750
  }
@@ -1305,12 +1778,34 @@ function selectCompileTimeRows(block, values, diagnostics) {
1305
1778
  if (row.key.name !== "when" &&
1306
1779
  row.key.name !== "when_not" &&
1307
1780
  row.key.name !== "when_eq") {
1308
- entries.push({
1309
- ...row,
1310
- value: row.value.kind === "block" && !row.iteration
1311
- ? selectCompileTimeRows(row.value, locals, diagnostics)
1312
- : row.value,
1313
- });
1781
+ const value = row.value.kind === "block" && !row.iteration
1782
+ ? selectCompileTimeRows(row.value, locals, diagnostics, localDependencies, controls)
1783
+ : row.value;
1784
+ const conditionPorts = row.key.name === "action"
1785
+ ? [
1786
+ ...new Set([
1787
+ ...(value.kind === "block" && entry(value, "due")
1788
+ ? []
1789
+ : controls),
1790
+ ...row.qualifiers
1791
+ .filter((qualifier) => qualifier.name.startsWith("["))
1792
+ .flatMap((qualifier) => expressionPorts(qualifier, localDependencies)),
1793
+ ...expressionPorts(value.kind === "block"
1794
+ ? entry(value, "port")?.value
1795
+ : undefined, localDependencies),
1796
+ ...expressionPorts(value.kind === "block"
1797
+ ? entry(value, "input")?.value
1798
+ : undefined, localDependencies),
1799
+ ...expressionPorts(value.kind === "block"
1800
+ ? entry(value, "capture_input", "captureInput")?.value
1801
+ : undefined, localDependencies),
1802
+ ...(value.kind === "block" && entry(value, "due")
1803
+ ? []
1804
+ : selectedControls(value)),
1805
+ ]),
1806
+ ]
1807
+ : controls;
1808
+ entries.push({ ...row, conditionPorts, value });
1314
1809
  continue;
1315
1810
  }
1316
1811
  const subject = row.qualifiers[0];
@@ -1336,7 +1831,12 @@ function selectCompileTimeRows(block, values, diagnostics) {
1336
1831
  continue;
1337
1832
  }
1338
1833
  if (include) {
1339
- entries.push(...selectCompileTimeRows(row.value, locals, diagnostics).entries);
1834
+ entries.push(...selectCompileTimeRows(row.value, locals, diagnostics, localDependencies, [
1835
+ ...new Set([
1836
+ ...controls,
1837
+ ...(subject ? expressionPorts(subject, localDependencies) : []),
1838
+ ]),
1839
+ ]).entries);
1340
1840
  }
1341
1841
  }
1342
1842
  return { ...block, entries };
@@ -1589,11 +2089,12 @@ function nameText(value) {
1589
2089
  return `${value.owner.name}.${value.member.name}`;
1590
2090
  return "invalid_compile_time_name";
1591
2091
  }
1592
- function expandComprehensions(block, diagnostics) {
2092
+ function expandComprehensions(block, diagnostics, values = new Map(), dependencies = new Map()) {
1593
2093
  const budget = { used: 0 };
1594
- const expand = (current, inherited = new Map()) => {
2094
+ const expand = (current, inherited = new Map(), inheritedDependencies = dependencies) => {
1595
2095
  const entries = [];
1596
2096
  const locals = new Map(inherited);
2097
+ const localDependencies = new Map(inheritedDependencies);
1597
2098
  for (const row of current.entries) {
1598
2099
  if (row.key.name === "let") {
1599
2100
  const binding = row.qualifiers[0];
@@ -1607,6 +2108,7 @@ function expandComprehensions(block, diagnostics) {
1607
2108
  });
1608
2109
  continue;
1609
2110
  }
2111
+ localDependencies.set(binding.name, expressionPorts(row.value, localDependencies));
1610
2112
  locals.set(binding.name, substituteExpr(row.value, locals));
1611
2113
  continue;
1612
2114
  }
@@ -1622,7 +2124,7 @@ function expandComprehensions(block, diagnostics) {
1622
2124
  if (row.key.name === "when_not")
1623
2125
  include = !include;
1624
2126
  if (include && row.value.kind === "block") {
1625
- entries.push(...expand(row.value, locals).entries);
2127
+ entries.push(...expand(row.value, locals, localDependencies).entries);
1626
2128
  }
1627
2129
  continue;
1628
2130
  }
@@ -1641,8 +2143,8 @@ function expandComprehensions(block, diagnostics) {
1641
2143
  };
1642
2144
  entries.push({
1643
2145
  ...substituted,
1644
- value: substituted.value.kind === "block"
1645
- ? expand(substituted.value, locals)
2146
+ value: row.value.kind === "block"
2147
+ ? expand(row.value, locals, localDependencies)
1646
2148
  : substituted.value,
1647
2149
  });
1648
2150
  continue;
@@ -1677,14 +2179,13 @@ function expandComprehensions(block, diagnostics) {
1677
2179
  ...locals,
1678
2180
  [row.iteration.binding.name, value],
1679
2181
  ]);
1680
- const body = substituteExpr(row.value, substitutions);
1681
- if (body.kind === "block")
1682
- entries.push(...expand(body, substitutions).entries);
2182
+ const body = selectCompileTimeRows(row.value, substitutions, diagnostics, localDependencies, row.conditionPorts);
2183
+ entries.push(...expand(body, substitutions, localDependencies).entries);
1683
2184
  }
1684
2185
  }
1685
2186
  return { ...current, entries };
1686
2187
  };
1687
- return expand(block);
2188
+ return expand(block, values);
1688
2189
  }
1689
2190
  function finiteIterationValues(bound) {
1690
2191
  if (bound.kind === "call") {
@@ -1704,7 +2205,7 @@ function finiteIterationValues(bound) {
1704
2205
  span: bound.span,
1705
2206
  }));
1706
2207
  }
1707
- function checkInstrument(name, body, parties, aliases, diagnostics) {
2208
+ function checkInstrument(name, body, parties, ports, aliases, diagnostics) {
1708
2209
  if (!SNAKE_CASE.test(name.name)) {
1709
2210
  diagnostics.push({
1710
2211
  code: "HSX1003",
@@ -1752,8 +2253,57 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1752
2253
  }
1753
2254
  const slots = {};
1754
2255
  const partiesEntry = entry(body, "parties");
1755
- if (partiesEntry?.value.kind === "block") {
1756
- const roleObject = blockToObject(partiesEntry.value, new Map(), diagnostics);
2256
+ const roleObject = blockToObject(partiesEntry?.value.kind === "block" ? partiesEntry.value : undefined, new Map(), diagnostics);
2257
+ const partyAccounts = new Map([...parties].map((party) => [party, `${camel(party)}AccountId`]));
2258
+ for (const [role, binding] of Object.entries(roleObject)) {
2259
+ if (!isJsonObject(binding))
2260
+ continue;
2261
+ if (typeof binding.party !== "string" ||
2262
+ !parties.has(binding.party) ||
2263
+ typeof binding.account !== "string" ||
2264
+ !endpointFields.has(binding.account)) {
2265
+ diagnostics.push({
2266
+ code: "HSX1024",
2267
+ fix: "bind a declared party to a declared account field",
2268
+ message: `party role ${role} has an invalid party/account binding`,
2269
+ severity: "error",
2270
+ span: partiesEntry?.span ?? name.span,
2271
+ });
2272
+ continue;
2273
+ }
2274
+ partyAccounts.set(binding.party, binding.account);
2275
+ roleObject[role] = binding.account;
2276
+ }
2277
+ const decisionRoles = new Map();
2278
+ const inferredDecisionFields = new Set();
2279
+ // Decision authority needs an identity binding, even when no money moves
2280
+ // through that party. Reuse the same account binding as economic roles.
2281
+ for (const action of body.entries) {
2282
+ if (action.key.name !== "action" || action.value.kind !== "block")
2283
+ continue;
2284
+ const port = entry(action.value, "port")?.value;
2285
+ if (port?.kind !== "block")
2286
+ continue;
2287
+ const allowed = entry(port, "allowed_parties", "allowedParties")?.value;
2288
+ if (allowed?.kind !== "list")
2289
+ continue;
2290
+ for (const item of allowed.items) {
2291
+ const party = nameText(item);
2292
+ if (!parties.has(party))
2293
+ continue;
2294
+ const accountField = partyAccounts.get(party);
2295
+ const existing = Object.entries(roleObject).find(([, value]) => value === party || value === accountField);
2296
+ let role = existing?.[0] ?? party;
2297
+ for (let suffix = 2; !existing && Object.hasOwn(roleObject, role); suffix++)
2298
+ role = `${party}_${suffix}`;
2299
+ if (!existing) {
2300
+ roleObject[role] = party;
2301
+ inferredDecisionFields.add(accountField);
2302
+ }
2303
+ decisionRoles.set(party, role);
2304
+ }
2305
+ }
2306
+ if (Object.keys(roleObject).length > 0) {
1757
2307
  for (const [role, value] of Object.entries(roleObject)) {
1758
2308
  if (typeof value !== "string")
1759
2309
  continue;
@@ -1770,11 +2320,11 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1770
2320
  if (!fieldNames.has(field)) {
1771
2321
  fields.push({
1772
2322
  name: field,
1773
- origin: partiesEntry.span,
2323
+ origin: partiesEntry?.span ?? name.span,
1774
2324
  required: true,
1775
2325
  schema: {
1776
2326
  description: `The ${value.replaceAll("_", " ")} account`,
1777
- pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
2327
+ pattern: ACCOUNT_PATTERN,
1778
2328
  type: "string",
1779
2329
  "x-hyperscale-reference-filter": {
1780
2330
  column: "role",
@@ -1792,8 +2342,11 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1792
2342
  fix: "bind the role to a declared party or field",
1793
2343
  message: `party role refers to ${String(value)}, which is not declared`,
1794
2344
  severity: "error",
1795
- span: partiesEntry.value.entries.find((entry) => entry.key.name === role)
1796
- ?.value.span ?? partiesEntry.span,
2345
+ span: (partiesEntry?.value.kind === "block"
2346
+ ? partiesEntry.value.entries
2347
+ : []).find((entry) => entry.key.name === role)?.value.span ??
2348
+ partiesEntry?.span ??
2349
+ name.span,
1797
2350
  });
1798
2351
  }
1799
2352
  slots.parties = roleObject;
@@ -1831,7 +2384,7 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1831
2384
  origin: actionEntry.span,
1832
2385
  required: true,
1833
2386
  schema: {
1834
- pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
2387
+ pattern: ACCOUNT_PATTERN,
1835
2388
  type: "string",
1836
2389
  },
1837
2390
  type: { kind: "account" },
@@ -1841,14 +2394,98 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1841
2394
  }
1842
2395
  }
1843
2396
  }
1844
- const actions = actionEntries.flatMap((candidate) => {
2397
+ const actions = actionEntries
2398
+ .flatMap((candidate) => {
1845
2399
  const actionName = candidate.qualifiers[0];
1846
2400
  if (!actionName || candidate.value.kind !== "block")
1847
2401
  return [];
1848
2402
  return [
1849
2403
  checkAction(actionName, candidate.value, fields, name.name, endpointFields, holdDestinations, diagnostics),
1850
2404
  ];
1851
- });
2405
+ })
2406
+ .map((action) => {
2407
+ const port = action.slots.port;
2408
+ if (port === undefined ||
2409
+ !isJsonObject(port) ||
2410
+ !Array.isArray(port.allowedParties))
2411
+ return action;
2412
+ return {
2413
+ ...action,
2414
+ slots: {
2415
+ ...action.slots,
2416
+ port: {
2417
+ ...port,
2418
+ allowedParties: port.allowedParties.map((party) => typeof party === "string"
2419
+ ? (decisionRoles.get(party) ?? party)
2420
+ : party),
2421
+ },
2422
+ },
2423
+ };
2424
+ })
2425
+ .map((action) => pinFixedCurrencyBindings(action, fields, diagnostics));
2426
+ for (const action of actions) {
2427
+ for (const declared of ports.get(action.name) ?? []) {
2428
+ const allowed = entry(declared.body, "allowed")?.value;
2429
+ const allowedItems = allowed?.kind === "list" ? allowed.items : allowed ? [allowed] : [];
2430
+ if (allowedItems.length === 0) {
2431
+ diagnostics.push({
2432
+ code: "HSX1024",
2433
+ fix: `declare a nonempty allowed list of declared parties on port ${declared.name.name}`,
2434
+ message: `wired decision port ${declared.name.name} reaches action ${action.name} without declared allowed parties`,
2435
+ severity: "error",
2436
+ span: declared.name.span,
2437
+ });
2438
+ continue;
2439
+ }
2440
+ const port = action.slots.port;
2441
+ if (port === undefined ||
2442
+ !isJsonObject(port) ||
2443
+ !Array.isArray(port.allowedParties)) {
2444
+ diagnostics.push({
2445
+ code: "HSX1024",
2446
+ fix: `emit the allowed parties of ${declared.name.name} in action ${action.name}'s port`,
2447
+ message: `wired decision port ${declared.name.name} reaches action ${action.name} without a decision port`,
2448
+ severity: "error",
2449
+ span: action.origin,
2450
+ });
2451
+ continue;
2452
+ }
2453
+ const allowedFields = new Set();
2454
+ for (const item of allowedItems) {
2455
+ const party = nameText(item);
2456
+ const role = decisionRoles.get(party) ??
2457
+ Object.keys(roleObject).find((role) => roleObject[role] ===
2458
+ (partyAccounts.get(party) ?? `${camel(party)}AccountId`));
2459
+ const field = role === undefined ? undefined : roleObject[role];
2460
+ if (typeof field === "string") {
2461
+ allowedFields.add(field);
2462
+ continue;
2463
+ }
2464
+ if (!parties.has(party))
2465
+ continue;
2466
+ diagnostics.push({
2467
+ code: "HSX1024",
2468
+ fix: `bind ${party} to an account field and use that binding in action ${action.name}'s port`,
2469
+ message: `decision port ${action.name} allows party ${party}, but settlement ${name.name} does not bind that party to the decision action`,
2470
+ severity: "error",
2471
+ span: item.span,
2472
+ });
2473
+ }
2474
+ if (port.allowedParties.length === 0 ||
2475
+ allowedFields.size !==
2476
+ new Set(port.allowedParties.map((role) => String(roleObject[String(role)]))).size ||
2477
+ port.allowedParties.some((role) => typeof role !== "string" ||
2478
+ !allowedFields.has(String(roleObject[role])))) {
2479
+ diagnostics.push({
2480
+ code: "HSX1024",
2481
+ fix: `use only the account bindings allowed by ${declared.name.name}`,
2482
+ message: `action ${action.name} does not preserve the allowed parties of decision port ${declared.name.name}`,
2483
+ severity: "error",
2484
+ span: action.origin,
2485
+ });
2486
+ }
2487
+ }
2488
+ }
1852
2489
  if (!actionNames.has("create")) {
1853
2490
  diagnostics.push({
1854
2491
  code: "HSX1504",
@@ -1911,8 +2548,43 @@ function checkInstrument(name, body, parties, aliases, diagnostics) {
1911
2548
  slots.summary ??= `${titleize(name.name)} instrument`;
1912
2549
  slots.title ??= titleize(name.name);
1913
2550
  linearityDiagnostics(actionEntries, diagnostics);
2551
+ agentDescriptionDiagnostics(name, slots, actions, diagnostics);
2552
+ if (Array.isArray(slots.required)) {
2553
+ slots.required = [
2554
+ ...new Set([...slots.required, ...inferredDecisionFields]),
2555
+ ];
2556
+ }
1914
2557
  return { actions, fields, id: name.name, origin: name.span, slots };
1915
2558
  }
2559
+ // Mirrors the catalog law agent_description_required: every action a caller
2560
+ // can reach becomes an agent tool whose description is authored, never
2561
+ // synthesized from the human-facing summary. System actions with a due clause
2562
+ // are exempt because no caller can reach them.
2563
+ function agentDescriptionDiagnostics(name, slots, actions, diagnostics) {
2564
+ const callable = actions.filter((action) => action.slots.due === undefined);
2565
+ if (callable.length === 0)
2566
+ return;
2567
+ if (slots.agentDescription === undefined) {
2568
+ diagnostics.push({
2569
+ code: "HSX1509",
2570
+ fix: 'add agent_description: "..." telling an agent when to reach for this instrument',
2571
+ message: `instrument ${name.name} exposes callable actions without an agent_description`,
2572
+ severity: "error",
2573
+ span: name.span,
2574
+ });
2575
+ }
2576
+ for (const action of callable) {
2577
+ if (action.slots.agentDescription !== undefined)
2578
+ continue;
2579
+ diagnostics.push({
2580
+ code: "HSX1509",
2581
+ fix: 'add agent_description: "..." telling an agent when to call this action',
2582
+ message: `action ${action.name} of ${name.name} is callable without an agent_description`,
2583
+ severity: "error",
2584
+ span: action.origin,
2585
+ });
2586
+ }
2587
+ }
1916
2588
  function checkRequiredFields(value, fields, span, diagnostics) {
1917
2589
  const report = (message, fix) => {
1918
2590
  diagnostics.push({
@@ -1980,62 +2652,76 @@ function checkFields(block, aliases, diagnostics) {
1980
2652
  continue;
1981
2653
  }
1982
2654
  names.add(name);
1983
- let typeExpr = row.value;
1984
- let description;
1985
- let required = true;
1986
- let extra = {};
1987
- if (row.value.kind === "block") {
1988
- const typeRow = entry(row.value, "type");
1989
- if (typeRow)
1990
- typeExpr = typeRow.value;
1991
- const desc = entry(row.value, "description") ?? entry(row.value, "desc");
1992
- if (desc?.value.kind === "string")
1993
- description = desc.value.value;
1994
- const optional = entry(row.value, "optional");
1995
- if (optional?.value.kind === "boolean")
1996
- required = !optional.value.value;
1997
- extra = blockToObject({
1998
- ...row.value,
1999
- entries: row.value.entries.filter((item) => !["type", "description", "desc", "optional"].includes(item.key.name)),
2000
- }, new Map(), diagnostics);
2001
- }
2002
- const type = typeOf(typeExpr, aliases);
2003
- const fixedMoneyIssue = invalidFixedMoneyBinding(typeExpr);
2004
- if (fixedMoneyIssue) {
2005
- diagnostics.push({
2006
- code: "HSX1103",
2007
- fix: `write ${name} as money(SAR, 2500) with a positive safe integer in minor units`,
2008
- message: `field ${name} ${fixedMoneyIssue}`,
2009
- severity: "error",
2010
- span: typeExpr.span,
2011
- });
2012
- }
2013
- if ((typeExpr.kind === "ident" && typeExpr.name === "ref") ||
2014
- (type.kind === "ref" && !type.target)) {
2015
- diagnostics.push({
2016
- code: "HSX1007",
2017
- fix: "declare the target instrument as ref<instrument_id>",
2018
- message: `field ${name} declares ref without an instrument target`,
2019
- severity: "error",
2020
- span: typeExpr.span,
2021
- });
2022
- }
2023
- const structuralType = Object.hasOwn(extra, "items")
2024
- ? "array"
2025
- : Object.hasOwn(extra, "properties") ||
2026
- Object.hasOwn(extra, "additionalProperties")
2027
- ? "object"
2028
- : undefined;
2029
- const schema = {
2030
- ...schemaFor(type, required),
2031
- ...extra,
2032
- ...(structuralType ? { type: structuralType } : {}),
2033
- ...(description ? { description } : {}),
2034
- };
2035
- result.push({ name, origin: row.span, required, schema, type });
2655
+ result.push(lowerField(row, aliases, diagnostics));
2036
2656
  }
2037
2657
  return result;
2038
2658
  }
2659
+ function lowerField(row, aliases, diagnostics) {
2660
+ const name = camel(row.key.name);
2661
+ let typeExpr = row.value;
2662
+ let description;
2663
+ let required = true;
2664
+ let extra = {};
2665
+ if (row.value.kind === "block") {
2666
+ const typeRow = entry(row.value, "type");
2667
+ if (typeRow)
2668
+ typeExpr = typeRow.value;
2669
+ const desc = entry(row.value, "description") ?? entry(row.value, "desc");
2670
+ if (desc?.value.kind === "string")
2671
+ description = desc.value.value;
2672
+ const optional = entry(row.value, "optional");
2673
+ if (optional?.value.kind === "boolean")
2674
+ required = !optional.value.value;
2675
+ extra = blockToObject({
2676
+ ...row.value,
2677
+ entries: row.value.entries.filter((item) => !["type", "description", "desc", "optional"].includes(item.key.name)),
2678
+ }, new Map(), diagnostics);
2679
+ }
2680
+ const type = typeOf(typeExpr, aliases);
2681
+ // Diagnostics point at the field's own type expression, not at an alias
2682
+ // declaration that may live in an imported module.
2683
+ const declaredSpan = typeExpr.span;
2684
+ const seen = new Set();
2685
+ while (typeExpr.kind === "ident" &&
2686
+ aliases.has(typeExpr.name) &&
2687
+ !seen.has(typeExpr.name)) {
2688
+ seen.add(typeExpr.name);
2689
+ typeExpr = aliases.get(typeExpr.name);
2690
+ }
2691
+ const fixedMoneyIssue = invalidFixedMoneyBinding(typeExpr);
2692
+ if (fixedMoneyIssue) {
2693
+ diagnostics.push({
2694
+ code: "HSX1103",
2695
+ fix: `write ${name} as money(SAR, 2500) with a positive safe integer in minor units`,
2696
+ message: `field ${name} ${fixedMoneyIssue}`,
2697
+ severity: "error",
2698
+ span: declaredSpan,
2699
+ });
2700
+ }
2701
+ if ((typeExpr.kind === "ident" && typeExpr.name === "ref") ||
2702
+ (type.kind === "ref" && !type.target)) {
2703
+ diagnostics.push({
2704
+ code: "HSX1007",
2705
+ fix: "declare the target instrument as ref<instrument_id>",
2706
+ message: `field ${name} declares ref without an instrument target`,
2707
+ severity: "error",
2708
+ span: declaredSpan,
2709
+ });
2710
+ }
2711
+ const structuralType = Object.hasOwn(extra, "items")
2712
+ ? "array"
2713
+ : Object.hasOwn(extra, "properties") ||
2714
+ Object.hasOwn(extra, "additionalProperties")
2715
+ ? "object"
2716
+ : undefined;
2717
+ const schema = {
2718
+ ...schemaFor(type, required),
2719
+ ...extra,
2720
+ ...(structuralType ? { type: structuralType } : {}),
2721
+ ...(description ? { description } : {}),
2722
+ };
2723
+ return { name, origin: row.span, required, schema, type };
2724
+ }
2039
2725
  function checkLifecycle(instrument, block, actions, diagnostics) {
2040
2726
  const statesRows = block.entries.filter((row) => row.key.name === "states");
2041
2727
  const initialRow = entry(block, "initial");
@@ -2139,6 +2825,18 @@ function checkAction(name, body, fields, instrumentId, endpointFields, holdDesti
2139
2825
  continue;
2140
2826
  }
2141
2827
  if (definition?.scope === "action") {
2828
+ if (definition.target === "port" && row.value.kind === "block") {
2829
+ const allowedEntry = entry(row.value, "allowed");
2830
+ if (allowedEntry) {
2831
+ diagnostics.push({
2832
+ code: "HSX1508",
2833
+ fix: "Use allowed_parties: [...] inside an action-level port clause; allowed: is for top-level port declarations.",
2834
+ message: "action-level port clause uses allowed: instead of allowed_parties:",
2835
+ severity: "error",
2836
+ span: allowedEntry.key.span,
2837
+ });
2838
+ }
2839
+ }
2142
2840
  const rawValue = exprToJsonForUdlSlot(row.value, definition.target, diagnostics);
2143
2841
  const moveOffset = Array.isArray(slots.moves) ? slots.moves.length : 0;
2144
2842
  const value = definition.target === "moves"
@@ -2302,6 +3000,93 @@ function friendlyMoveRows(body) {
2302
3000
  function isJsonObject(value) {
2303
3001
  return value !== null && typeof value === "object" && !Array.isArray(value);
2304
3002
  }
3003
+ /**
3004
+ * A money<SAR> field moves SAR, whatever the caller wrote in the instance
3005
+ * currency field. Every move of a fixed-currency amount, and every account
3006
+ * step of an instrument whose money is fixed, binds that currency as a
3007
+ * constant: a binding that reads one of the instrument's own text fields is
3008
+ * pinned here, and one the compiler cannot pin (an input, a ref, or a
3009
+ * constant naming another currency) is refused as HSX1306.
3010
+ */
3011
+ function pinFixedCurrencyBindings(action, fields, diagnostics) {
3012
+ const fieldByName = new Map(fields.map((field) => [field.name, field]));
3013
+ const fixedCurrencyOf = (field) => field?.type.kind === "money" &&
3014
+ field.type.currency &&
3015
+ CURRENCY.test(field.type.currency)
3016
+ ? field.type.currency
3017
+ : undefined;
3018
+ const fixed = new Set(fields.flatMap((field) => fixedCurrencyOf(field) ?? []));
3019
+ // Two fixed currencies is HSX1305's refusal; one decides here, none skips.
3020
+ const ledger = fixed.size === 1 ? [...fixed][0] : undefined;
3021
+ if (!ledger)
3022
+ return action;
3023
+ const instanceField = (binding) => binding !== undefined &&
3024
+ isJsonObject(binding) &&
3025
+ binding.from === "instance" &&
3026
+ typeof binding.path === "string" &&
3027
+ binding.path.startsWith("fields.")
3028
+ ? fieldByName.get(binding.path.slice("fields.".length))
3029
+ : undefined;
3030
+ const pin = (entry, currency, subject) => {
3031
+ if (!isJsonObject(entry))
3032
+ return entry;
3033
+ const bind = entry.bind;
3034
+ if (bind === undefined || !isJsonObject(bind))
3035
+ return entry;
3036
+ const binding = bind.currency;
3037
+ if (binding === undefined)
3038
+ return entry;
3039
+ const bound = isJsonObject(binding) && binding.from === "const"
3040
+ ? String(binding.value)
3041
+ : undefined;
3042
+ if (bound === currency)
3043
+ return entry;
3044
+ if (bound === undefined && instanceField(binding)?.type.kind === "text") {
3045
+ return {
3046
+ ...entry,
3047
+ bind: { ...bind, currency: { from: "const", value: currency } },
3048
+ };
3049
+ }
3050
+ diagnostics.push({
3051
+ code: "HSX1306",
3052
+ fix: `bind currency as { from: const; value: ${currency}; }`,
3053
+ message: `action ${action.name} binds the currency of ${subject} to ${bound ?? "a caller-supplied value"}; a money<${currency}> amount moves in ${currency}`,
3054
+ severity: "error",
3055
+ span: action.origin,
3056
+ });
3057
+ return entry;
3058
+ };
3059
+ const moves = action.slots.moves ?? [];
3060
+ const steps = action.slots.steps ?? [];
3061
+ return {
3062
+ ...action,
3063
+ slots: {
3064
+ ...action.slots,
3065
+ moves: Array.isArray(moves)
3066
+ ? moves.map((move) => {
3067
+ if (!isJsonObject(move))
3068
+ return move;
3069
+ // A post settles a reservation; the ledger refuses a post whose
3070
+ // currency differs from the reserve, which is pinned here.
3071
+ if (move.operation === "internal_transfer.post")
3072
+ return move;
3073
+ const bind = move.bind;
3074
+ const amount = bind !== undefined && isJsonObject(bind)
3075
+ ? instanceField(bind.amount)
3076
+ : undefined;
3077
+ return pin(move, fixedCurrencyOf(amount) ?? ledger, amount ? `fields.${amount.name}` : "the moved amount");
3078
+ })
3079
+ : moves,
3080
+ steps: Array.isArray(steps)
3081
+ ? steps.map((step) => isJsonObject(step) &&
3082
+ typeof step.operation === "string" &&
3083
+ step.operation.startsWith("account.")
3084
+ ? pin(step, ledger, `the ${step.operation} account`)
3085
+ : step)
3086
+ : steps,
3087
+ },
3088
+ };
3089
+ }
2305
3090
  function bindFieldReferences(slots, fields, action, diagnostics) {
2306
3091
  const externalFieldSlot = (path) => {
2307
3092
  const key = path.at(-1);
@@ -2385,9 +3170,18 @@ function crossInstrumentReferenceDiagnostics(instruments, diagnostics) {
2385
3170
  report(owner, owner.origin, `${childId}.${childRefField} is not ref<${owner.id}>`, `declare ${childId}.${childRefField} as ref<${owner.id}> or correct childRefField`);
2386
3171
  }
2387
3172
  }
3173
+ const parentField = invariant.parentField;
3174
+ const parent = typeof parentField === "string"
3175
+ ? fieldIn(owner, parentField)
3176
+ : undefined;
2388
3177
  const childField = invariant.childField;
3178
+ const childFieldDef = typeof childField === "string" ? fieldIn(child, childField) : undefined;
3179
+ const isIntegerSum = invariant.count !== true &&
3180
+ parent?.type.kind === "integer" &&
3181
+ childFieldDef?.type.kind === "integer";
2389
3182
  if (typeof childField === "string" &&
2390
- fieldIn(child, childField)?.type.kind !== "money") {
3183
+ !isIntegerSum &&
3184
+ childFieldDef?.type.kind !== "money") {
2391
3185
  report(owner, owner.origin, `${childId}.${childField} is not declared money`, `declare ${childId}.${childField} as money<C> or correct childField`);
2392
3186
  }
2393
3187
  const childStates = statesOf(child);
@@ -2398,16 +3192,12 @@ function crossInstrumentReferenceDiagnostics(instruments, diagnostics) {
2398
3192
  report(owner, owner.origin, `${childId} has no lifecycle state ${status}`, `add ${status} to ${childId} or correct childStatuses`);
2399
3193
  }
2400
3194
  }
2401
- const parentField = invariant.parentField;
2402
- const parent = typeof parentField === "string"
2403
- ? fieldIn(owner, parentField)
2404
- : undefined;
2405
3195
  if (typeof parentField === "string" &&
2406
3196
  (!parent ||
2407
- (invariant.count === true
3197
+ (invariant.count === true || isIntegerSum
2408
3198
  ? parent.type.kind !== "integer"
2409
3199
  : parent.type.kind !== "money"))) {
2410
- const expected = invariant.count === true ? "integer" : "money<C>";
3200
+ const expected = invariant.count === true || isIntegerSum ? "integer" : "money<C>";
2411
3201
  report(owner, owner.origin, `${owner.id}.${parentField} is not declared ${expected}`, `declare ${owner.id}.${parentField} as ${expected} or correct parentField`);
2412
3202
  }
2413
3203
  }
@@ -2885,16 +3675,15 @@ function typeOf(expr, aliases = new Map(), seen = new Set()) {
2885
3675
  function schemaFor(type, required) {
2886
3676
  switch (type.kind) {
2887
3677
  case "money":
2888
- return type.fixedAmount
2889
- ? {
2890
- const: type.fixedAmount,
2891
- pattern: MONEY_PATTERN,
2892
- type: "string",
2893
- }
2894
- : {
2895
- pattern: required ? MONEY_PATTERN : OPTIONAL_MONEY_PATTERN,
2896
- type: "string",
2897
- };
3678
+ return {
3679
+ ...(type.fixedAmount
3680
+ ? { const: type.fixedAmount, pattern: MONEY_PATTERN }
3681
+ : { pattern: required ? MONEY_PATTERN : OPTIONAL_MONEY_PATTERN }),
3682
+ type: "string",
3683
+ ...(type.currency && CURRENCY.test(type.currency)
3684
+ ? { "x-hyperscale-currency": type.currency }
3685
+ : {}),
3686
+ };
2898
3687
  case "bps":
2899
3688
  case "percent":
2900
3689
  return { maximum: 10_000, minimum: 0, type: "integer" };
@@ -2906,6 +3695,8 @@ function schemaFor(type, required) {
2906
3695
  return { format: "hyperscale-date-time", type: "string" };
2907
3696
  case "ref":
2908
3697
  return { type: "string" };
3698
+ case "account":
3699
+ return { pattern: ACCOUNT_PATTERN, type: "string" };
2909
3700
  default:
2910
3701
  return { type: "string" };
2911
3702
  }