@hyperscale0/hsx 1.0.0-rc.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +32 -4
  2. package/README.md +58 -36
  3. package/bin/hsx.ts +2 -0
  4. package/dist/bin/hsx.js +2 -0
  5. package/dist/bin/hsx.js.map +1 -1
  6. package/dist/src/ast.d.ts +10 -2
  7. package/dist/src/ast.d.ts.map +1 -1
  8. package/dist/src/ast.js +24 -0
  9. package/dist/src/ast.js.map +1 -1
  10. package/dist/src/cli.d.ts +3 -1
  11. package/dist/src/cli.d.ts.map +1 -1
  12. package/dist/src/cli.js +25 -5
  13. package/dist/src/cli.js.map +1 -1
  14. package/dist/src/compile.d.ts +9 -5
  15. package/dist/src/compile.d.ts.map +1 -1
  16. package/dist/src/compile.js +9 -3
  17. package/dist/src/compile.js.map +1 -1
  18. package/dist/src/cost.d.ts +3 -1
  19. package/dist/src/cost.d.ts.map +1 -1
  20. package/dist/src/cost.js +124 -94
  21. package/dist/src/cost.js.map +1 -1
  22. package/dist/src/diagnostics.d.ts.map +1 -1
  23. package/dist/src/diagnostics.js +89 -5
  24. package/dist/src/diagnostics.js.map +1 -1
  25. package/dist/src/emit.d.ts +4 -3
  26. package/dist/src/emit.d.ts.map +1 -1
  27. package/dist/src/emit.js +28 -233
  28. package/dist/src/emit.js.map +1 -1
  29. package/dist/src/format.js.map +1 -1
  30. package/dist/src/index.d.ts +4 -3
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +3 -1
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/ir.d.ts.map +1 -1
  35. package/dist/src/lex.d.ts.map +1 -1
  36. package/dist/src/lsp/server.d.ts +32 -0
  37. package/dist/src/lsp/server.d.ts.map +1 -0
  38. package/dist/src/lsp/server.js +391 -0
  39. package/dist/src/lsp/server.js.map +1 -0
  40. package/dist/src/modules.d.ts +2 -0
  41. package/dist/src/modules.d.ts.map +1 -1
  42. package/dist/src/modules.js +14 -14
  43. package/dist/src/modules.js.map +1 -1
  44. package/dist/src/parse.js +3 -3
  45. package/dist/src/parse.js.map +1 -1
  46. package/dist/src/std-bundle.d.ts +2 -0
  47. package/dist/src/std-bundle.d.ts.map +1 -0
  48. package/dist/src/std-bundle.js +88 -0
  49. package/dist/src/std-bundle.js.map +1 -0
  50. package/dist/src/std-library.d.ts +5 -0
  51. package/dist/src/std-library.d.ts.map +1 -0
  52. package/dist/src/std-library.js +9 -0
  53. package/dist/src/std-library.js.map +1 -0
  54. package/dist/src/typecheck.d.ts.map +1 -1
  55. package/dist/src/typecheck.js +859 -156
  56. package/dist/src/typecheck.js.map +1 -1
  57. package/dist/src/version.d.ts +1 -1
  58. package/dist/src/version.d.ts.map +1 -1
  59. package/dist/src/version.js +1 -1
  60. package/dist/src/version.js.map +1 -1
  61. package/docs/README.md +12 -0
  62. package/docs/assets/hsx.svg +18 -0
  63. package/docs/guide/01-first-program.md +2 -2
  64. package/docs/guide/02-money.md +1 -1
  65. package/docs/guide/03-instruments.md +44 -3
  66. package/docs/guide/04-lifecycles.md +18 -3
  67. package/docs/guide/05-fees-and-splits.md +2 -2
  68. package/docs/guide/06-schedules.md +1 -1
  69. package/docs/guide/07-composition.md +8 -2
  70. package/docs/guide/08-writing-a-module.md +13 -2
  71. package/docs/guide/09-cost.md +3 -3
  72. package/docs/guide/10-diagnostics.md +1 -1
  73. package/docs/llms-full.txt +458 -194
  74. package/docs/llms.txt +2 -2
  75. package/docs/playground.md +55 -0
  76. package/docs/reference/cli.md +5 -3
  77. package/docs/reference/diagnostics.md +223 -37
  78. package/docs/reference/grammar.md +2 -2
  79. package/docs/reference/std/advance.md +2 -2
  80. package/docs/reference/std/cancellable_booking.md +10 -10
  81. package/docs/reference/std/captured_payment.md +14 -14
  82. package/docs/reference/std/conditional_disbursement.md +3 -3
  83. package/docs/reference/std/credit_facility.md +2 -2
  84. package/docs/reference/std/held_payment.md +37 -33
  85. package/docs/reference/std/instant_transfer.md +8 -8
  86. package/docs/reference/std/metered.md +2 -2
  87. package/docs/reference/std/pooled_split.md +2 -2
  88. package/docs/reference/std/premium_forward.md +13 -13
  89. package/docs/reference/std/reconciled_payout.md +2 -2
  90. package/docs/reference/std/recurring_collection.md +2 -2
  91. package/docs/reference/std/rotating_pool.md +2 -2
  92. package/docs/reference/std/scheduled.md +18 -18
  93. package/docs/reference/std/security_deposit.md +14 -14
  94. package/docs/reference/std/settlement_batch.md +2 -2
  95. package/docs/reference/std/swap.md +18 -18
  96. package/docs/reference/std/threshold_pool.md +2 -2
  97. package/docs/reference/std/weighted_distribution.md +2 -2
  98. package/docs/reference/types.md +1 -1
  99. package/docs/reference/udl-output.md +2 -3
  100. package/examples/01-first-program/README.md +1 -1
  101. package/examples/01-first-program/tip-jar.hsx +1 -1
  102. package/examples/02-imports-and-archetypes/README.md +1 -4
  103. package/examples/02-imports-and-archetypes/photo-booth.hsx +1 -1
  104. package/examples/03-diagnostics/corner-shop-fixed.hsx +1 -1
  105. package/examples/03-diagnostics/corner-shop.hsx +1 -1
  106. package/examples/04-complete-product/README.md +0 -3
  107. package/examples/04-complete-product/study-hall.hsx +1 -1
  108. package/examples/05-watch-club/README.md +0 -3
  109. package/examples/05-watch-club/watch-club.hsx +1 -1
  110. package/examples/README.md +35 -4
  111. package/examples/advance/advance.hsx +1 -1
  112. package/examples/advance/advance.udl +10 -5
  113. package/examples/cancellable_booking/cancellable_booking.hsx +1 -1
  114. package/examples/cancellable_booking/cancellable_booking.udl +3 -1
  115. package/examples/captured_payment/captured_payment.hsx +1 -1
  116. package/examples/captured_payment/captured_payment.udl +4 -1
  117. package/examples/conditional_disbursement/conditional_disbursement.hsx +1 -1
  118. package/examples/conditional_disbursement/conditional_disbursement.udl +5 -3
  119. package/examples/cost-table.json +1602 -382
  120. package/examples/credit_facility/credit_facility.hsx +1 -1
  121. package/examples/credit_facility/credit_facility.udl +16 -9
  122. package/examples/held_payment/held_payment.hsx +1 -1
  123. package/examples/held_payment/held_payment.udl +38 -3
  124. package/examples/instant_transfer/instant_transfer.hsx +1 -1
  125. package/examples/instant_transfer/instant_transfer.udl +11 -5
  126. package/examples/metered/metered.hsx +1 -1
  127. package/examples/metered/metered.udl +4 -2
  128. package/examples/pooled_split/pooled_split.hsx +1 -1
  129. package/examples/pooled_split/pooled_split.udl +6 -3
  130. package/examples/premium_forward/premium_forward.hsx +1 -1
  131. package/examples/premium_forward/premium_forward.udl +10 -4
  132. package/examples/reconciled_payout/reconciled_payout.hsx +1 -1
  133. package/examples/reconciled_payout/reconciled_payout.udl +4 -2
  134. package/examples/recurring_collection/recurring_collection.hsx +1 -1
  135. package/examples/recurring_collection/recurring_collection.udl +12 -7
  136. package/examples/rotating_pool/rotating_pool.hsx +1 -1
  137. package/examples/rotating_pool/rotating_pool.udl +14 -4
  138. package/examples/scheduled/scheduled.hsx +1 -1
  139. package/examples/scheduled/scheduled.udl +9 -4
  140. package/examples/security_deposit/security_deposit.hsx +1 -1
  141. package/examples/security_deposit/security_deposit.udl +17 -3
  142. package/examples/settlement_batch/settlement_batch.hsx +1 -1
  143. package/examples/swap/swap.hsx +1 -1
  144. package/examples/swap/swap.udl +12 -6
  145. package/examples/threshold_pool/threshold_pool.hsx +1 -1
  146. package/examples/threshold_pool/threshold_pool.udl +4 -2
  147. package/examples/weighted_distribution/weighted_distribution.hsx +1 -1
  148. package/examples/weighted_distribution/weighted_distribution.udl +4 -2
  149. package/package.json +15 -6
  150. package/skills/hsx/SKILL.md +26 -26
  151. package/src/ast.ts +34 -2
  152. package/src/cli.ts +32 -13
  153. package/src/compile.ts +15 -7
  154. package/src/cost.ts +176 -104
  155. package/src/diagnostics.ts +89 -4
  156. package/src/emit.ts +34 -266
  157. package/src/index.ts +10 -7
  158. package/src/lsp/server.ts +460 -0
  159. package/src/modules.ts +21 -13
  160. package/src/parse.ts +3 -3
  161. package/src/std-bundle.ts +91 -0
  162. package/src/std-library.ts +12 -0
  163. package/src/typecheck.ts +1147 -235
  164. package/src/version.ts +1 -1
  165. package/std/SEMANTICS.md +12 -3
  166. package/std/{settlements → money_flows}/advance.hsx +1 -1
  167. package/std/{settlements → money_flows}/cancellable_booking.hsx +4 -3
  168. package/std/{settlements → money_flows}/captured_payment.hsx +35 -6
  169. package/std/{settlements → money_flows}/conditional_disbursement.hsx +17 -16
  170. package/std/{settlements → money_flows}/credit_facility.hsx +1 -2
  171. package/std/{settlements → money_flows}/held_payment.hsx +59 -25
  172. package/std/money_flows/index.hsx +3 -0
  173. package/std/{settlements → money_flows}/instant_transfer.hsx +2 -3
  174. package/std/{settlements → money_flows}/metered.hsx +1 -1
  175. package/std/{settlements → money_flows}/pooled_split.hsx +3 -3
  176. package/std/{settlements → money_flows}/premium_forward.hsx +22 -23
  177. package/std/{settlements → money_flows}/reconciled_payout.hsx +3 -3
  178. package/std/{settlements → money_flows}/recurring_collection.hsx +1 -1
  179. package/std/{settlements → money_flows}/rotating_pool.hsx +8 -6
  180. package/std/{settlements → money_flows}/scheduled.hsx +22 -27
  181. package/std/{settlements → money_flows}/security_deposit.hsx +38 -14
  182. package/std/{settlements → money_flows}/settlement_batch.hsx +3 -3
  183. package/std/{settlements → money_flows}/swap.hsx +41 -16
  184. package/std/{settlements → money_flows}/threshold_pool.hsx +5 -5
  185. package/std/{settlements → money_flows}/weighted_distribution.hsx +6 -6
  186. package/docs/sessions/2026-09-02-two-instruments.hsx +0 -21
  187. package/docs/sessions/2026-09-02-two-instruments.md +0 -41
  188. package/std/settlements/index.hsx +0 -3
package/src/typecheck.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  ExposeDecl,
16
16
  IdentExpr,
17
17
  InstrumentDecl,
18
+ ModuleDecl,
18
19
  PortDecl,
19
20
  Program,
20
21
  ProgramDecl,
@@ -40,6 +41,7 @@ const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
40
41
  const CURRENCY = /^[A-Z]{3}$/;
41
42
  const MONEY_PATTERN = "^[1-9][0-9]{0,17}$";
42
43
  const OPTIONAL_MONEY_PATTERN = "^(0|[1-9][0-9]{0,17})$";
44
+ const ACCOUNT_PATTERN = "^acct_(sandbox|live)_[a-z0-9]{8,64}$";
43
45
  const MAX_COMPREHENSION_EXPANSIONS = 256;
44
46
  const NONE_SENTINEL = "__hsx_none__";
45
47
 
@@ -55,6 +57,7 @@ interface ConcreteInstrument {
55
57
  readonly generatedPrefix: boolean;
56
58
  readonly name: IdentExpr;
57
59
  readonly parties: ReadonlySet<string>;
60
+ readonly ports: ReadonlyMap<string, readonly PortDecl[]>;
58
61
  }
59
62
 
60
63
  const actionClauseBySpelling: ReadonlyMap<string, ClauseDefinition> = new Map(
@@ -130,6 +133,32 @@ export function checkGeneralProgram(
130
133
  .filter((decl): decl is PortDecl => decl.kind === "port")
131
134
  .map((decl) => [decl.name.name, decl]),
132
135
  );
136
+ const checkedPorts = new Set<PortDecl>();
137
+ const checkPortDeclarations = (
138
+ declared: ReadonlyMap<string, PortDecl>,
139
+ knownParties: ReadonlySet<string>,
140
+ ) => {
141
+ for (const port of declared.values()) {
142
+ if (checkedPorts.has(port)) continue;
143
+ checkedPorts.add(port);
144
+ const allowed = entry(port.body, "allowed")?.value;
145
+ for (const item of allowed?.kind === "list"
146
+ ? allowed.items
147
+ : allowed
148
+ ? [allowed]
149
+ : []) {
150
+ const party = nameText(item);
151
+ if (knownParties.has(party)) continue;
152
+ report(
153
+ "HSX1024",
154
+ item.span,
155
+ `decision port ${port.name.name} allows undeclared party ${party}; declare the party before using it`,
156
+ `declare party ${party} with its party kind before using it in this port`,
157
+ );
158
+ }
159
+ }
160
+ };
161
+ checkPortDeclarations(ports, parties);
133
162
  const assetSubjects: TypedSubject[] = program.decls
134
163
  .filter((decl) => decl.kind === "asset")
135
164
  .map((decl) => ({
@@ -200,33 +229,24 @@ export function checkGeneralProgram(
200
229
  (declaration) =>
201
230
  declaration.kind === "use" || declaration.kind === "expose",
202
231
  );
203
- const authoredInstrumentDeclarations = program.decls.filter(
204
- (declaration) =>
205
- declaration.kind === "instrument" ||
206
- declaration.kind === "instrument_apply",
207
- );
208
- if (
209
- options.publishedCatalog &&
210
- (compositionDeclarations.length > 0 ||
211
- authoredInstrumentDeclarations.length === 0)
212
- ) {
213
- return checkPublishedProgram(
214
- program,
215
- options.publishedCatalog,
216
- header,
217
- subjects,
218
- diagnostics,
219
- report,
220
- );
221
- }
222
- for (const declaration of compositionDeclarations) {
223
- report(
224
- "HSX1018",
225
- declaration.span,
226
- "this compiler host did not supply a published instrument catalog",
227
- "pass the canonical UDL catalog as the publishedCatalog compile option",
228
- );
232
+ if (!options.publishedCatalog) {
233
+ for (const declaration of compositionDeclarations) {
234
+ report(
235
+ "HSX1018",
236
+ declaration.span,
237
+ "this compiler host did not supply a published instrument catalog",
238
+ "pass the canonical UDL catalog as the publishedCatalog compile option",
239
+ );
240
+ }
229
241
  }
242
+ const catalogById = options.publishedCatalog
243
+ ? new Map(
244
+ options.publishedCatalog.instruments.map((instrument) => [
245
+ instrument.id,
246
+ instrument,
247
+ ]),
248
+ )
249
+ : undefined;
230
250
 
231
251
  const aliases = new Map(
232
252
  program.decls
@@ -250,6 +270,8 @@ export function checkGeneralProgram(
250
270
  }
251
271
 
252
272
  const concrete: ConcreteInstrument[] = [];
273
+ const reachedPorts = new Set<PortDecl>();
274
+ const droppedPorts = new Set<PortDecl>();
253
275
  const scopedDeclarations = (
254
276
  declarationScope: readonly ApplicationScopeDecl[],
255
277
  ) => {
@@ -270,6 +292,7 @@ export function checkGeneralProgram(
270
292
  scopedTemplates.set(local.name.name, local);
271
293
  }
272
294
  }
295
+ checkPortDeclarations(scopedPorts, scopedParties);
273
296
  checkConstants(declarationScope, scopedAliases, diagnostics);
274
297
  return {
275
298
  aliases: scopedAliases,
@@ -286,13 +309,50 @@ export function checkGeneralProgram(
286
309
  decl.typeParameters.length === 0
287
310
  ) {
288
311
  const scoped = scopedDeclarations(decl.declarationScope ?? []);
289
- concrete.push({
290
- aliases: scoped.aliases,
291
- body: substituteExpr(decl.body, scoped.constants) as BlockExpr,
292
- generatedPrefix: false,
293
- name: decl.name,
294
- parties: scoped.parties,
295
- });
312
+ const values = new Map(scoped.constants);
313
+ const dependencies = new Map<string, readonly string[]>();
314
+ const shapes = new Map<string, BlockExpr>();
315
+ for (const [name, port] of scoped.ports) {
316
+ const reference: Extract<Expr, { kind: "port_ref" }> = {
317
+ kind: "port_ref",
318
+ name: port.name,
319
+ span: port.span,
320
+ };
321
+ values.set(name, reference);
322
+ bindPortCompileValues(
323
+ values,
324
+ name,
325
+ reference,
326
+ scoped.ports,
327
+ diagnostics,
328
+ scoped.aliases,
329
+ shapes,
330
+ dependencies,
331
+ );
332
+ }
333
+ const selected = selectCompileTimeRows(
334
+ decl.body,
335
+ values,
336
+ diagnostics,
337
+ dependencies,
338
+ );
339
+ const body = expandComprehensions(
340
+ selected,
341
+ diagnostics,
342
+ values,
343
+ dependencies,
344
+ );
345
+ concrete.push(
346
+ ...bindActionPorts(
347
+ constructedInstruments(decl.name, body, diagnostics),
348
+ scoped.ports,
349
+ reachedPorts,
350
+ ).map((candidate) => ({
351
+ ...candidate,
352
+ aliases: scoped.aliases,
353
+ parties: scoped.parties,
354
+ })),
355
+ );
296
356
  }
297
357
  if (decl.kind !== "instrument_apply") continue;
298
358
  const application = decl.application;
@@ -315,6 +375,7 @@ export function checkGeneralProgram(
315
375
  );
316
376
  continue;
317
377
  }
378
+ const boundPorts = new Map<string, PortDecl>();
318
379
  const body = instantiate(
319
380
  template,
320
381
  application,
@@ -324,23 +385,41 @@ export function checkGeneralProgram(
324
385
  scoped.parties,
325
386
  scoped.aliases,
326
387
  scoped.constants,
388
+ boundPorts,
327
389
  );
328
390
  if (body) {
391
+ const applicationPorts = new Set<PortDecl>();
329
392
  const merged = decl.metadata
330
393
  ? mergeApplicationMetadata(body, decl.metadata, diagnostics)
331
394
  : body;
332
395
  concrete.push(
333
- ...constructedInstruments(decl.name, merged, diagnostics).map(
334
- (candidate) => ({
335
- ...candidate,
336
- aliases: scoped.aliases,
337
- parties: scoped.parties,
338
- }),
339
- ),
396
+ ...bindActionPorts(
397
+ constructedInstruments(decl.name, merged, diagnostics),
398
+ boundPorts,
399
+ applicationPorts,
400
+ ).map((candidate) => ({
401
+ ...candidate,
402
+ aliases: scoped.aliases,
403
+ parties: scoped.parties,
404
+ })),
340
405
  );
406
+ for (const port of boundPorts.values()) {
407
+ if (applicationPorts.has(port)) reachedPorts.add(port);
408
+ else droppedPorts.add(port);
409
+ }
341
410
  }
342
411
  }
343
412
 
413
+ for (const port of checkedPorts) {
414
+ if (reachedPorts.has(port) && !droppedPorts.has(port)) continue;
415
+ report(
416
+ "HSX1024",
417
+ port.name.span,
418
+ `wired decision port ${port.name.name} reaches no action`,
419
+ `carry ${port.name.name}'s condition into the action it authorizes`,
420
+ );
421
+ }
422
+
344
423
  const allocated = allocateGeneratedPrefixes(concrete);
345
424
 
346
425
  const instruments: TypedInstrument[] = [];
@@ -356,17 +435,39 @@ export function checkGeneralProgram(
356
435
  continue;
357
436
  }
358
437
  ids.add(candidate.name.name);
438
+ if (catalogById?.has(candidate.name.name)) {
439
+ report(
440
+ "HSX1022",
441
+ candidate.name.span,
442
+ `authored instrument ${candidate.name.name} collides with published catalog instrument`,
443
+ "rename the authored instrument to avoid colliding with the published catalog",
444
+ );
445
+ continue;
446
+ }
359
447
  const expandedBody = expandComprehensions(candidate.body, diagnostics);
360
448
  const checked = checkInstrument(
361
449
  candidate.name,
362
450
  expandedBody,
363
451
  candidate.parties,
452
+ candidate.ports,
364
453
  candidate.aliases,
365
454
  diagnostics,
366
455
  );
367
456
  if (checked) instruments.push(checked);
368
457
  }
369
458
 
459
+ if (options.publishedCatalog) {
460
+ return checkPublishedProgram(
461
+ program,
462
+ options.publishedCatalog,
463
+ header,
464
+ subjects,
465
+ instruments,
466
+ diagnostics,
467
+ report,
468
+ );
469
+ }
470
+
370
471
  crossInstrumentReferenceDiagnostics(instruments, diagnostics);
371
472
 
372
473
  if (
@@ -402,6 +503,7 @@ function checkPublishedProgram(
402
503
  catalog: UdlDocument,
403
504
  header: ProgramDecl | undefined,
404
505
  authoredSubjects: readonly TypedSubject[],
506
+ authoredInstruments: readonly TypedInstrument[],
405
507
  diagnostics: GeneralDiagnostic[],
406
508
  report: (
407
509
  code: string,
@@ -411,50 +513,43 @@ function checkPublishedProgram(
411
513
  severity?: "error" | "warning",
412
514
  ) => void,
413
515
  ): GeneralCheckResult {
414
- if (!header) {
415
- return { diagnostics };
416
- }
417
- if (!header.title || header.title.value.length === 0) {
418
- report(
419
- "HSX1015",
420
- header.span,
421
- `composed program ${header.name.name} needs a product title`,
422
- 'write the title after the product id, like `program product_name "Product title"`',
423
- );
424
- }
425
- if (header.name.name.length > 60) {
426
- report(
427
- "HSX1015",
428
- header.name.span,
429
- "a composed program product id is at most 60 characters",
430
- "shorten the product id without changing its snake_case spelling",
431
- );
432
- }
433
- if ((header.title?.value.length ?? 0) > 80) {
434
- report(
435
- "HSX1015",
436
- header.title?.span ?? header.span,
437
- "a composed program title is at most 80 characters",
438
- "shorten the product title to 80 characters or fewer",
439
- );
440
- }
441
-
442
- const authoredInstruments = program.decls.filter(
443
- (decl) => decl.kind === "instrument" || decl.kind === "instrument_apply",
516
+ const compositionDeclarations = program.decls.filter(
517
+ (decl) => decl.kind === "use" || decl.kind === "expose",
444
518
  );
445
- for (const declaration of authoredInstruments) {
446
- report(
447
- "HSX1022",
448
- declaration.span,
449
- "a composed program cannot author an instrument",
450
- "publish the instrument in the catalog, then select it with use",
451
- );
519
+ if (
520
+ header &&
521
+ (compositionDeclarations.length > 0 || authoredInstruments.length === 0)
522
+ ) {
523
+ if (!header.title || header.title.value.length === 0) {
524
+ report(
525
+ "HSX1015",
526
+ header.span,
527
+ `composed program ${header.name.name} needs a product title`,
528
+ 'write the title after the product id, like `program product_name "Product title"`',
529
+ );
530
+ }
531
+ if (header.name.name.length > 60) {
532
+ report(
533
+ "HSX1015",
534
+ header.name.span,
535
+ "a composed program product id is at most 60 characters",
536
+ "shorten the product id without changing its snake_case spelling",
537
+ );
538
+ }
539
+ if ((header.title?.value.length ?? 0) > 80) {
540
+ report(
541
+ "HSX1015",
542
+ header.title?.span ?? header.span,
543
+ "a composed program title is at most 80 characters",
544
+ "shorten the product title to 80 characters or fewer",
545
+ );
546
+ }
452
547
  }
453
548
 
454
549
  const uses = program.decls.filter(
455
550
  (decl): decl is UseDecl => decl.kind === "use",
456
551
  );
457
- if (uses.length === 0) {
552
+ if (header && uses.length === 0 && authoredInstruments.length === 0) {
458
553
  report(
459
554
  "HSX1016",
460
555
  header.span,
@@ -504,6 +599,9 @@ function checkPublishedProgram(
504
599
  const exposures = program.decls.filter(
505
600
  (decl): decl is ExposeDecl => decl.kind === "expose",
506
601
  );
602
+ const authoredById = new Map(
603
+ authoredInstruments.map((instrument) => [instrument.id, instrument]),
604
+ );
507
605
  const publicNames = new Map<string, ExposeDecl>();
508
606
  const exposedTargets = new Map<string, ExposeDecl>();
509
607
  for (const exposure of exposures) {
@@ -511,11 +609,18 @@ function checkPublishedProgram(
511
609
  const explicitlyUsed = useById.has(exposure.instrument.name);
512
610
  const instrument = selectedById.get(exposure.instrument.name)?.instrument;
513
611
  const action = instrument?.actions[exposure.action.name];
514
- if (!explicitlyUsed || !instrument || !action) {
612
+ const authoredInstrument = authoredById.get(exposure.instrument.name);
613
+ const authoredAction = authoredInstrument?.actions.find(
614
+ (candidate) => candidate.name === exposure.action.name,
615
+ );
616
+ if (
617
+ (!explicitlyUsed || !instrument || !action) &&
618
+ (!authoredInstrument || !authoredAction)
619
+ ) {
515
620
  report(
516
621
  "HSX1021",
517
622
  exposure.span,
518
- `${target} is not an action on an explicitly used published instrument`,
623
+ `${target} is not an action on an explicitly used published instrument or authored instrument`,
519
624
  "add the matching use declaration and choose one of that instrument's actions",
520
625
  );
521
626
  continue;
@@ -546,14 +651,66 @@ function checkPublishedProgram(
546
651
  exposedTargets.set(target, exposure);
547
652
  }
548
653
 
654
+ const updatedAuthoredInstruments = authoredInstruments.map(
655
+ (authoredInstrument) => {
656
+ const instrumentExposures = exposures.filter(
657
+ (exposure) => exposure.instrument.name === authoredInstrument.id,
658
+ );
659
+ if (instrumentExposures.length === 0) return authoredInstrument;
660
+ const exposureByAction = new Map(
661
+ instrumentExposures.map((exposure) => [exposure.action.name, exposure]),
662
+ );
663
+ return {
664
+ ...authoredInstrument,
665
+ actions: authoredInstrument.actions.map((action) => {
666
+ const exposure = exposureByAction.get(action.name);
667
+ if (!exposure) return action;
668
+ return {
669
+ ...action,
670
+ origin: exposure.span,
671
+ slots: {
672
+ ...action.slots,
673
+ publicAction: exposure.publicName.name,
674
+ },
675
+ };
676
+ }),
677
+ };
678
+ },
679
+ );
680
+
681
+ const publishedInstruments = selected.map(({ instrument, origin }) => {
682
+ const projected = projectedPublishedInstrument(instrument, exposures);
683
+ return typedPublishedInstrument(
684
+ projected,
685
+ origin,
686
+ exposures.filter(
687
+ (exposure) => exposure.instrument.name === instrument.id,
688
+ ),
689
+ catalog.instruments,
690
+ );
691
+ });
692
+ const instruments = [...publishedInstruments, ...updatedAuthoredInstruments];
693
+ crossInstrumentReferenceDiagnostics(instruments, diagnostics);
694
+ if (
695
+ header &&
696
+ instruments.length === 0 &&
697
+ !diagnostics.some((diagnostic) => diagnostic.severity === "error")
698
+ ) {
699
+ report(
700
+ "HSX1502",
701
+ header.span,
702
+ `program ${header.name.name} emits no instruments`,
703
+ "declare or instantiate at least one instrument",
704
+ );
705
+ }
706
+
549
707
  const referencedSubjectKinds = new Set(
550
708
  selected.flatMap(({ instrument }) => instrument.subject?.kinds ?? []),
551
709
  );
710
+ const defaultSpan = uses[0]?.span ?? header?.span ?? program.span;
552
711
  const catalogSubjects = catalog.subjects
553
712
  .filter((subject) => referencedSubjectKinds.has(subject.kind))
554
- .map((subject) =>
555
- typedPublishedSubject(subject, uses[0]?.span ?? header.span),
556
- );
713
+ .map((subject) => typedPublishedSubject(subject, defaultSpan));
557
714
  const catalogSubjectKinds = new Set(
558
715
  catalogSubjects.map((subject) => subject.kind),
559
716
  );
@@ -570,19 +727,14 @@ function checkPublishedProgram(
570
727
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
571
728
  return { diagnostics };
572
729
  }
730
+ const module = program.decls.find(
731
+ (decl): decl is ModuleDecl => decl.kind === "module",
732
+ );
733
+ const name = header?.name.name ?? module?.name.parts.at(-1)?.name ?? "module";
573
734
  const typed: TypedProgram = {
574
- instruments: selected.map(({ instrument, origin }) => {
575
- const projected = projectedPublishedInstrument(instrument, exposures);
576
- return typedPublishedInstrument(
577
- projected,
578
- origin,
579
- exposures.filter(
580
- (exposure) => exposure.instrument.name === instrument.id,
581
- ),
582
- );
583
- }),
735
+ instruments,
584
736
  kind: "typed_program",
585
- name: header.name.name,
737
+ name,
586
738
  origin: program.span,
587
739
  subjects: [
588
740
  ...catalogSubjects,
@@ -590,7 +742,7 @@ function checkPublishedProgram(
590
742
  (subject) => !catalogSubjectKinds.has(subject.kind),
591
743
  ),
592
744
  ],
593
- title: header.title?.value ?? sentenceCase(header.name.name),
745
+ title: header?.title?.value ?? sentenceCase(name),
594
746
  };
595
747
  return { diagnostics, program: typed };
596
748
  }
@@ -694,6 +846,7 @@ function typedPublishedInstrument(
694
846
  source: UdlInstrument,
695
847
  origin: Span,
696
848
  exposures: readonly ExposeDecl[],
849
+ catalog: readonly UdlInstrument[],
697
850
  ): TypedInstrument {
698
851
  const exposureByAction = new Map(
699
852
  exposures.map((exposure) => [exposure.action.name, exposure]),
@@ -724,7 +877,7 @@ function typedPublishedInstrument(
724
877
  origin,
725
878
  required: requiredFields.has(name),
726
879
  schema,
727
- type: { kind: "unknown" },
880
+ type: publishedFieldType(schema, catalog),
728
881
  })),
729
882
  id: source.id,
730
883
  origin,
@@ -735,6 +888,31 @@ function typedPublishedInstrument(
735
888
  };
736
889
  }
737
890
 
891
+ /** Recover the semantic types retained by UDL before checking mixed relations. */
892
+ function publishedFieldType(
893
+ schema: Readonly<Record<string, JsonValue>>,
894
+ catalog: readonly UdlInstrument[],
895
+ ): HsxType {
896
+ if (schema.type === "integer") return { kind: "integer" };
897
+ if (schema.type === "boolean") return { kind: "boolean" };
898
+ if (schema.type !== "string") return { kind: "unknown" };
899
+ if (
900
+ schema.pattern === MONEY_PATTERN ||
901
+ schema.pattern === OPTIONAL_MONEY_PATTERN
902
+ ) {
903
+ const currency = schema["x-hyperscale-currency"];
904
+ return typeof currency === "string"
905
+ ? { currency, kind: "money" }
906
+ : { kind: "money" };
907
+ }
908
+ const target = catalog.find(
909
+ (instrument) =>
910
+ schema.pattern ===
911
+ `^${instrument.idPrefix}_(sandbox|live)_[a-z0-9]{8,64}$`,
912
+ );
913
+ return target ? { kind: "ref", target: target.id } : { kind: "text" };
914
+ }
915
+
738
916
  function typedPublishedSubject(
739
917
  subject: UdlDocument["subjects"][number],
740
918
  origin: Span,
@@ -758,6 +936,7 @@ const applicationMetadataKeys = new Set([
758
936
  "action",
759
937
  "agent_description",
760
938
  "description",
939
+ "journeys",
761
940
  "nav",
762
941
  "navigation",
763
942
  "summary",
@@ -769,6 +948,7 @@ const applicationMetadataKeys = new Set([
769
948
  const applicationActionMetadataKeys = new Set([
770
949
  "agent_description",
771
950
  "description",
951
+ "examples",
772
952
  "public",
773
953
  "public_action",
774
954
  "summary",
@@ -875,6 +1055,74 @@ function mergeApplicationMetadata(
875
1055
  return { ...body, entries };
876
1056
  }
877
1057
 
1058
+ function bindActionPorts(
1059
+ candidates: ReturnType<typeof constructedInstruments>,
1060
+ bindings: ReadonlyMap<string, PortDecl>,
1061
+ reached: Set<PortDecl>,
1062
+ ) {
1063
+ const declarations = new Map(
1064
+ [...bindings.values()].map((port) => [port.name.name, port]),
1065
+ );
1066
+ return candidates.map((candidate) => {
1067
+ const ports = new Map<string, readonly PortDecl[]>();
1068
+ const entries = candidate.body.entries.map((row) => {
1069
+ if (row.key.name !== "action" || row.value.kind !== "block") return row;
1070
+ const action = row.qualifiers[0]?.name;
1071
+ if (!action) return row;
1072
+ const declared = (row.conditionPorts ?? []).flatMap((name) => {
1073
+ const port = declarations.get(name);
1074
+ if (!port) return [];
1075
+ reached.add(port);
1076
+ return [port];
1077
+ });
1078
+ if (!declared.length) return row;
1079
+ ports.set(action, declared);
1080
+ if (entry(row.value, "port")) return row;
1081
+ const source = declared[0]!;
1082
+ const allowed = entry(source.body, "allowed")?.value;
1083
+ const span = source.span;
1084
+ return {
1085
+ ...row,
1086
+ value: {
1087
+ ...row.value,
1088
+ entries: [
1089
+ ...row.value.entries,
1090
+ {
1091
+ key: { kind: "ident" as const, name: "port", span },
1092
+ qualifiers: [],
1093
+ span,
1094
+ value: {
1095
+ kind: "block" as const,
1096
+ span,
1097
+ entries: [
1098
+ {
1099
+ key: {
1100
+ kind: "ident" as const,
1101
+ name: "allowed_parties",
1102
+ span,
1103
+ },
1104
+ qualifiers: [],
1105
+ span,
1106
+ value:
1107
+ allowed?.kind === "list"
1108
+ ? allowed
1109
+ : {
1110
+ kind: "list" as const,
1111
+ span,
1112
+ items: allowed ? [allowed] : [],
1113
+ },
1114
+ },
1115
+ ],
1116
+ },
1117
+ },
1118
+ ],
1119
+ },
1120
+ };
1121
+ });
1122
+ return { ...candidate, body: { ...candidate.body, entries }, ports };
1123
+ });
1124
+ }
1125
+
878
1126
  function constructedInstruments(
879
1127
  rootName: IdentExpr,
880
1128
  body: BlockExpr,
@@ -1089,7 +1337,10 @@ function instantiate(
1089
1337
  parties: ReadonlySet<string>,
1090
1338
  aliases: ReadonlyMap<string, Expr>,
1091
1339
  constants: ReadonlyMap<string, Expr>,
1340
+ boundPorts: Map<string, PortDecl>,
1092
1341
  ): BlockExpr | undefined {
1342
+ const loweredPortShapes = new Map<string, BlockExpr>();
1343
+ const portDependencies = new Map<string, readonly string[]>();
1093
1344
  const partyKeyedParameters = partyKeyedBlockParameters(template, aliases);
1094
1345
  const named = new Map<string, Expr>();
1095
1346
  const positional: Expr[] = [];
@@ -1169,9 +1420,14 @@ function instantiate(
1169
1420
  if (value) values.set(parameter.name.name, value);
1170
1421
  });
1171
1422
  for (const [name, value] of named) values.set(name, value);
1423
+ const resolvedArgs: Array<{
1424
+ readonly parameter: InstrumentDecl["parameters"][number];
1425
+ readonly value: Expr | undefined;
1426
+ }> = [];
1172
1427
  let position = 0;
1173
1428
  for (const parameter of template.parameters) {
1174
1429
  const value = named.get(parameter.name.name) ?? positional[position++];
1430
+ resolvedArgs.push({ parameter, value });
1175
1431
  if (!value) {
1176
1432
  if (optionalInner(parameter.type)) {
1177
1433
  values.set(parameter.name.name, booleanExpr(false, parameter.span));
@@ -1253,17 +1509,30 @@ function instantiate(
1253
1509
  } else {
1254
1510
  values.set(parameter.name.name, value);
1255
1511
  }
1256
- if (value.kind === "port_ref") {
1512
+ }
1513
+ for (const { parameter, value } of resolvedArgs) {
1514
+ if (value?.kind === "port_ref") {
1515
+ const port = ports.get(value.name.name);
1516
+ if (port) boundPorts.set(parameter.name.name, port);
1257
1517
  bindPortCompileValues(
1258
1518
  values,
1259
1519
  parameter.name.name,
1260
1520
  value,
1261
1521
  ports,
1262
1522
  diagnostics,
1523
+ aliases,
1524
+ loweredPortShapes,
1525
+ portDependencies,
1263
1526
  );
1264
1527
  }
1265
1528
  }
1266
- const selected = selectCompileTimeRows(template.body, values, diagnostics);
1529
+ checkClaimAmountPortField(resolvedArgs, ports, aliases, diagnostics);
1530
+ const selected = selectCompileTimeRows(
1531
+ template.body,
1532
+ values,
1533
+ diagnostics,
1534
+ portDependencies,
1535
+ );
1267
1536
  const dependencyDiagnosticStart = diagnostics.length;
1268
1537
  for (const [name, parameter] of missingOptional) {
1269
1538
  if (!referencesIdentifier(selected, name, values)) continue;
@@ -1278,7 +1547,12 @@ function instantiate(
1278
1547
  });
1279
1548
  }
1280
1549
  if (diagnostics.length > dependencyDiagnosticStart) return undefined;
1281
- const body = substituteExpr(selected, values) as BlockExpr;
1550
+ const body = expandComprehensions(
1551
+ selected,
1552
+ diagnostics,
1553
+ values,
1554
+ portDependencies,
1555
+ );
1282
1556
  return {
1283
1557
  ...body,
1284
1558
  entries: body.entries.map((row) => {
@@ -1499,12 +1773,268 @@ function partyKeyedBlockParameters(
1499
1773
  );
1500
1774
  }
1501
1775
 
1776
+ function shapeTypeText(expr: Expr): string {
1777
+ if (expr.kind === "ident") return expr.name;
1778
+ if (expr.kind === "block") return "block";
1779
+ if (expr.kind === "list") return "list";
1780
+ if (expr.kind === "number") return expr.raw;
1781
+ if (expr.kind === "string") return `"${expr.value}"`;
1782
+ if (expr.kind === "boolean") return String(expr.value);
1783
+ if (expr.kind === "call") {
1784
+ const args = expr.args.map(shapeTypeText).join(", ");
1785
+ return `${expr.callee.name}(${args})`;
1786
+ }
1787
+ if (expr.kind === "type_apply") {
1788
+ const args = expr.args.map(shapeTypeText).join(", ");
1789
+ return `${expr.callee.name}<${args}>`;
1790
+ }
1791
+ return nameText(expr);
1792
+ }
1793
+
1794
+ function lowerPortShapeField(
1795
+ port: PortDecl,
1796
+ row: Entry,
1797
+ aliases: ReadonlyMap<string, Expr>,
1798
+ diagnostics: GeneralDiagnostic[],
1799
+ ): Entry | undefined {
1800
+ const type = typeOf(row.value, aliases);
1801
+ if (
1802
+ ![
1803
+ "text",
1804
+ "money",
1805
+ "date",
1806
+ "integer",
1807
+ "boolean",
1808
+ "account",
1809
+ "ref",
1810
+ "bps",
1811
+ "percent",
1812
+ ].includes(type.kind) ||
1813
+ ((type.kind === "money" || type.kind === "account") &&
1814
+ (!type.currency || !CURRENCY.test(type.currency)))
1815
+ ) {
1816
+ diagnostics.push({
1817
+ code: "HSX1025",
1818
+ fix: `declare ${row.key.name} as text, money(CUR), date, integer, boolean, account(CUR), ref<instrument_id>, id(instrument_id), bps, or percent`,
1819
+ message: `decision port ${port.name.name} shape field ${row.key.name} has unknown type ${shapeTypeText(row.value)}`,
1820
+ severity: "error",
1821
+ span: row.value.span,
1822
+ });
1823
+ return undefined;
1824
+ }
1825
+ const extras: Record<string, string | number> = {};
1826
+ if (type.kind === "text") {
1827
+ extras.description =
1828
+ "Required decision reference retained in the operation receipt";
1829
+ extras.max_length = 180;
1830
+ extras.min_length = 1;
1831
+ } else if (type.kind === "money") {
1832
+ extras.description = `Decided amount in ${type.currency} minor units`;
1833
+ } else if (type.kind === "ref") {
1834
+ extras.max_length = 180;
1835
+ extras.min_length = 1;
1836
+ if (type.target)
1837
+ extras.pattern = `^${prefixFor(type.target)}_(sandbox|live)_[a-z0-9]{8,64}$`;
1838
+ } else if (type.kind === "account") {
1839
+ extras.pattern = ACCOUNT_PATTERN;
1840
+ }
1841
+ const scalarEntries = (
1842
+ values: Readonly<Record<string, JsonValue>>,
1843
+ ): Entry[] =>
1844
+ Object.entries(values).map(([name, value]) => ({
1845
+ key: { kind: "ident", name, span: row.span },
1846
+ qualifiers: [],
1847
+ span: row.span,
1848
+ value:
1849
+ typeof value === "number"
1850
+ ? { kind: "number", raw: String(value), span: row.span }
1851
+ : { kind: "string", value: String(value), span: row.span },
1852
+ }));
1853
+ // Port inputs require every key, but a caller-decided amount may be zero.
1854
+ const field = lowerField(
1855
+ {
1856
+ ...row,
1857
+ value: {
1858
+ kind: "block",
1859
+ span: row.span,
1860
+ entries: [
1861
+ {
1862
+ key: { kind: "ident", name: "type", span: row.span },
1863
+ qualifiers: [],
1864
+ span: row.span,
1865
+ value: row.value,
1866
+ },
1867
+ {
1868
+ key: { kind: "ident", name: "optional", span: row.span },
1869
+ qualifiers: [],
1870
+ span: row.span,
1871
+ value: { kind: "boolean", value: true, span: row.span },
1872
+ },
1873
+ ...scalarEntries(extras),
1874
+ ],
1875
+ },
1876
+ },
1877
+ aliases,
1878
+ diagnostics,
1879
+ );
1880
+ const schema =
1881
+ type.kind === "text"
1882
+ ? {
1883
+ type: field.schema.type!,
1884
+ description: field.schema.description!,
1885
+ ...field.schema,
1886
+ }
1887
+ : field.schema;
1888
+
1889
+ return {
1890
+ key: row.key,
1891
+ qualifiers: [],
1892
+ span: row.span,
1893
+ value: {
1894
+ kind: "block",
1895
+ span: row.span,
1896
+ entries: scalarEntries(schema),
1897
+ },
1898
+ };
1899
+ }
1900
+
1901
+ function lowerPortShape(
1902
+ port: PortDecl,
1903
+ rawShape: Expr,
1904
+ aliases: ReadonlyMap<string, Expr>,
1905
+ diagnostics: GeneralDiagnostic[],
1906
+ ): BlockExpr {
1907
+ if (rawShape.kind !== "block") {
1908
+ diagnostics.push({
1909
+ code: "HSX1025",
1910
+ fix: "declare shape as a block of fields, like shape { note: text; }",
1911
+ message: `decision port ${port.name.name} shape must be a block`,
1912
+ severity: "error",
1913
+ span: rawShape.span,
1914
+ });
1915
+ return { entries: [], kind: "block", span: rawShape.span };
1916
+ }
1917
+ const entries: Entry[] = [];
1918
+ const names = new Set<string>();
1919
+ for (const row of rawShape.entries) {
1920
+ if (names.has(row.key.name)) {
1921
+ diagnostics.push({
1922
+ code: "HSX1004",
1923
+ fix: `keep one ${row.key.name} field in decision port ${port.name.name}`,
1924
+ message: `decision port ${port.name.name} shape field ${row.key.name} is declared twice`,
1925
+ severity: "error",
1926
+ span: row.key.span,
1927
+ });
1928
+ continue;
1929
+ }
1930
+ names.add(row.key.name);
1931
+ const lowered = lowerPortShapeField(port, row, aliases, diagnostics);
1932
+ if (lowered) entries.push(lowered);
1933
+ }
1934
+ return {
1935
+ entries,
1936
+ kind: "block",
1937
+ span: rawShape.span,
1938
+ };
1939
+ }
1940
+
1941
+ function checkClaimAmountPortField(
1942
+ resolvedArgs: ReadonlyArray<{
1943
+ readonly parameter: InstrumentDecl["parameters"][number];
1944
+ readonly value: Expr | undefined;
1945
+ }>,
1946
+ ports: ReadonlyMap<string, PortDecl>,
1947
+ aliases: ReadonlyMap<string, Expr>,
1948
+ diagnostics: GeneralDiagnostic[],
1949
+ ): void {
1950
+ for (const { value } of resolvedArgs) {
1951
+ if (value?.kind !== "block") continue;
1952
+ const fieldEntry = entry(value, "field");
1953
+ const boundEntry = entry(value, "bound");
1954
+ if (!fieldEntry || !boundEntry) continue;
1955
+
1956
+ const claimFieldName =
1957
+ fieldEntry.value.kind === "ident"
1958
+ ? fieldEntry.value.name
1959
+ : fieldEntry.value.kind === "string"
1960
+ ? fieldEntry.value.value
1961
+ : undefined;
1962
+ const boundFieldName =
1963
+ boundEntry.value.kind === "ident"
1964
+ ? boundEntry.value.name
1965
+ : boundEntry.value.kind === "string"
1966
+ ? boundEntry.value.value
1967
+ : undefined;
1968
+ if (!claimFieldName || !boundFieldName) continue;
1969
+
1970
+ const boundArg = resolvedArgs.find(
1971
+ (arg) =>
1972
+ (arg.value?.kind === "binding" &&
1973
+ arg.value.name.name === boundFieldName) ||
1974
+ arg.parameter.name.name === boundFieldName,
1975
+ );
1976
+ const boundTypeExpr =
1977
+ boundArg?.value?.kind === "binding"
1978
+ ? boundArg.value.type
1979
+ : boundArg?.value;
1980
+ if (!boundTypeExpr) continue;
1981
+ const boundType = typeOf(boundTypeExpr, aliases);
1982
+ if (boundType.kind !== "money") continue;
1983
+
1984
+ const portArg = resolvedArgs.find(
1985
+ (arg) => arg.parameter.name.name === "claim",
1986
+ );
1987
+ if (portArg?.value?.kind !== "port_ref") continue;
1988
+ const portDecl = ports.get(portArg.value.name.name);
1989
+ if (!portDecl) continue;
1990
+ const shapeExpr = entry(portDecl.body, "shape")?.value;
1991
+ if (shapeExpr?.kind !== "block") continue;
1992
+ const shapeField = entry(shapeExpr, claimFieldName);
1993
+ if (!shapeField) {
1994
+ diagnostics.push({
1995
+ code: "HSX1011",
1996
+ fix: `add ${claimFieldName}: money(${boundType.currency ?? "CUR"}) to the shape of decision port ${portDecl.name.name}`,
1997
+ message: `decision port ${portDecl.name.name} shape lacks claim field ${claimFieldName}`,
1998
+ severity: "error",
1999
+ span: portArg.value.span,
2000
+ });
2001
+ continue;
2002
+ }
2003
+
2004
+ const actualType = typeOf(shapeField.value, aliases);
2005
+ if (actualType.kind !== "money") {
2006
+ diagnostics.push({
2007
+ code: "HSX1104",
2008
+ fix: `pass money<${boundType.currency ?? "CUR"}> to ${shapeField.key.name}`,
2009
+ message: `${shapeField.key.name} needs money<${boundType.currency ?? "CUR"}> but receives ${argumentWords(shapeField.value, aliases)}`,
2010
+ severity: "error",
2011
+ span: shapeField.value.span,
2012
+ });
2013
+ } else if (
2014
+ actualType.currency &&
2015
+ boundType.currency &&
2016
+ actualType.currency !== boundType.currency
2017
+ ) {
2018
+ diagnostics.push({
2019
+ code: "HSX1101",
2020
+ fix: `pass money<${boundType.currency}> to ${shapeField.key.name}`,
2021
+ message: `${shapeField.key.name} needs money<${boundType.currency}> but receives money<${actualType.currency}>`,
2022
+ severity: "error",
2023
+ span: shapeField.value.span,
2024
+ });
2025
+ }
2026
+ }
2027
+ }
2028
+
1502
2029
  function bindPortCompileValues(
1503
2030
  values: Map<string, Expr>,
1504
2031
  binding: string,
1505
2032
  value: Extract<Expr, { readonly kind: "port_ref" }>,
1506
2033
  ports: ReadonlyMap<string, PortDecl>,
1507
2034
  diagnostics: GeneralDiagnostic[],
2035
+ aliases: ReadonlyMap<string, Expr> = new Map(),
2036
+ loweredPortShapes: Map<string, BlockExpr> = new Map(),
2037
+ dependencies: Map<string, readonly string[]> = new Map(),
1508
2038
  ): void {
1509
2039
  const port = ports.get(value.name.name);
1510
2040
  if (!port) {
@@ -1517,13 +2047,41 @@ function bindPortCompileValues(
1517
2047
  });
1518
2048
  return;
1519
2049
  }
1520
- const allowed = port ? entry(port.body, "allowed")?.value : undefined;
1521
- const shape = entry(port.body, "shape")?.value ?? {
1522
- entries: [],
1523
- kind: "block",
1524
- span: port.body.span,
1525
- };
1526
- if (allowed) values.set(`${binding}_allowed`, allowed);
2050
+ for (const key of [binding, `${binding}_allowed`, `${binding}_fields`]) {
2051
+ dependencies.set(key, [port.name.name]);
2052
+ }
2053
+ const allowed = entry(port.body, "allowed")?.value;
2054
+ let shape = loweredPortShapes.get(value.name.name);
2055
+ if (!shape) {
2056
+ const rawShape = entry(port.body, "shape")?.value ?? {
2057
+ entries: [],
2058
+ kind: "block",
2059
+ span: port.body.span,
2060
+ };
2061
+ const shapeDiagnostics: GeneralDiagnostic[] = [];
2062
+ shape = lowerPortShape(port, rawShape, aliases, shapeDiagnostics);
2063
+ for (const diagnostic of shapeDiagnostics) {
2064
+ if (
2065
+ !diagnostics.some(
2066
+ (prior) =>
2067
+ prior.code === diagnostic.code &&
2068
+ prior.span === diagnostic.span &&
2069
+ prior.message === diagnostic.message,
2070
+ )
2071
+ )
2072
+ diagnostics.push(diagnostic);
2073
+ }
2074
+ loweredPortShapes.set(value.name.name, shape);
2075
+ }
2076
+ const normalizedAllowed: Expr | undefined =
2077
+ allowed?.kind === "ident"
2078
+ ? {
2079
+ items: [allowed],
2080
+ kind: "list",
2081
+ span: allowed.span,
2082
+ }
2083
+ : allowed;
2084
+ if (normalizedAllowed) values.set(`${binding}_allowed`, normalizedAllowed);
1527
2085
  values.set(`${binding}_fields`, shape);
1528
2086
  if (value.within) values.set(`${binding}_within`, value.within);
1529
2087
  if (value.deadline) values.set(`${binding}_deadline`, value.deadline);
@@ -1759,17 +2317,65 @@ function booleanExpr(value: boolean, span: Span): Expr {
1759
2317
  return { kind: "boolean", span, value };
1760
2318
  }
1761
2319
 
2320
+ function selectedControls(value: Expr): readonly string[] {
2321
+ if (value.kind !== "block") return [];
2322
+ return value.entries.flatMap((row) => [
2323
+ ...(row.conditionPorts ?? []),
2324
+ ...selectedControls(row.value),
2325
+ ]);
2326
+ }
2327
+
2328
+ // Retain dependencies before compile-time evaluation erases identifiers.
2329
+ function expressionPorts(
2330
+ value: unknown,
2331
+ dependencies: ReadonlyMap<string, readonly string[]>,
2332
+ ): string[] {
2333
+ if (!value || typeof value !== "object") return [];
2334
+ if (Array.isArray(value))
2335
+ return [
2336
+ ...new Set(value.flatMap((item) => expressionPorts(item, dependencies))),
2337
+ ];
2338
+ const node = value as Record<string, unknown>;
2339
+ const found = new Set<string>(
2340
+ (node.conditionPorts as readonly string[] | undefined) ?? [],
2341
+ );
2342
+ if (node.kind === "ident" && typeof node.name === "string") {
2343
+ for (const port of dependencies.get(node.name.replace(/^\[|\]$/g, "")) ??
2344
+ [])
2345
+ found.add(port);
2346
+ }
2347
+ if (node.kind === "string" && typeof node.value === "string") {
2348
+ for (const match of node.value.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)) {
2349
+ for (const port of dependencies.get(match[1]!) ?? []) found.add(port);
2350
+ }
2351
+ }
2352
+ for (const [key, child] of Object.entries(node)) {
2353
+ if (key === "span" || key === "conditionPorts") continue;
2354
+ for (const port of expressionPorts(child, dependencies)) found.add(port);
2355
+ }
2356
+ return [...found];
2357
+ }
2358
+
1762
2359
  function selectCompileTimeRows(
1763
2360
  block: BlockExpr,
1764
2361
  values: ReadonlyMap<string, Expr>,
1765
2362
  diagnostics: GeneralDiagnostic[],
2363
+ dependencies: ReadonlyMap<string, readonly string[]> = new Map(),
2364
+ controls: readonly string[] = [],
1766
2365
  ): BlockExpr {
1767
2366
  const entries: Entry[] = [];
1768
2367
  const locals = new Map(values);
2368
+ const localDependencies = new Map(dependencies);
1769
2369
  for (const row of block.entries) {
1770
2370
  if (row.key.name === "let") {
1771
2371
  const binding = row.qualifiers[0];
1772
- if (binding) locals.set(binding.name, substituteExpr(row.value, locals));
2372
+ if (binding) {
2373
+ localDependencies.set(
2374
+ binding.name,
2375
+ expressionPorts(row.value, localDependencies),
2376
+ );
2377
+ locals.set(binding.name, substituteExpr(row.value, locals));
2378
+ }
1773
2379
  entries.push(row);
1774
2380
  continue;
1775
2381
  }
@@ -1809,13 +2415,53 @@ function selectCompileTimeRows(
1809
2415
  row.key.name !== "when_not" &&
1810
2416
  row.key.name !== "when_eq"
1811
2417
  ) {
1812
- entries.push({
1813
- ...row,
1814
- value:
1815
- row.value.kind === "block" && !row.iteration
1816
- ? selectCompileTimeRows(row.value, locals, diagnostics)
1817
- : row.value,
1818
- });
2418
+ const value =
2419
+ row.value.kind === "block" && !row.iteration
2420
+ ? selectCompileTimeRows(
2421
+ row.value,
2422
+ locals,
2423
+ diagnostics,
2424
+ localDependencies,
2425
+ controls,
2426
+ )
2427
+ : row.value;
2428
+ const conditionPorts =
2429
+ row.key.name === "action"
2430
+ ? [
2431
+ ...new Set([
2432
+ ...(value.kind === "block" && entry(value, "due")
2433
+ ? []
2434
+ : controls),
2435
+ ...row.qualifiers
2436
+ .filter((qualifier) => qualifier.name.startsWith("["))
2437
+ .flatMap((qualifier) =>
2438
+ expressionPorts(qualifier, localDependencies),
2439
+ ),
2440
+ ...expressionPorts(
2441
+ value.kind === "block"
2442
+ ? entry(value, "port")?.value
2443
+ : undefined,
2444
+ localDependencies,
2445
+ ),
2446
+ ...expressionPorts(
2447
+ value.kind === "block"
2448
+ ? entry(value, "input")?.value
2449
+ : undefined,
2450
+ localDependencies,
2451
+ ),
2452
+ ...expressionPorts(
2453
+ value.kind === "block"
2454
+ ? entry(value, "capture_input", "captureInput")?.value
2455
+ : undefined,
2456
+ localDependencies,
2457
+ ),
2458
+ ...(value.kind === "block" && entry(value, "due")
2459
+ ? []
2460
+ : selectedControls(value)),
2461
+ ]),
2462
+ ]
2463
+ : controls;
2464
+ entries.push({ ...row, conditionPorts, value });
1819
2465
  continue;
1820
2466
  }
1821
2467
  const subject = row.qualifiers[0];
@@ -1842,7 +2488,18 @@ function selectCompileTimeRows(
1842
2488
  }
1843
2489
  if (include) {
1844
2490
  entries.push(
1845
- ...selectCompileTimeRows(row.value, locals, diagnostics).entries,
2491
+ ...selectCompileTimeRows(
2492
+ row.value,
2493
+ locals,
2494
+ diagnostics,
2495
+ localDependencies,
2496
+ [
2497
+ ...new Set([
2498
+ ...controls,
2499
+ ...(subject ? expressionPorts(subject, localDependencies) : []),
2500
+ ]),
2501
+ ],
2502
+ ).entries,
1846
2503
  );
1847
2504
  }
1848
2505
  }
@@ -2104,14 +2761,21 @@ function nameText(value: Expr): string {
2104
2761
  function expandComprehensions(
2105
2762
  block: BlockExpr,
2106
2763
  diagnostics: GeneralDiagnostic[],
2764
+ values: ReadonlyMap<string, Expr> = new Map(),
2765
+ dependencies: ReadonlyMap<string, readonly string[]> = new Map(),
2107
2766
  ): BlockExpr {
2108
2767
  const budget = { used: 0 };
2109
2768
  const expand = (
2110
2769
  current: BlockExpr,
2111
2770
  inherited: ReadonlyMap<string, Expr> = new Map(),
2771
+ inheritedDependencies: ReadonlyMap<
2772
+ string,
2773
+ readonly string[]
2774
+ > = dependencies,
2112
2775
  ): BlockExpr => {
2113
2776
  const entries: Entry[] = [];
2114
2777
  const locals = new Map(inherited);
2778
+ const localDependencies = new Map(inheritedDependencies);
2115
2779
  for (const row of current.entries) {
2116
2780
  if (row.key.name === "let") {
2117
2781
  const binding = row.qualifiers[0];
@@ -2125,6 +2789,10 @@ function expandComprehensions(
2125
2789
  });
2126
2790
  continue;
2127
2791
  }
2792
+ localDependencies.set(
2793
+ binding.name,
2794
+ expressionPorts(row.value, localDependencies),
2795
+ );
2128
2796
  locals.set(binding.name, substituteExpr(row.value, locals));
2129
2797
  continue;
2130
2798
  }
@@ -2142,7 +2810,7 @@ function expandComprehensions(
2142
2810
  : compileTimeTruthy(actual);
2143
2811
  if (row.key.name === "when_not") include = !include;
2144
2812
  if (include && row.value.kind === "block") {
2145
- entries.push(...expand(row.value, locals).entries);
2813
+ entries.push(...expand(row.value, locals, localDependencies).entries);
2146
2814
  }
2147
2815
  continue;
2148
2816
  }
@@ -2162,8 +2830,8 @@ function expandComprehensions(
2162
2830
  entries.push({
2163
2831
  ...substituted,
2164
2832
  value:
2165
- substituted.value.kind === "block"
2166
- ? expand(substituted.value, locals)
2833
+ row.value.kind === "block"
2834
+ ? expand(row.value, locals, localDependencies)
2167
2835
  : substituted.value,
2168
2836
  });
2169
2837
  continue;
@@ -2197,14 +2865,19 @@ function expandComprehensions(
2197
2865
  ...locals,
2198
2866
  [row.iteration.binding.name, value],
2199
2867
  ]);
2200
- const body = substituteExpr(row.value, substitutions);
2201
- if (body.kind === "block")
2202
- entries.push(...expand(body, substitutions).entries);
2868
+ const body = selectCompileTimeRows(
2869
+ row.value,
2870
+ substitutions,
2871
+ diagnostics,
2872
+ localDependencies,
2873
+ row.conditionPorts,
2874
+ );
2875
+ entries.push(...expand(body, substitutions, localDependencies).entries);
2203
2876
  }
2204
2877
  }
2205
2878
  return { ...current, entries };
2206
2879
  };
2207
- return expand(block);
2880
+ return expand(block, values);
2208
2881
  }
2209
2882
 
2210
2883
  function finiteIterationValues(bound: Expr): readonly Expr[] | undefined {
@@ -2216,20 +2889,18 @@ function finiteIterationValues(bound: Expr): readonly Expr[] | undefined {
2216
2889
  if (bound.kind !== "number" || bound.raw.includes(".")) return undefined;
2217
2890
  const count = Number(bound.raw);
2218
2891
  if (!Number.isSafeInteger(count) || count < 0) return undefined;
2219
- return Array.from(
2220
- { length: count },
2221
- (_, index): Expr => ({
2222
- kind: "number",
2223
- raw: String(index + 1),
2224
- span: bound.span,
2225
- }),
2226
- );
2892
+ return Array.from({ length: count }, (_, index): Expr => ({
2893
+ kind: "number",
2894
+ raw: String(index + 1),
2895
+ span: bound.span,
2896
+ }));
2227
2897
  }
2228
2898
 
2229
2899
  function checkInstrument(
2230
2900
  name: IdentExpr,
2231
2901
  body: BlockExpr,
2232
2902
  parties: ReadonlySet<string>,
2903
+ ports: ReadonlyMap<string, readonly PortDecl[]>,
2233
2904
  aliases: ReadonlyMap<string, Expr>,
2234
2905
  diagnostics: GeneralDiagnostic[],
2235
2906
  ): TypedInstrument | undefined {
@@ -2283,12 +2954,66 @@ function checkInstrument(
2283
2954
  }
2284
2955
  const slots: Record<string, JsonValue> = {};
2285
2956
  const partiesEntry = entry(body, "parties");
2286
- if (partiesEntry?.value.kind === "block") {
2287
- const roleObject = blockToObject(
2288
- partiesEntry.value,
2289
- new Map(),
2290
- diagnostics,
2291
- );
2957
+ const roleObject = blockToObject(
2958
+ partiesEntry?.value.kind === "block" ? partiesEntry.value : undefined,
2959
+ new Map(),
2960
+ diagnostics,
2961
+ );
2962
+ const partyAccounts = new Map(
2963
+ [...parties].map((party) => [party, `${camel(party)}AccountId`]),
2964
+ );
2965
+ for (const [role, binding] of Object.entries(roleObject)) {
2966
+ if (!isJsonObject(binding)) continue;
2967
+ if (
2968
+ typeof binding.party !== "string" ||
2969
+ !parties.has(binding.party) ||
2970
+ typeof binding.account !== "string" ||
2971
+ !endpointFields.has(binding.account)
2972
+ ) {
2973
+ diagnostics.push({
2974
+ code: "HSX1024",
2975
+ fix: "bind a declared party to a declared account field",
2976
+ message: `party role ${role} has an invalid party/account binding`,
2977
+ severity: "error",
2978
+ span: partiesEntry?.span ?? name.span,
2979
+ });
2980
+ continue;
2981
+ }
2982
+ partyAccounts.set(binding.party, binding.account);
2983
+ roleObject[role] = binding.account;
2984
+ }
2985
+ const decisionRoles = new Map<string, string>();
2986
+ const inferredDecisionFields = new Set<string>();
2987
+ // Decision authority needs an identity binding, even when no money moves
2988
+ // through that party. Reuse the same account binding as economic roles.
2989
+ for (const action of body.entries) {
2990
+ if (action.key.name !== "action" || action.value.kind !== "block") continue;
2991
+ const port = entry(action.value, "port")?.value;
2992
+ if (port?.kind !== "block") continue;
2993
+ const allowed = entry(port, "allowed_parties", "allowedParties")?.value;
2994
+ if (allowed?.kind !== "list") continue;
2995
+ for (const item of allowed.items) {
2996
+ const party = nameText(item);
2997
+ if (!parties.has(party)) continue;
2998
+ const accountField = partyAccounts.get(party)!;
2999
+ const existing = Object.entries(roleObject).find(
3000
+ ([, value]) => value === party || value === accountField,
3001
+ );
3002
+ let role = existing?.[0] ?? party;
3003
+ for (
3004
+ let suffix = 2;
3005
+ !existing && Object.hasOwn(roleObject, role);
3006
+ suffix++
3007
+ )
3008
+ role = `${party}_${suffix}`;
3009
+ if (!existing) {
3010
+ roleObject[role] = party;
3011
+ inferredDecisionFields.add(accountField);
3012
+ }
3013
+ decisionRoles.set(party, role);
3014
+ }
3015
+ }
3016
+ if (Object.keys(roleObject).length > 0) {
2292
3017
  for (const [role, value] of Object.entries(roleObject)) {
2293
3018
  if (typeof value !== "string") continue;
2294
3019
  if (fieldNames.has(value)) {
@@ -2304,11 +3029,11 @@ function checkInstrument(
2304
3029
  if (!fieldNames.has(field)) {
2305
3030
  fields.push({
2306
3031
  name: field,
2307
- origin: partiesEntry.span,
3032
+ origin: partiesEntry?.span ?? name.span,
2308
3033
  required: true,
2309
3034
  schema: {
2310
3035
  description: `The ${value.replaceAll("_", " ")} account`,
2311
- pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
3036
+ pattern: ACCOUNT_PATTERN,
2312
3037
  type: "string",
2313
3038
  "x-hyperscale-reference-filter": {
2314
3039
  column: "role",
@@ -2327,8 +3052,12 @@ function checkInstrument(
2327
3052
  message: `party role refers to ${String(value)}, which is not declared`,
2328
3053
  severity: "error",
2329
3054
  span:
2330
- partiesEntry.value.entries.find((entry) => entry.key.name === role)
2331
- ?.value.span ?? partiesEntry.span,
3055
+ (partiesEntry?.value.kind === "block"
3056
+ ? partiesEntry.value.entries
3057
+ : []
3058
+ ).find((entry) => entry.key.name === role)?.value.span ??
3059
+ partiesEntry?.span ??
3060
+ name.span,
2332
3061
  });
2333
3062
  }
2334
3063
  slots.parties = roleObject;
@@ -2370,7 +3099,7 @@ function checkInstrument(
2370
3099
  origin: actionEntry.span,
2371
3100
  required: true,
2372
3101
  schema: {
2373
- pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
3102
+ pattern: ACCOUNT_PATTERN,
2374
3103
  type: "string",
2375
3104
  },
2376
3105
  type: { kind: "account" },
@@ -2380,21 +3109,121 @@ function checkInstrument(
2380
3109
  }
2381
3110
  }
2382
3111
  }
2383
- const actions = actionEntries.flatMap((candidate) => {
2384
- const actionName = candidate.qualifiers[0];
2385
- if (!actionName || candidate.value.kind !== "block") return [];
2386
- return [
2387
- checkAction(
2388
- actionName,
2389
- candidate.value,
2390
- fields,
2391
- name.name,
2392
- endpointFields,
2393
- holdDestinations,
2394
- diagnostics,
2395
- ),
2396
- ];
2397
- });
3112
+ const actions = actionEntries
3113
+ .flatMap((candidate) => {
3114
+ const actionName = candidate.qualifiers[0];
3115
+ if (!actionName || candidate.value.kind !== "block") return [];
3116
+ return [
3117
+ checkAction(
3118
+ actionName,
3119
+ candidate.value,
3120
+ fields,
3121
+ name.name,
3122
+ endpointFields,
3123
+ holdDestinations,
3124
+ diagnostics,
3125
+ ),
3126
+ ];
3127
+ })
3128
+ .map((action) => {
3129
+ const port = action.slots.port;
3130
+ if (
3131
+ port === undefined ||
3132
+ !isJsonObject(port) ||
3133
+ !Array.isArray(port.allowedParties)
3134
+ )
3135
+ return action;
3136
+ return {
3137
+ ...action,
3138
+ slots: {
3139
+ ...action.slots,
3140
+ port: {
3141
+ ...port,
3142
+ allowedParties: port.allowedParties.map((party) =>
3143
+ typeof party === "string"
3144
+ ? (decisionRoles.get(party) ?? party)
3145
+ : party,
3146
+ ),
3147
+ },
3148
+ },
3149
+ };
3150
+ });
3151
+ for (const action of actions) {
3152
+ for (const declared of ports.get(action.name) ?? []) {
3153
+ const allowed = entry(declared.body, "allowed")?.value;
3154
+ const allowedItems =
3155
+ allowed?.kind === "list" ? allowed.items : allowed ? [allowed] : [];
3156
+ if (allowedItems.length === 0) {
3157
+ diagnostics.push({
3158
+ code: "HSX1024",
3159
+ fix: `declare a nonempty allowed list of declared parties on port ${declared.name.name}`,
3160
+ message: `wired decision port ${declared.name.name} reaches action ${action.name} without declared allowed parties`,
3161
+ severity: "error",
3162
+ span: declared.name.span,
3163
+ });
3164
+ continue;
3165
+ }
3166
+ const port = action.slots.port;
3167
+ if (
3168
+ port === undefined ||
3169
+ !isJsonObject(port) ||
3170
+ !Array.isArray(port.allowedParties)
3171
+ ) {
3172
+ diagnostics.push({
3173
+ code: "HSX1024",
3174
+ fix: `emit the allowed parties of ${declared.name.name} in action ${action.name}'s port`,
3175
+ message: `wired decision port ${declared.name.name} reaches action ${action.name} without a decision port`,
3176
+ severity: "error",
3177
+ span: action.origin,
3178
+ });
3179
+ continue;
3180
+ }
3181
+ const allowedFields = new Set<string>();
3182
+ for (const item of allowedItems) {
3183
+ const party = nameText(item);
3184
+ const role =
3185
+ decisionRoles.get(party) ??
3186
+ Object.keys(roleObject).find(
3187
+ (role) =>
3188
+ roleObject[role] ===
3189
+ (partyAccounts.get(party) ?? `${camel(party)}AccountId`),
3190
+ );
3191
+ const field = role === undefined ? undefined : roleObject[role];
3192
+ if (typeof field === "string") {
3193
+ allowedFields.add(field);
3194
+ continue;
3195
+ }
3196
+ if (!parties.has(party)) continue;
3197
+ diagnostics.push({
3198
+ code: "HSX1024",
3199
+ fix: `bind ${party} to an account field and use that binding in action ${action.name}'s port`,
3200
+ message: `decision port ${action.name} allows party ${party}, but settlement ${name.name} does not bind that party to the decision action`,
3201
+ severity: "error",
3202
+ span: item.span,
3203
+ });
3204
+ }
3205
+ if (
3206
+ port.allowedParties.length === 0 ||
3207
+ allowedFields.size !==
3208
+ new Set(
3209
+ port.allowedParties.map((role) => String(roleObject[String(role)])),
3210
+ ).size ||
3211
+ port.allowedParties.some(
3212
+ (role) =>
3213
+ typeof role !== "string" ||
3214
+ !allowedFields.has(String(roleObject[role])),
3215
+ )
3216
+ ) {
3217
+ diagnostics.push({
3218
+ code: "HSX1024",
3219
+ fix: `use only the account bindings allowed by ${declared.name.name}`,
3220
+ message: `action ${action.name} does not preserve the allowed parties of decision port ${declared.name.name}`,
3221
+ severity: "error",
3222
+ span: action.origin,
3223
+ });
3224
+ }
3225
+ }
3226
+ }
2398
3227
  if (!actionNames.has("create")) {
2399
3228
  diagnostics.push({
2400
3229
  code: "HSX1504",
@@ -2479,10 +3308,49 @@ function checkInstrument(
2479
3308
  slots.summary ??= `${titleize(name.name)} instrument`;
2480
3309
  slots.title ??= titleize(name.name);
2481
3310
  linearityDiagnostics(actionEntries, diagnostics);
3311
+ agentDescriptionDiagnostics(name, slots, actions, diagnostics);
3312
+ if (Array.isArray(slots.required)) {
3313
+ slots.required = [
3314
+ ...new Set([...slots.required, ...inferredDecisionFields]),
3315
+ ];
3316
+ }
2482
3317
 
2483
3318
  return { actions, fields, id: name.name, origin: name.span, slots };
2484
3319
  }
2485
3320
 
3321
+ // Mirrors the catalog law agent_description_required: every action a caller
3322
+ // can reach becomes an agent tool whose description is authored, never
3323
+ // synthesized from the human-facing summary. System actions with a due clause
3324
+ // are exempt because no caller can reach them.
3325
+ function agentDescriptionDiagnostics(
3326
+ name: IdentExpr,
3327
+ slots: Readonly<Record<string, JsonValue>>,
3328
+ actions: readonly TypedAction[],
3329
+ diagnostics: GeneralDiagnostic[],
3330
+ ): void {
3331
+ const callable = actions.filter((action) => action.slots.due === undefined);
3332
+ if (callable.length === 0) return;
3333
+ if (slots.agentDescription === undefined) {
3334
+ diagnostics.push({
3335
+ code: "HSX1509",
3336
+ fix: 'add agent_description: "..." telling an agent when to reach for this instrument',
3337
+ message: `instrument ${name.name} exposes callable actions without an agent_description`,
3338
+ severity: "error",
3339
+ span: name.span,
3340
+ });
3341
+ }
3342
+ for (const action of callable) {
3343
+ if (action.slots.agentDescription !== undefined) continue;
3344
+ diagnostics.push({
3345
+ code: "HSX1509",
3346
+ fix: 'add agent_description: "..." telling an agent when to call this action',
3347
+ message: `action ${action.name} of ${name.name} is callable without an agent_description`,
3348
+ severity: "error",
3349
+ span: action.origin,
3350
+ });
3351
+ }
3352
+ }
3353
+
2486
3354
  function checkRequiredFields(
2487
3355
  value: JsonValue,
2488
3356
  fields: readonly TypedField[],
@@ -2576,71 +3444,93 @@ function checkFields(
2576
3444
  continue;
2577
3445
  }
2578
3446
  names.add(name);
2579
- let typeExpr = row.value;
2580
- let description: string | undefined;
2581
- let required = true;
2582
- let extra: Record<string, JsonValue> = {};
2583
- if (row.value.kind === "block") {
2584
- const typeRow = entry(row.value, "type");
2585
- if (typeRow) typeExpr = typeRow.value;
2586
- const desc = entry(row.value, "description") ?? entry(row.value, "desc");
2587
- if (desc?.value.kind === "string") description = desc.value.value;
2588
- const optional = entry(row.value, "optional");
2589
- if (optional?.value.kind === "boolean") required = !optional.value.value;
2590
- extra = blockToObject(
2591
- {
2592
- ...row.value,
2593
- entries: row.value.entries.filter(
2594
- (item) =>
2595
- !["type", "description", "desc", "optional"].includes(
2596
- item.key.name,
2597
- ),
2598
- ),
2599
- },
2600
- new Map(),
2601
- diagnostics,
2602
- );
2603
- }
2604
- const type = typeOf(typeExpr, aliases);
2605
- const fixedMoneyIssue = invalidFixedMoneyBinding(typeExpr);
2606
- if (fixedMoneyIssue) {
2607
- diagnostics.push({
2608
- code: "HSX1103",
2609
- fix: `write ${name} as money(SAR, 2500) with a positive safe integer in minor units`,
2610
- message: `field ${name} ${fixedMoneyIssue}`,
2611
- severity: "error",
2612
- span: typeExpr.span,
2613
- });
2614
- }
2615
- if (
2616
- (typeExpr.kind === "ident" && typeExpr.name === "ref") ||
2617
- (type.kind === "ref" && !type.target)
2618
- ) {
2619
- diagnostics.push({
2620
- code: "HSX1007",
2621
- fix: "declare the target instrument as ref<instrument_id>",
2622
- message: `field ${name} declares ref without an instrument target`,
2623
- severity: "error",
2624
- span: typeExpr.span,
2625
- });
2626
- }
2627
- const structuralType = Object.hasOwn(extra, "items")
2628
- ? "array"
2629
- : Object.hasOwn(extra, "properties") ||
2630
- Object.hasOwn(extra, "additionalProperties")
2631
- ? "object"
2632
- : undefined;
2633
- const schema = {
2634
- ...schemaFor(type, required),
2635
- ...extra,
2636
- ...(structuralType ? { type: structuralType } : {}),
2637
- ...(description ? { description } : {}),
2638
- };
2639
- result.push({ name, origin: row.span, required, schema, type });
3447
+ result.push(lowerField(row, aliases, diagnostics));
2640
3448
  }
2641
3449
  return result;
2642
3450
  }
2643
3451
 
3452
+ function lowerField(
3453
+ row: Entry,
3454
+ aliases: ReadonlyMap<string, Expr>,
3455
+ diagnostics: GeneralDiagnostic[],
3456
+ ): TypedField {
3457
+ const name = camel(row.key.name);
3458
+ let typeExpr = row.value;
3459
+ let description: string | undefined;
3460
+ let required = true;
3461
+ let extra: Record<string, JsonValue> = {};
3462
+ if (row.value.kind === "block") {
3463
+ const typeRow = entry(row.value, "type");
3464
+ if (typeRow) typeExpr = typeRow.value;
3465
+ const desc = entry(row.value, "description") ?? entry(row.value, "desc");
3466
+ if (desc?.value.kind === "string") description = desc.value.value;
3467
+ const optional = entry(row.value, "optional");
3468
+ if (optional?.value.kind === "boolean") required = !optional.value.value;
3469
+ extra = blockToObject(
3470
+ {
3471
+ ...row.value,
3472
+ entries: row.value.entries.filter(
3473
+ (item) =>
3474
+ !["type", "description", "desc", "optional"].includes(
3475
+ item.key.name,
3476
+ ),
3477
+ ),
3478
+ },
3479
+ new Map(),
3480
+ diagnostics,
3481
+ );
3482
+ }
3483
+ const type = typeOf(typeExpr, aliases);
3484
+ // Diagnostics point at the field's own type expression, not at an alias
3485
+ // declaration that may live in an imported module.
3486
+ const declaredSpan = typeExpr.span;
3487
+ const seen = new Set<string>();
3488
+ while (
3489
+ typeExpr.kind === "ident" &&
3490
+ aliases.has(typeExpr.name) &&
3491
+ !seen.has(typeExpr.name)
3492
+ ) {
3493
+ seen.add(typeExpr.name);
3494
+ typeExpr = aliases.get(typeExpr.name)!;
3495
+ }
3496
+ const fixedMoneyIssue = invalidFixedMoneyBinding(typeExpr);
3497
+ if (fixedMoneyIssue) {
3498
+ diagnostics.push({
3499
+ code: "HSX1103",
3500
+ fix: `write ${name} as money(SAR, 2500) with a positive safe integer in minor units`,
3501
+ message: `field ${name} ${fixedMoneyIssue}`,
3502
+ severity: "error",
3503
+ span: declaredSpan,
3504
+ });
3505
+ }
3506
+ if (
3507
+ (typeExpr.kind === "ident" && typeExpr.name === "ref") ||
3508
+ (type.kind === "ref" && !type.target)
3509
+ ) {
3510
+ diagnostics.push({
3511
+ code: "HSX1007",
3512
+ fix: "declare the target instrument as ref<instrument_id>",
3513
+ message: `field ${name} declares ref without an instrument target`,
3514
+ severity: "error",
3515
+ span: declaredSpan,
3516
+ });
3517
+ }
3518
+
3519
+ const structuralType = Object.hasOwn(extra, "items")
3520
+ ? "array"
3521
+ : Object.hasOwn(extra, "properties") ||
3522
+ Object.hasOwn(extra, "additionalProperties")
3523
+ ? "object"
3524
+ : undefined;
3525
+ const schema = {
3526
+ ...schemaFor(type, required),
3527
+ ...extra,
3528
+ ...(structuralType ? { type: structuralType } : {}),
3529
+ ...(description ? { description } : {}),
3530
+ };
3531
+ return { name, origin: row.span, required, schema, type };
3532
+ }
3533
+
2644
3534
  function checkLifecycle(
2645
3535
  instrument: string,
2646
3536
  block: BlockExpr,
@@ -2775,6 +3665,19 @@ function checkAction(
2775
3665
  continue;
2776
3666
  }
2777
3667
  if (definition?.scope === "action") {
3668
+ if (definition.target === "port" && row.value.kind === "block") {
3669
+ const allowedEntry = entry(row.value, "allowed");
3670
+ if (allowedEntry) {
3671
+ diagnostics.push({
3672
+ code: "HSX1508",
3673
+ fix: "Use allowed_parties: [...] inside an action-level port clause; allowed: is for top-level port declarations.",
3674
+ message:
3675
+ "action-level port clause uses allowed: instead of allowed_parties:",
3676
+ severity: "error",
3677
+ span: allowedEntry.key.span,
3678
+ });
3679
+ }
3680
+ }
2778
3681
  const rawValue = exprToJsonForUdlSlot(
2779
3682
  row.value,
2780
3683
  definition.target,
@@ -3109,10 +4012,22 @@ function crossInstrumentReferenceDiagnostics(
3109
4012
  );
3110
4013
  }
3111
4014
  }
4015
+ const parentField = invariant.parentField;
4016
+ const parent =
4017
+ typeof parentField === "string"
4018
+ ? fieldIn(owner, parentField)
4019
+ : undefined;
3112
4020
  const childField = invariant.childField;
4021
+ const childFieldDef =
4022
+ typeof childField === "string" ? fieldIn(child, childField) : undefined;
4023
+ const isIntegerSum =
4024
+ invariant.count !== true &&
4025
+ parent?.type.kind === "integer" &&
4026
+ childFieldDef?.type.kind === "integer";
3113
4027
  if (
3114
4028
  typeof childField === "string" &&
3115
- fieldIn(child, childField)?.type.kind !== "money"
4029
+ !isIntegerSum &&
4030
+ childFieldDef?.type.kind !== "money"
3116
4031
  ) {
3117
4032
  report(
3118
4033
  owner,
@@ -3134,19 +4049,15 @@ function crossInstrumentReferenceDiagnostics(
3134
4049
  );
3135
4050
  }
3136
4051
  }
3137
- const parentField = invariant.parentField;
3138
- const parent =
3139
- typeof parentField === "string"
3140
- ? fieldIn(owner, parentField)
3141
- : undefined;
3142
4052
  if (
3143
4053
  typeof parentField === "string" &&
3144
4054
  (!parent ||
3145
- (invariant.count === true
4055
+ (invariant.count === true || isIntegerSum
3146
4056
  ? parent.type.kind !== "integer"
3147
4057
  : parent.type.kind !== "money"))
3148
4058
  ) {
3149
- const expected = invariant.count === true ? "integer" : "money<C>";
4059
+ const expected =
4060
+ invariant.count === true || isIntegerSum ? "integer" : "money<C>";
3150
4061
  report(
3151
4062
  owner,
3152
4063
  owner.origin,
@@ -3792,16 +4703,15 @@ function schemaFor(
3792
4703
  ): Record<string, JsonValue> {
3793
4704
  switch (type.kind) {
3794
4705
  case "money":
3795
- return type.fixedAmount
3796
- ? {
3797
- const: type.fixedAmount,
3798
- pattern: MONEY_PATTERN,
3799
- type: "string",
3800
- }
3801
- : {
3802
- pattern: required ? MONEY_PATTERN : OPTIONAL_MONEY_PATTERN,
3803
- type: "string",
3804
- };
4706
+ return {
4707
+ ...(type.fixedAmount
4708
+ ? { const: type.fixedAmount, pattern: MONEY_PATTERN }
4709
+ : { pattern: required ? MONEY_PATTERN : OPTIONAL_MONEY_PATTERN }),
4710
+ type: "string",
4711
+ ...(type.currency && CURRENCY.test(type.currency)
4712
+ ? { "x-hyperscale-currency": type.currency }
4713
+ : {}),
4714
+ };
3805
4715
  case "bps":
3806
4716
  case "percent":
3807
4717
  return { maximum: 10_000, minimum: 0, type: "integer" };
@@ -3813,6 +4723,8 @@ function schemaFor(
3813
4723
  return { format: "hyperscale-date-time", type: "string" };
3814
4724
  case "ref":
3815
4725
  return { type: "string" };
4726
+ case "account":
4727
+ return { pattern: ACCOUNT_PATTERN, type: "string" };
3816
4728
  default:
3817
4729
  return { type: "string" };
3818
4730
  }