@hyperscale0/hsx 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/typecheck.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  deriveUdlActionEffects,
3
3
  udlClauseVocabulary,
4
+ type UdlAction,
4
5
  type UdlDocument,
5
6
  type UdlInstrument,
6
7
  } from "@hyperscale0/udl";
@@ -54,6 +55,7 @@ type ClauseDefinition = (typeof udlClauseVocabulary)[number];
54
55
  interface ConcreteInstrument {
55
56
  readonly aliases: ReadonlyMap<string, Expr>;
56
57
  readonly body: BlockExpr;
58
+ readonly callee?: string;
57
59
  readonly generatedPrefix: boolean;
58
60
  readonly name: IdentExpr;
59
61
  readonly parties: ReadonlySet<string>;
@@ -350,6 +352,7 @@ export function checkGeneralProgram(
350
352
  ).map((candidate) => ({
351
353
  ...candidate,
352
354
  aliases: scoped.aliases,
355
+ callee: candidate.name.name,
353
356
  parties: scoped.parties,
354
357
  })),
355
358
  );
@@ -397,11 +400,21 @@ export function checkGeneralProgram(
397
400
  constructedInstruments(decl.name, merged, diagnostics),
398
401
  boundPorts,
399
402
  applicationPorts,
400
- ).map((candidate) => ({
401
- ...candidate,
402
- aliases: scoped.aliases,
403
- parties: scoped.parties,
404
- })),
403
+ ).map((candidate) => {
404
+ let candidateCallee = callee;
405
+ if (
406
+ candidate.name.name !== decl.name.name &&
407
+ candidate.name.name.startsWith(`${decl.name.name}_`)
408
+ ) {
409
+ candidateCallee = `${callee}${candidate.name.name.slice(decl.name.name.length)}`;
410
+ }
411
+ return {
412
+ ...candidate,
413
+ aliases: scoped.aliases,
414
+ callee: candidateCallee,
415
+ parties: scoped.parties,
416
+ };
417
+ }),
405
418
  );
406
419
  for (const port of boundPorts.values()) {
407
420
  if (applicationPorts.has(port)) reachedPorts.add(port);
@@ -422,6 +435,15 @@ export function checkGeneralProgram(
422
435
 
423
436
  const allocated = allocateGeneratedPrefixes(concrete);
424
437
 
438
+ if (options.publishedCatalog) {
439
+ checkPortCaptureTypes(
440
+ allocated,
441
+ options.publishedCatalog,
442
+ aliases,
443
+ diagnostics,
444
+ );
445
+ }
446
+
425
447
  const instruments: TypedInstrument[] = [];
426
448
  const ids = new Set<string>();
427
449
  for (const candidate of allocated) {
@@ -913,6 +935,149 @@ function publishedFieldType(
913
935
  return target ? { kind: "ref", target: target.id } : { kind: "text" };
914
936
  }
915
937
 
938
+ const STRING_BACKED_PORT_KINDS: ReadonlySet<HsxType["kind"]> = new Set([
939
+ "account",
940
+ "condition",
941
+ "date",
942
+ "party",
943
+ "ref",
944
+ "text",
945
+ ]);
946
+ const INTEGER_BACKED_PORT_KINDS: ReadonlySet<HsxType["kind"]> = new Set([
947
+ "bps",
948
+ "integer",
949
+ "percent",
950
+ ]);
951
+
952
+ /**
953
+ * A published schema keeps less than the HSX type: dates and accounts publish
954
+ * as plain strings, percents and bps as integers. Compare by the family the
955
+ * schema can still express so the check never rejects a richer declared type.
956
+ */
957
+ function samePortType(declared: HsxType, captured: HsxType): boolean {
958
+ if (captured.kind === "unknown" || declared.kind === "unknown") return true;
959
+ if (captured.kind === "text")
960
+ return STRING_BACKED_PORT_KINDS.has(declared.kind);
961
+ if (captured.kind === "integer") {
962
+ return INTEGER_BACKED_PORT_KINDS.has(declared.kind);
963
+ }
964
+ if (declared.kind !== captured.kind) return false;
965
+ if (captured.currency && declared.currency !== captured.currency) {
966
+ return false;
967
+ }
968
+ if (captured.target && declared.target !== captured.target) {
969
+ return false;
970
+ }
971
+ return true;
972
+ }
973
+
974
+ function portTypeWords(type: HsxType): string {
975
+ if (type.kind === "ref" && type.target) return `ref<${type.target}>`;
976
+ return typeWords(type);
977
+ }
978
+
979
+ function getFieldSchemaFromAction(
980
+ action: UdlAction,
981
+ fieldName: string,
982
+ ): Readonly<Record<string, JsonValue>> | undefined {
983
+ if (!action.captureInput) return undefined;
984
+ const properties = action.input?.properties as
985
+ | Record<string, Record<string, JsonValue>>
986
+ | undefined;
987
+ if (!properties) return undefined;
988
+
989
+ for (const inputKey of Object.values(action.captureInput)) {
990
+ if (inputKey !== fieldName && camel(inputKey) !== camel(fieldName)) {
991
+ continue;
992
+ }
993
+ const schema = properties[inputKey] ?? properties[camel(inputKey)];
994
+ if (schema && typeof schema === "object") return schema;
995
+ }
996
+
997
+ return undefined;
998
+ }
999
+
1000
+ function findCapturedFieldSchema(
1001
+ catalogInstrument: UdlInstrument,
1002
+ actionName: string,
1003
+ fieldName: string,
1004
+ ): Readonly<Record<string, JsonValue>> | undefined {
1005
+ const targetAction = catalogInstrument.actions[actionName];
1006
+ return targetAction
1007
+ ? getFieldSchemaFromAction(targetAction, fieldName)
1008
+ : undefined;
1009
+ }
1010
+
1011
+ function checkPortCaptureTypes(
1012
+ candidates: readonly ConcreteInstrument[],
1013
+ publishedCatalog: UdlDocument,
1014
+ topLevelAliases: ReadonlyMap<string, Expr>,
1015
+ diagnostics: GeneralDiagnostic[],
1016
+ ): void {
1017
+ const catalogById = new Map<string, UdlInstrument>(
1018
+ publishedCatalog.instruments.map((instrument) => [
1019
+ instrument.id,
1020
+ instrument,
1021
+ ]),
1022
+ );
1023
+
1024
+ for (const candidate of candidates) {
1025
+ const catalogInstrument =
1026
+ catalogById.get(candidate.name.name) ??
1027
+ (candidate.callee ? catalogById.get(candidate.callee) : undefined);
1028
+ if (!catalogInstrument) continue;
1029
+
1030
+ const aliases = new Map([...topLevelAliases, ...candidate.aliases]);
1031
+
1032
+ for (const [actionName, declaredPorts] of candidate.ports) {
1033
+ for (const port of declaredPorts) {
1034
+ const rawShape = entry(port.body, "shape")?.value;
1035
+ if (rawShape?.kind !== "block") continue;
1036
+
1037
+ for (const fieldEntry of rawShape.entries) {
1038
+ const fieldName = fieldEntry.key.name;
1039
+ const capturedSchema = findCapturedFieldSchema(
1040
+ catalogInstrument,
1041
+ actionName,
1042
+ fieldName,
1043
+ );
1044
+ if (!capturedSchema) continue;
1045
+
1046
+ const capturedType = publishedFieldType(
1047
+ capturedSchema,
1048
+ publishedCatalog.instruments,
1049
+ );
1050
+ const declaredType = typeOf(fieldEntry.value, aliases);
1051
+
1052
+ if (!samePortType(declaredType, capturedType)) {
1053
+ const declaredWords = portTypeWords(declaredType);
1054
+ const capturedWords = portTypeWords(capturedType);
1055
+ const message = `decision port ${port.name.name} field ${fieldName} declares ${declaredWords} but instrument ${catalogInstrument.id} captures it as ${capturedWords}`;
1056
+ const fix = `declare ${fieldName} as ${capturedWords} in decision port ${port.name.name}`;
1057
+ if (
1058
+ !diagnostics.some(
1059
+ (prior) =>
1060
+ prior.code === "HSX1026" &&
1061
+ prior.span.start === fieldEntry.value.span.start &&
1062
+ prior.span.end === fieldEntry.value.span.end &&
1063
+ prior.message === message,
1064
+ )
1065
+ ) {
1066
+ diagnostics.push({
1067
+ code: "HSX1026",
1068
+ fix,
1069
+ message,
1070
+ severity: "error",
1071
+ span: fieldEntry.value.span,
1072
+ });
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+
916
1081
  function typedPublishedSubject(
917
1082
  subject: UdlDocument["subjects"][number],
918
1083
  origin: Span,
@@ -39,6 +39,7 @@ module std.money_flows.held_payment
39
39
  // - `cancel_charge_bps`: Optional cancellation charge in basis points. Declaring it gives the settlement a
40
40
  // quoted cancellation: `quote_cancellation` prices the charge and the refund and freezes both,
41
41
  // `cancel` pays the refund to the payer, and `retain_cancellation_charge` pays the charge to the payee.
42
+ // A zero charge keeps the flow with a zero fee.
42
43
  // - `cancel_offer_life`: ISO 8601 duration a cancellation quote stays open, required with `cancel_charge_bps`.
43
44
  //
44
45
  // ### Decision ports
@@ -70,6 +71,8 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
70
71
  let(payee_fee): get(fees, payee);
71
72
  let(payer_fee_kind): kind(payer_fee);
72
73
  let(payee_fee_kind): kind(payee_fee);
74
+ let(has_cancel_quote): if_eq(kind(cancel_charge_bps), "boolean", false, true);
75
+ let(has_charge): if_eq(cancel_charge_bps, 0, false, true);
73
76
  when_eq(payee_fee_kind, binding) {
74
77
  when(on_cancel) {
75
78
  unsupported {
@@ -79,7 +82,7 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
79
82
  }
80
83
  }
81
84
  }
82
- when(cancel_charge_bps) {
85
+ when(has_cancel_quote) {
83
86
  when(on_cancel) {
84
87
  unsupported {
85
88
  code: HSX1110;
@@ -110,10 +113,10 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
110
113
  }
111
114
  }
112
115
  when(cancel_offer_life) {
113
- when_not(cancel_charge_bps) {
116
+ when_not(has_cancel_quote) {
114
117
  unsupported {
115
118
  code: HSX1110;
116
- message: "cancel_offer_life needs a nonzero cancel_charge_bps to quote";
119
+ message: "cancel_offer_life needs cancel_charge_bps to quote";
117
120
  fix: "declare the cancellation charge in basis points, or drop cancel_offer_life";
118
121
  }
119
122
  }
@@ -202,7 +205,7 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
202
205
  when_not(cancel_splits) {
203
206
  states created funded disputed released;
204
207
  when(on_cancel) { states cancelled; }
205
- when(cancel_charge_bps) { states cancellation_quoted cancelled settled; }
208
+ when(has_cancel_quote) { states cancellation_quoted cancelled settled; }
206
209
  states abandoned;
207
210
  }
208
211
  when(cancel_splits) { states created funding_1 funded disputed releasing_1 released cancelling_1 cancelled abandoned; }
@@ -219,14 +222,14 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
219
222
  when_not(payee_fee) {
220
223
  when_not(cancel_splits) {
221
224
  on fund_piece_1: created -> funded;
222
- when_not(cancel_charge_bps) {
225
+ when_not(has_cancel_quote) {
223
226
  on [release_name]: funded -> released;
224
227
  when(release_deadline) { on release_on_deadline: funded -> released; }
225
228
  }
226
- when(cancel_charge_bps) {
229
+ when(has_cancel_quote) {
227
230
  on [release_name]: funded | cancellation_quoted -> released;
228
231
  when(release_deadline) { on release_on_deadline: funded | cancellation_quoted -> released; }
229
- on quote_cancellation: funded -> cancellation_quoted;
232
+ on quote_cancellation: funded | cancellation_quoted -> cancellation_quoted;
230
233
  on cancel: cancellation_quoted -> cancelled;
231
234
  on retain_cancellation_charge: cancelled -> settled;
232
235
  }
@@ -275,7 +278,12 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
275
278
  on unfund_piece_1: funding_1 | abandoning_1 -> abandoned;
276
279
  }
277
280
  }
278
- on dispute: funded -> disputed;
281
+ when_not(has_cancel_quote) {
282
+ on dispute: funded -> disputed;
283
+ }
284
+ when(has_cancel_quote) {
285
+ on dispute: funded | cancellation_quoted -> disputed;
286
+ }
279
287
  on resume: disputed -> funded;
280
288
  on abandon: created -> abandoned;
281
289
  }
@@ -520,7 +528,7 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
520
528
  steps: [];
521
529
  }
522
530
  }
523
- when(cancel_charge_bps) {
531
+ when(has_cancel_quote) {
524
532
  action quote_cancellation {
525
533
  agent_description: concat("Price the cancellation and hold that price open. No money moves. The charge the ", words(payee), " keeps and the refund the ", words(payer), " gets back are worked out here and frozen for ", cancel_offer_life, ", after which a fresh call prices it again. Call cancel to spend the quote.");
526
534
  summary: "Price the cancellation and hold that price open";
@@ -554,7 +562,12 @@ export instrument held_payment<C>(payer: party, payee: party, amount: money<C>,
554
562
  let(metadata_phase): "metadata.phase";
555
563
  agent_description: concat("Pay the quoted cancellation charge out of escrow to the ", words(payee), ". This moves money and does not reverse. The settlement must already be cancelled, so the figure is the one the quote fixed and the escrow ends empty.");
556
564
  summary: concat("Pay the quoted cancellation charge to the ", words(payee));
557
- moves: [{ key: charge; operation: internal_transfer.create; bind: { amount: { from: instance; path: refs.cancellationChargeAmount; }; currency: { from: instance; path: fields.currency; }; destinationAccountId: { from: instance; path: concat("fields.", payee_account_field); }; [metadata_instrument]: { from: const; value: instrument; }; [metadata_instance]: { from: instance; path: instrumentInstanceId; }; [metadata_phase]: { from: const; value: retain_cancellation_charge; }; productId: { from: instance; path: productId; }; sourceAccountId: { from: instance; path: refs.escrowAccountId; }; }; capture: { retainCancellationChargeTransferId: transferId; }; }];
565
+ when(has_charge) {
566
+ moves: [{ key: charge; operation: internal_transfer.create; bind: { amount: { from: instance; path: refs.cancellationChargeAmount; }; currency: { from: instance; path: fields.currency; }; destinationAccountId: { from: instance; path: concat("fields.", payee_account_field); }; [metadata_instrument]: { from: const; value: instrument; }; [metadata_instance]: { from: instance; path: instrumentInstanceId; }; [metadata_phase]: { from: const; value: retain_cancellation_charge; }; productId: { from: instance; path: productId; }; sourceAccountId: { from: instance; path: refs.escrowAccountId; }; }; capture: { retainCancellationChargeTransferId: transferId; }; }];
567
+ }
568
+ when_not(has_charge) {
569
+ moves: [];
570
+ }
558
571
  steps: [];
559
572
  }
560
573
  }