@crediolabs/policy-synth 0.1.17 → 0.2.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 (113) hide show
  1. package/README.md +44 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/authority-overlap.d.ts +134 -0
  8. package/dist/install/authority-overlap.js +0 -0
  9. package/dist/install/build-add-context-rule.d.ts +8 -0
  10. package/dist/install/build-add-context-rule.js +17 -46
  11. package/dist/install/build-install-policy.d.ts +12 -5
  12. package/dist/install/build-install-policy.js +32 -23
  13. package/dist/install/build-merge-policy.d.ts +70 -0
  14. package/dist/install/build-merge-policy.js +130 -0
  15. package/dist/install/get-interpreter-info.js +2 -2
  16. package/dist/install/index.d.ts +3 -1
  17. package/dist/install/index.js +8 -1
  18. package/dist/install/oz-auth.js +5 -3
  19. package/dist/install/plan-merge-policy.d.ts +49 -0
  20. package/dist/install/plan-merge-policy.js +86 -0
  21. package/dist/install/read-account-rules.d.ts +100 -0
  22. package/dist/install/read-account-rules.js +283 -0
  23. package/dist/predicate/decode.js +1 -1
  24. package/dist/predicate/encode.js +171 -10
  25. package/dist/record/decode.js +5 -6
  26. package/dist/registry/protocols.d.ts +1 -1
  27. package/dist/registry/protocols.js +1 -1
  28. package/dist/review-card/builder.d.ts +6 -0
  29. package/dist/review-card/builder.js +9 -1
  30. package/dist/run/index.d.ts +104 -18
  31. package/dist/run/index.js +359 -75
  32. package/dist/run/schemas.d.ts +479 -18
  33. package/dist/run/schemas.js +132 -24
  34. package/dist/synth/compose-from-recording.d.ts +10 -15
  35. package/dist/synth/compose-from-recording.js +79 -124
  36. package/dist/synth/deny-cases.d.ts +5 -0
  37. package/dist/synth/deny-cases.js +52 -13
  38. package/dist/synth/evaluate.js +69 -119
  39. package/dist/synth/harness.d.ts +13 -1
  40. package/dist/synth/harness.js +22 -1
  41. package/dist/synth/index.d.ts +1 -1
  42. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  43. package/dist/synth/synthesize-from-recording.js +129 -155
  44. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  45. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  46. package/dist-cjs/adapters/oz/adapter.js +12 -13
  47. package/dist-cjs/codegen/compile-gate.js +5 -0
  48. package/dist-cjs/codegen/template.js +17 -0
  49. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  50. package/dist-cjs/install/authority-overlap.js +0 -0
  51. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  52. package/dist-cjs/install/build-add-context-rule.js +17 -45
  53. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  54. package/dist-cjs/install/build-install-policy.js +32 -23
  55. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  56. package/dist-cjs/install/build-merge-policy.js +134 -0
  57. package/dist-cjs/install/get-interpreter-info.js +2 -2
  58. package/dist-cjs/install/index.d.ts +3 -1
  59. package/dist-cjs/install/index.js +24 -3
  60. package/dist-cjs/install/oz-auth.js +5 -3
  61. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  62. package/dist-cjs/install/plan-merge-policy.js +90 -0
  63. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  64. package/dist-cjs/install/read-account-rules.js +296 -0
  65. package/dist-cjs/predicate/decode.js +1 -1
  66. package/dist-cjs/predicate/encode.js +171 -10
  67. package/dist-cjs/record/decode.js +5 -6
  68. package/dist-cjs/registry/protocols.d.ts +1 -1
  69. package/dist-cjs/registry/protocols.js +1 -1
  70. package/dist-cjs/review-card/builder.d.ts +6 -0
  71. package/dist-cjs/review-card/builder.js +9 -1
  72. package/dist-cjs/run/index.d.ts +104 -18
  73. package/dist-cjs/run/index.js +360 -74
  74. package/dist-cjs/run/schemas.d.ts +479 -18
  75. package/dist-cjs/run/schemas.js +133 -25
  76. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  77. package/dist-cjs/synth/compose-from-recording.js +79 -124
  78. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  79. package/dist-cjs/synth/deny-cases.js +52 -13
  80. package/dist-cjs/synth/evaluate.js +69 -119
  81. package/dist-cjs/synth/harness.d.ts +13 -1
  82. package/dist-cjs/synth/harness.js +22 -1
  83. package/dist-cjs/synth/index.d.ts +1 -1
  84. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  85. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  86. package/package.json +1 -1
  87. package/src/adapters/interpreter/adapter.ts +28 -48
  88. package/src/adapters/oz/adapter.ts +12 -13
  89. package/src/codegen/compile-gate.ts +5 -0
  90. package/src/codegen/template.ts +17 -0
  91. package/src/install/authority-overlap.ts +0 -0
  92. package/src/install/build-add-context-rule.ts +29 -56
  93. package/src/install/build-install-policy.ts +41 -23
  94. package/src/install/build-merge-policy.ts +219 -0
  95. package/src/install/get-interpreter-info.ts +2 -2
  96. package/src/install/index.ts +36 -2
  97. package/src/install/oz-auth.ts +5 -3
  98. package/src/install/plan-merge-policy.ts +133 -0
  99. package/src/install/read-account-rules.ts +376 -0
  100. package/src/predicate/decode.ts +1 -1
  101. package/src/predicate/encode.ts +176 -10
  102. package/src/record/decode.ts +5 -6
  103. package/src/registry/protocols.ts +1 -1
  104. package/src/review-card/builder.ts +17 -1
  105. package/src/run/index.ts +500 -102
  106. package/src/run/schemas.ts +145 -24
  107. package/src/synth/compose-from-recording.ts +87 -132
  108. package/src/synth/deny-cases.ts +59 -13
  109. package/src/synth/evaluate.ts +70 -118
  110. package/src/synth/harness.ts +29 -1
  111. package/src/synth/index.ts +1 -0
  112. package/src/synth/synthesize-from-recording.ts +209 -192
  113. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -1,34 +1,24 @@
1
1
  "use strict";
2
- // src/synth/evaluate.ts - the model evaluator.
2
+ // src/synth/evaluate.ts - the model evaluator (TS model of interpreter semantics).
3
3
  //
4
- // Pure function. The single semantic evaluator every deny-case runs through
5
- // (TS model of the interpreter's semantics; real-semantics wiring is Phase 03).
6
- // Determinism: same `(predicate, ctx)` -> byte-identical result, no clock, no
7
- // randomness.
4
+ // Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
5
+ // no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
6
+ // 1. ledger expiry -> 'EXPIRED'
7
+ // 2. `now` past valid_until -> 'EXPIRED'
8
+ // 3. contract mismatch -> 'CONTRACT_SCOPE'
9
+ // 4. per-ScVal equality on fn/args; EXACT ordered vector
10
+ // equality; fail-closed on opaque args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
11
+ // 5. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
12
+ // 6. amount / window_spent via BigInt -> 'AMOUNT_BOUND'
13
+ // 7. invocation_count_in_window -> 'FREQUENCY'
14
+ // 8. oracle_price: fatal -> throw OracleError, deny with
15
+ // underlying ORACLE_* reason -> 'ORACLE_*'
16
+ // 9. boolean nodes (and/or/not)
17
+ // 10. signer threshold gate -> 'THRESHOLD_NOT_MET'
18
+ // 11. otherwise permit.
8
19
  //
9
- // Deny order (deny on FIRST violation, stable `reason` string):
10
- // 1. `atLedger > validUntilLedger` (when set) -> 'EXPIRED'
11
- // 2. a `now` leaf compared past `valid_until` semantics -> 'EXPIRED'
12
- // 3. a `call_contract` eq whose literal != ctx.contract -> 'CONTRACT_SCOPE'
13
- // 4. per-ScVal equality on `call_fn` / `call_arg[i]`;
14
- // `eq(selector, literal_vec)` = EXACT ordered vector equality
15
- // (compare element-by-element, deny if length or any differs);
16
- // fail-closed on opaque/undecodable args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
17
- // 5. `in` membership: needle NOT in haystack; an EMPTY
18
- // haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
19
- // 6. amount / window_spent comparisons via BigInt -> 'AMOUNT_BOUND'
20
- // 7. `invocation_count_in_window` compare -> 'FREQUENCY'
21
- // 8. `oracle_price`: missing/stale/deviation/etc. is FATAL -
22
- // throw `OracleError`, catch at the top, deny with the
23
- // underlying `ORACLE_*` reason -> 'ORACLE_*'
24
- // 9. boolean nodes: `and` = all children permit;
25
- // `or` = any child permits; `not` = invert a non-oracle child
26
- // 10. signer threshold gate (when `signerWeights` provided,
27
- // no positive-weight signer -> deny 'THRESHOLD_NOT_MET')
28
- // 11. otherwise `{ permit: true }`.
29
- //
30
- // Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
31
- // modelled by throwing (never a boolean-false that `not`/`or` could mask).
20
+ // Amounts: BigInt on decimal strings. Oracle errors thrown (never
21
+ // boolean-false `not`/`or` could mask).
32
22
  Object.defineProperty(exports, "__esModule", { value: true });
33
23
  exports.evaluate = evaluate;
34
24
  const predicate_literals_ts_1 = require("./predicate-literals.js");
@@ -38,8 +28,8 @@ const NORMALISED_DECIMALS = 9;
38
28
  /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
39
29
  const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
40
30
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
41
- * and converted to the matching `ORACLE_*` deny reason. NOT a
42
- * boolean-false that `not` / `or` could mask. */
31
+ * and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
32
+ * that `not` / `or` could mask. */
43
33
  class OracleError extends Error {
44
34
  code;
45
35
  constructor(code) {
@@ -93,12 +83,9 @@ function walk(node, ctx) {
93
83
  for (const child of node.children) {
94
84
  const r = walk(child, ctx);
95
85
  if (!r.permit) {
96
- // deny-on-first: short-circuit. The "active branch" is the failing
97
- // child, so the reason we surface is from that child.
86
+ // deny-on-first: short-circuit on the failing child.
98
87
  return r;
99
88
  }
100
- // keep a reference to the last permit so unused-variable analysis
101
- // doesn't complain; this is purely structural.
102
89
  lastDeny = r;
103
90
  }
104
91
  return lastDeny ?? { permit: true };
@@ -114,11 +101,9 @@ function walk(node, ctx) {
114
101
  return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' };
115
102
  }
116
103
  case 'not': {
117
- // `not` structurally inverts the child UNLESS the child contains an
118
- // oracle leaf (compile-time rule: no oracle leaf under not/or). Here we
119
- // walk the child; if it throws `OracleError`, the catch at the top of
120
- // `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
121
- // deny is inverted to a permit, and a permit is inverted to a deny.
104
+ // `not` structurally inverts the child unless the child contains an
105
+ // oracle leaf (compile-time rule: no oracle leaf under not/or). A
106
+ // child `OracleError` is re-thrown to the catch at the top of `evaluate`.
122
107
  const r = walk(node.child, ctx);
123
108
  if (r.permit)
124
109
  return { permit: false, reason: 'FN_MISMATCH' };
@@ -139,17 +124,14 @@ function evalCompare(op, left, right, ctx) {
139
124
  // --- step 2: `now` vs `valid_until` semantics ---
140
125
  if (left.kind === 'now' && right.kind === 'valid_until') {
141
126
  const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0;
142
- // only `gt` / `gte` / `lt` / `lte` are meaningful here. Treat `eq` /
143
- // any other combo as: "expired iff nowSeconds > validUntilSeconds".
144
- // We model `valid_until` as a synthetic future timestamp far past
145
- // `nowSeconds` so the only true-positive expired path is the
146
- // `gt`/`gte` shapes callers actually write.
127
+ // `valid_until` is modelled as a synthetic future timestamp far past
128
+ // `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
129
+ // shapes callers actually write.
147
130
  if (op === 'gt' || op === 'gte') {
148
131
  if (ctx.nowSeconds > 0)
149
132
  return { permit: false, reason: 'EXPIRED' };
150
133
  }
151
134
  else if (op === 'lt' || op === 'lte') {
152
- // permits when valid_until is in the future
153
135
  return { permit: true };
154
136
  }
155
137
  return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true };
@@ -181,11 +163,6 @@ function evalCompare(op, left, right, ctx) {
181
163
  return evalScaledArgCompare(op, left, right, ctx);
182
164
  }
183
165
  const actual = ctx.args[left.index];
184
- // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
185
- // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
186
- // cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
187
- // in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
188
- // a non-numeric arg or literal.
189
166
  if (op !== 'eq')
190
167
  return evalArgOrderedCompare(op, actual, right);
191
168
  return evalArgEq(op, actual, right, ctx);
@@ -195,11 +172,11 @@ function evalCompare(op, left, right, ctx) {
195
172
  if (left.kind === 'call_arg_scaled') {
196
173
  return evalScaledArgCompare(op, right, left, ctx);
197
174
  }
198
- // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
199
- // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
175
+ // --- step 4c: call_arg_len: length of a vec-typed argument as u32.
176
+ // Fails closed on a non-vec / absent arg or a non-u32 literal.
200
177
  if (left.kind === 'call_arg_len') {
201
178
  const actual = ctx.args[left.index];
202
- if (!actual || actual.type !== 'vec')
179
+ if (actual?.type !== 'vec')
203
180
  return { permit: false, reason: 'ARG_MISMATCH' };
204
181
  if (right.kind !== 'literal_u32')
205
182
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -207,16 +184,14 @@ function evalCompare(op, left, right, ctx) {
207
184
  ? { permit: true }
208
185
  : { permit: false, reason: 'ARG_MISMATCH' };
209
186
  }
210
- // --- step 4d: call_arg_field: the value of a field in the map at element i
211
- // of the vec at argument index. Fails closed on a non-vec arg, an
212
- // out-of-range element, a missing field, a non-map element, or a type
213
- // mismatch between the field ScVal and the literal leaf.
187
+ // --- step 4d: call_arg_field: value of a field in the map at element i of
188
+ // the vec at argument index. Fails closed on shape / type / range issues.
214
189
  if (left.kind === 'call_arg_field') {
215
190
  const actual = ctx.args[left.index];
216
- if (!actual || actual.type !== 'vec')
191
+ if (actual?.type !== 'vec')
217
192
  return { permit: false, reason: 'ARG_MISMATCH' };
218
193
  const element = actual.value[left.element];
219
- if (!element || element.type !== 'map')
194
+ if (element?.type !== 'map')
220
195
  return { permit: false, reason: 'ARG_MISMATCH' };
221
196
  if (!Array.isArray(element.value))
222
197
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -229,7 +204,7 @@ function evalCompare(op, left, right, ctx) {
229
204
  }
230
205
  // --- step 6: AMOUNT_BOUND ---
231
206
  if (left.kind === 'amount' && op !== 'eq') {
232
- return evalAmountCompare(op, left.token, right, ctx);
207
+ return evalAmountCompare(op, left.token, right, ctx, 'amountByToken');
233
208
  }
234
209
  if (left.kind === 'window_spent' && op !== 'eq') {
235
210
  return evalWindowSpentCompare(op, left.token, right, ctx);
@@ -250,7 +225,7 @@ function evalCompare(op, left, right, ctx) {
250
225
  * sequence: compare element-by-element in order; deny if length or any
251
226
  * element differs. Opaque args (`type: 'other'`) fail closed. */
252
227
  function evalArgEq(op, actual, right, ctx) {
253
- // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
228
+ // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
254
229
  if (op === 'eq' && right.kind === 'literal_vec') {
255
230
  if (actual?.type !== 'vec')
256
231
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -258,7 +233,6 @@ function evalArgEq(op, actual, right, ctx) {
258
233
  ? { permit: true }
259
234
  : { permit: false, reason: 'ARG_MISMATCH' };
260
235
  }
261
- // eq(call_arg[i], literal_address) -> address compare
262
236
  if (op === 'eq' && right.kind === 'literal_address') {
263
237
  if (!actual)
264
238
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -268,7 +242,6 @@ function evalArgEq(op, actual, right, ctx) {
268
242
  ? { permit: true }
269
243
  : { permit: false, reason: 'ARG_MISMATCH' };
270
244
  }
271
- // eq(call_arg[i], literal_i128) -> BigInt compare
272
245
  if (op === 'eq' && right.kind === 'literal_i128') {
273
246
  if (actual?.type !== 'i128')
274
247
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -276,7 +249,6 @@ function evalArgEq(op, actual, right, ctx) {
276
249
  ? { permit: true }
277
250
  : { permit: false, reason: 'ARG_MISMATCH' };
278
251
  }
279
- // eq(call_arg[i], literal_symbol)
280
252
  if (op === 'eq' && right.kind === 'literal_symbol') {
281
253
  if (!actual)
282
254
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -284,7 +256,6 @@ function evalArgEq(op, actual, right, ctx) {
284
256
  ? { permit: true }
285
257
  : { permit: false, reason: 'ARG_MISMATCH' };
286
258
  }
287
- // eq(call_arg[i], literal_u32)
288
259
  if (op === 'eq' && right.kind === 'literal_u32') {
289
260
  if (!actual)
290
261
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -292,7 +263,6 @@ function evalArgEq(op, actual, right, ctx) {
292
263
  ? { permit: true }
293
264
  : { permit: false, reason: 'ARG_MISMATCH' };
294
265
  }
295
- // eq(call_arg[i], literal_u64)
296
266
  if (op === 'eq' && right.kind === 'literal_u64') {
297
267
  if (!actual)
298
268
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -300,7 +270,6 @@ function evalArgEq(op, actual, right, ctx) {
300
270
  ? { permit: true }
301
271
  : { permit: false, reason: 'ARG_MISMATCH' };
302
272
  }
303
- // eq(call_arg[i], literal_bytes)
304
273
  if (op === 'eq' && right.kind === 'literal_bytes') {
305
274
  if (!actual)
306
275
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -313,20 +282,14 @@ function evalArgEq(op, actual, right, ctx) {
313
282
  return { permit: false, reason: 'ARG_MISMATCH' };
314
283
  return { permit: false, reason: 'ARG_MISMATCH' };
315
284
  }
316
- /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
317
- * path: the scaled leaf is `args[index] * num / den` (truncating toward
318
- * zero). On `checked_mul` / `checked_div` failure (overflow or
319
- * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
320
- * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
321
- * rather than the generic `ARG_MISMATCH`.
322
- *
323
- * `left` is whichever side of the compare is NOT the scaled leaf. A
324
- * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
325
- * leaves has no definable semantics). The other operand must be a
326
- * numeric shape (`call_arg` carrying a number, or a numeric literal);
327
- * anything else is `ARG_MISMATCH`. */
285
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
286
+ * the scaled leaf is `args[index] * num / den` (truncating toward zero). On
287
+ * overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
288
+ * denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
289
+ * `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
290
+ * (no definable semantics). The non-scaled operand must be numeric
291
+ * (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
328
292
  function evalScaledArgCompare(op, left, right, ctx) {
329
- // Identify which side is scaled, and pull the other side as a number.
330
293
  let scaled;
331
294
  let other;
332
295
  let scaledOnRight;
@@ -341,14 +304,12 @@ function evalScaledArgCompare(op, left, right, ctx) {
341
304
  scaledOnRight = true;
342
305
  }
343
306
  else {
344
- // Neither side is scaled - this is a programming error in the
345
- // dispatcher; the contract returns ARG_MISMATCH to fail closed.
307
+ // Programming error in the dispatcher; fail closed.
346
308
  return { permit: false, reason: 'ARG_MISMATCH' };
347
309
  }
348
- // Source `args[index]` -> BigInt. An out-of-bounds index or a
349
- // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
350
- // a violated floor is the wrong code when the operand itself could
351
- // not be read.
310
+ // Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
311
+ // SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
312
+ // itself could not be read.
352
313
  if (scaled.index >= ctx.args.length) {
353
314
  return { permit: false, reason: 'ARG_MISMATCH' };
354
315
  }
@@ -365,16 +326,11 @@ function evalScaledArgCompare(op, left, right, ctx) {
365
326
  return { permit: false, reason: 'ARG_MISMATCH' };
366
327
  }
367
328
  // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
368
- // check is a defensive belt-and-braces - a future validator
369
- // regression cannot panic the frame on a divide-by-zero.
329
+ // check is defensive belt-and-braces so a validator regression cannot
330
+ // panic the frame on a divide-by-zero.
370
331
  if (den === 0n)
371
332
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
372
333
  const product = input * num;
373
- // BigInt overflow is silent in JS (it does not throw on multiplication).
374
- // We can detect over-range by dividing and checking the result against
375
- // i128 bounds, but the more practical check is whether the result
376
- // divides cleanly. We rely on BigInt's arbitrary precision and then
377
- // surface ARITHMETIC_OVERFLOW if the result is outside i128.
378
334
  let scaledValue;
379
335
  try {
380
336
  // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
@@ -383,18 +339,13 @@ function evalScaledArgCompare(op, left, right, ctx) {
383
339
  catch {
384
340
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
385
341
  }
386
- // i128 range check: if the scaled value is outside i128, the contract
387
- // would have wrapped on i128 arithmetic; surface the same deny.
342
+ // i128 range check: the contract would have wrapped on i128 arithmetic;
343
+ // surface the same deny.
388
344
  const I128_MAX = (1n << 127n) - 1n;
389
345
  const I128_MIN = -(1n << 127n);
390
346
  if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
391
347
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
392
348
  }
393
- // Resolving the operand when the scaled side is on the right: the
394
- // operand is `left`; the scaled value is the RHS. The AST order is
395
- // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
396
- // When the scaled side is on the left, the order is `scaled <op> other`,
397
- // i.e. `scaled <op> other_val`.
398
349
  let otherVal;
399
350
  if (other.kind === 'call_arg') {
400
351
  otherVal = argNumericBigInt(ctx.args[other.index]);
@@ -462,7 +413,6 @@ function compareVecExact(actual, expected, ctx) {
462
413
  function evalIn(needle, haystack, ctx) {
463
414
  if (haystack.length === 0)
464
415
  return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
465
- // Resolve the needle's ScVal against the candidate call.
466
416
  const actual = resolveLeaf(needle, ctx);
467
417
  if (!actual || actual.type === 'other')
468
418
  return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
@@ -489,10 +439,10 @@ function resolveLeaf(leaf, ctx) {
489
439
  return undefined;
490
440
  case 'call_arg_field': {
491
441
  const actual = ctx.args[leaf.index];
492
- if (!actual || actual.type !== 'vec')
442
+ if (actual?.type !== 'vec')
493
443
  return undefined;
494
444
  const element = actual.value[leaf.element];
495
- if (!element || element.type !== 'map')
445
+ if (element?.type !== 'map')
496
446
  return undefined;
497
447
  if (!Array.isArray(element.value))
498
448
  return undefined;
@@ -523,25 +473,21 @@ function resolveLeaf(leaf, ctx) {
523
473
  return undefined;
524
474
  }
525
475
  }
526
- /** Step 6: amount compare on BigInt. */
527
- function evalAmountCompare(op, token, right, ctx) {
476
+ /** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
477
+ * per-token BigInt record (current vs. rolling) to an i128 literal; the
478
+ * compare + denial reason are identical, so a single helper handles both. */
479
+ function evalAmountCompare(op, token, right, ctx, record) {
528
480
  const literal = right.kind === 'literal_i128' ? right.value : null;
529
481
  if (literal === null)
530
482
  return { permit: false, reason: 'AMOUNT_BOUND' };
531
- const actual = ctx.amountByToken[token] ?? '0';
483
+ const actual = ctx[record][token] ?? '0';
532
484
  return bigintCmp(op, actual, literal)
533
485
  ? { permit: true }
534
486
  : { permit: false, reason: 'AMOUNT_BOUND' };
535
487
  }
536
488
  /** Step 6: window_spent compare on BigInt. */
537
489
  function evalWindowSpentCompare(op, token, right, ctx) {
538
- const literal = right.kind === 'literal_i128' ? right.value : null;
539
- if (literal === null)
540
- return { permit: false, reason: 'AMOUNT_BOUND' };
541
- const actual = ctx.windowSpentByToken[token] ?? '0';
542
- return bigintCmp(op, actual, literal)
543
- ? { permit: true }
544
- : { permit: false, reason: 'AMOUNT_BOUND' };
490
+ return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken');
545
491
  }
546
492
  /** Step 7: invocation_count_in_window compare. */
547
493
  function evalFrequencyCompare(op, windowSecs, right, ctx) {
@@ -565,24 +511,28 @@ function evalOracleCompare(op, asset, right, ctx) {
565
511
  throw new OracleError(mapped);
566
512
  }
567
513
  // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
568
- // decimal basis: prices are on the normalised 9-dp basis, and assuming a
569
- // bare literal shares it is what let a raw 14-dp threshold permit
570
- // everything. A bare literal is refused rather than assumed.
514
+ // decimal basis: prices are on the normalised 9-dp basis, and a bare literal
515
+ // is refused (not assumed) because a raw 14-dp threshold that assumed the
516
+ // 9-dp basis would permit everything.
571
517
  if (right.kind !== 'oracle_threshold')
572
518
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
573
519
  if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
574
520
  throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
575
521
  }
576
- // Scale BOTH sides up to the wider basis rather than dividing the threshold
577
- // down: dividing truncates, and a truncated bound moves the permit boundary.
522
+ // Scale BOTH sides up to the wider basis; dividing the threshold down
523
+ // truncates and moves the permit boundary.
578
524
  const decimals = BigInt(right.decimals);
579
525
  const normalised = BigInt(NORMALISED_DECIMALS);
580
526
  const common = decimals > normalised ? decimals : normalised;
581
527
  const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
582
528
  const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
529
+ // A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
530
+ // `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
531
+ // the same reason so a future TS/Rust reason divergence fails CI; the
532
+ // cross-layer harness in this file now asserts reason codes match.
583
533
  return bigintCmp(op, String(priceScaled), String(literalScaled))
584
534
  ? { permit: true }
585
- : { permit: false, reason: 'FN_MISMATCH' };
535
+ : { permit: false, reason: 'STATEFUL_BOUND' };
586
536
  }
587
537
  /** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
588
538
  * equal checks), but the dedicated arg path uses it via `BigInt(actual) === BigInt(right)`. */
@@ -5,6 +5,13 @@ export interface HarnessFailure {
5
5
  expected: 'permit' | 'deny';
6
6
  got: 'permit' | 'deny';
7
7
  reason?: string;
8
+ /** When `expectedReason` is set on the deny case and the case actually
9
+ * denies, this carries the canonical reason the TS evaluator MUST emit
10
+ * to stay in lockstep with the Rust interpreter (the cross-layer
11
+ * reason-code contract). A future TS/Rust reason divergence surfaces
12
+ * here in CI. */
13
+ expectedReason?: string;
14
+ actualReason?: string;
8
15
  }
9
16
  export type HarnessResult = {
10
17
  ok: true;
@@ -12,5 +19,10 @@ export type HarnessResult = {
12
19
  ok: false;
13
20
  failures: HarnessFailure[];
14
21
  };
15
- /** Evaluate the intended call and every generated denial without side effects. */
22
+ /** Evaluate the intended call and every generated denial without side effects.
23
+ * Asserts both the boolean decision AND, for deny cases that carry an
24
+ * `expectedReason`, the concrete reason the TS evaluator emits. The reason
25
+ * is what the Rust interpreter returns; the cross-layer conformance test in
26
+ * `contracts/policy-interpreter/tests/conformance` mirrors the same contract
27
+ * for the verifyLive path. */
16
28
  export declare function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult;
@@ -2,7 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runHarness = runHarness;
4
4
  const evaluate_ts_1 = require("./evaluate.js");
5
- /** Evaluate the intended call and every generated denial without side effects. */
5
+ /** Evaluate the intended call and every generated denial without side effects.
6
+ * Asserts both the boolean decision AND, for deny cases that carry an
7
+ * `expectedReason`, the concrete reason the TS evaluator emits. The reason
8
+ * is what the Rust interpreter returns; the cross-layer conformance test in
9
+ * `contracts/policy-interpreter/tests/conformance` mirrors the same contract
10
+ * for the verifyLive path. */
6
11
  function runHarness(predicate, cases) {
7
12
  const failures = [];
8
13
  const permitResult = (0, evaluate_ts_1.evaluate)(predicate, cases.permit);
@@ -23,6 +28,22 @@ function runHarness(predicate, cases) {
23
28
  got: 'permit',
24
29
  reason: 'DENY_CASE_FAILURE',
25
30
  });
31
+ continue;
32
+ }
33
+ // Reason-code assertion (cross-layer contract). When a deny case carries
34
+ // an expected reason, the TS evaluator MUST emit that exact string; a
35
+ // divergence means the TS model and the Rust interpreter disagree on
36
+ // what the user sees in CI - the harness fails so the diff is visible
37
+ // here, not at runtime in production.
38
+ if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
39
+ failures.push({
40
+ dimension: deny.dimension,
41
+ expected: 'deny',
42
+ got: 'deny',
43
+ reason: 'REASON_MISMATCH',
44
+ expectedReason: deny.expectedReason,
45
+ actualReason: result.reason,
46
+ });
26
47
  }
27
48
  }
28
49
  return failures.length === 0 ? { ok: true } : { ok: false, failures };
@@ -7,4 +7,4 @@ export { type IntentFacts, lower } from './lower.ts';
7
7
  export { minimize } from './minimize.ts';
8
8
  export { type DecideScopeOptions, decideScope, type ScopeDecision, scopeToContextRuleType, } from './scope.ts';
9
9
  export { synthesizeFromMandate } from './synthesize-from-mandate.ts';
10
- export { type SynthesizeFromRecordingOptions, synthesizeFromRecording, } from './synthesize-from-recording.ts';
10
+ export { type __TestInterpreterAdapterOptions, type SynthesizeFromRecordingOptions, synthesizeFromRecording, } from './synthesize-from-recording.ts';
@@ -20,21 +20,18 @@ export interface InterpreterAdapterOptions {
20
20
  maxStalenessSeconds?: number;
21
21
  maxDeviationBps?: number;
22
22
  };
23
- /** @internal Test-only seam. When set, the orchestrator uses this
24
- * PredicateNode as the starting point for self-verify + minimise instead
25
- * of calling the interpreter adapter's compile(). Production callers MUST
26
- * NOT set this; the field exists so the self-verify pipeline can be
27
- * exercised on hand-crafted predicates (redundant conjuncts for
28
- * minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
29
- * `smartAccountAddress` + `installNonce` + `oracleParams` are still
30
- * carried through so the emitted PolicyRef + PolicyDocument match the
31
- * recorded call shape. */
32
- __testPredicateNode?: PredicateNode;
33
23
  }
34
- /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
35
- * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
36
- * limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
37
- * `interpreter` opts the recording path into the interpreter adapter. */
24
+ /** PRIVATE test-only extension of `InterpreterAdapterOptions` for the
25
+ * `__testPredicateNode` seam. Exported under a `__` prefix so production
26
+ * callers can grep for it and see it is a test hook. The seam is read in
27
+ * `synthesizeFromRecordingInner` only when `process.env.NODE_ENV === 'test'`
28
+ * - a hard RUNTIME guard throws otherwise so a misconfigured production
29
+ * caller cannot bypass the recording -> interpreter adapter compile path. */
30
+ export type __TestInterpreterAdapterOptions = InterpreterAdapterOptions & {
31
+ /** @internal Test-only seam. Throws at runtime when NODE_ENV !== 'test'. */
32
+ __testPredicateNode?: PredicateNode;
33
+ };
34
+ /** Top-level orchestrator inputs. */
38
35
  export interface SynthesizeFromRecordingOptions {
39
36
  network: Network;
40
37
  userResponses?: ComposeUserResponses;
@@ -42,13 +39,8 @@ export interface SynthesizeFromRecordingOptions {
42
39
  threshold: number;
43
40
  };
44
41
  interpreter?: InterpreterAdapterOptions;
45
- /** --explain opt-in. When true, the orchestrator attaches the
46
- * in-memory `PredicateNode` + the corresponding `SimulationResult`
47
- * to the success envelope so the CLI can render a human-readable
48
- * review card. Absent or false -> the success envelope is unchanged
49
- * (byte-identical to today). The flag is ADDITIVE: the existing
50
- * ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
51
- * never altered by enabling explain. */
42
+ /** --explain opt-in. The flag is ADDITIVE: existing ProposedPolicy fields are
43
+ * never altered by enabling explain. Absent -> success envelope is byte-identical. */
52
44
  explain?: true;
53
45
  }
54
46
  export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
@@ -71,3 +63,21 @@ export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: S
71
63
  * failure that the envelope converts to `{ok:false, error}`. */
72
64
  declare function throwToolError(code: ToolError['code'], message: string): never;
73
65
  export { throwToolError };
66
+ /** Diagnostic reported by `visitOracleLeaves` when an oracle comparison
67
+ * cannot be normalised to oracle-on-left. Currently only "oracle-on-right
68
+ * with a non-literal LHS" - the other branches already produce a parseable
69
+ * threshold and are returned to the caller. */
70
+ export interface OracleNormalisationWarning {
71
+ /** The dimension name (currently always "oracle_normalisation_dropped"). */
72
+ dimension: 'oracle_normalisation_dropped';
73
+ /** The operator as written. */
74
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte';
75
+ /** The asset the oracle leaf is bound to. */
76
+ asset: string;
77
+ /** The non-literal RHS (oracle-on-left) or LHS (oracle-on-right) leaf
78
+ * whose shape stopped the normalisation. Surfaced for diagnostics;
79
+ * intentionally a partial view - the full leaf is the caller's job. */
80
+ otherKind: string;
81
+ /** Human-readable message (matches the warn-on-drop text). */
82
+ message: string;
83
+ }