@crediolabs/policy-synth 0.1.16 → 0.1.18

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 (91) hide show
  1. package/README.md +43 -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/build-add-context-rule.js +16 -45
  8. package/dist/install/build-install-policy.d.ts +12 -5
  9. package/dist/install/build-install-policy.js +32 -23
  10. package/dist/install/get-interpreter-info.js +2 -2
  11. package/dist/install/index.d.ts +1 -0
  12. package/dist/install/index.js +15 -0
  13. package/dist/install/oz-auth.js +5 -3
  14. package/dist/predicate/decode.js +1 -1
  15. package/dist/predicate/encode.js +171 -10
  16. package/dist/record/decode.js +5 -6
  17. package/dist/registry/protocols.d.ts +1 -1
  18. package/dist/registry/protocols.js +1 -1
  19. package/dist/review-card/builder.d.ts +6 -0
  20. package/dist/review-card/builder.js +9 -1
  21. package/dist/run/index.d.ts +14 -13
  22. package/dist/run/index.js +80 -67
  23. package/dist/run/schemas.d.ts +193 -11
  24. package/dist/run/schemas.js +59 -17
  25. package/dist/synth/compose-from-recording.d.ts +10 -15
  26. package/dist/synth/compose-from-recording.js +79 -124
  27. package/dist/synth/deny-cases.d.ts +5 -0
  28. package/dist/synth/deny-cases.js +52 -13
  29. package/dist/synth/evaluate.js +69 -119
  30. package/dist/synth/harness.d.ts +13 -1
  31. package/dist/synth/harness.js +22 -1
  32. package/dist/synth/index.d.ts +1 -1
  33. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  34. package/dist/synth/synthesize-from-recording.js +129 -155
  35. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  36. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  37. package/dist-cjs/adapters/oz/adapter.js +12 -13
  38. package/dist-cjs/codegen/compile-gate.js +5 -0
  39. package/dist-cjs/codegen/template.js +17 -0
  40. package/dist-cjs/install/build-add-context-rule.js +16 -45
  41. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  42. package/dist-cjs/install/build-install-policy.js +32 -23
  43. package/dist-cjs/install/get-interpreter-info.js +2 -2
  44. package/dist-cjs/install/index.d.ts +1 -0
  45. package/dist-cjs/install/index.js +21 -0
  46. package/dist-cjs/install/oz-auth.js +5 -3
  47. package/dist-cjs/predicate/decode.js +1 -1
  48. package/dist-cjs/predicate/encode.js +171 -10
  49. package/dist-cjs/record/decode.js +5 -6
  50. package/dist-cjs/registry/protocols.d.ts +1 -1
  51. package/dist-cjs/registry/protocols.js +1 -1
  52. package/dist-cjs/review-card/builder.d.ts +6 -0
  53. package/dist-cjs/review-card/builder.js +9 -1
  54. package/dist-cjs/run/index.d.ts +14 -13
  55. package/dist-cjs/run/index.js +80 -67
  56. package/dist-cjs/run/schemas.d.ts +193 -11
  57. package/dist-cjs/run/schemas.js +59 -17
  58. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  59. package/dist-cjs/synth/compose-from-recording.js +79 -124
  60. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  61. package/dist-cjs/synth/deny-cases.js +52 -13
  62. package/dist-cjs/synth/evaluate.js +69 -119
  63. package/dist-cjs/synth/harness.d.ts +13 -1
  64. package/dist-cjs/synth/harness.js +22 -1
  65. package/dist-cjs/synth/index.d.ts +1 -1
  66. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  67. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  68. package/package.json +11 -4
  69. package/src/adapters/interpreter/adapter.ts +28 -48
  70. package/src/adapters/oz/adapter.ts +12 -13
  71. package/src/codegen/compile-gate.ts +5 -0
  72. package/src/codegen/template.ts +17 -0
  73. package/src/install/build-add-context-rule.ts +17 -55
  74. package/src/install/build-install-policy.ts +41 -23
  75. package/src/install/get-interpreter-info.ts +2 -2
  76. package/src/install/index.ts +21 -0
  77. package/src/install/oz-auth.ts +5 -3
  78. package/src/predicate/decode.ts +1 -1
  79. package/src/predicate/encode.ts +176 -10
  80. package/src/record/decode.ts +5 -6
  81. package/src/registry/protocols.ts +1 -1
  82. package/src/review-card/builder.ts +17 -1
  83. package/src/run/index.ts +117 -91
  84. package/src/run/schemas.ts +64 -16
  85. package/src/synth/compose-from-recording.ts +87 -132
  86. package/src/synth/deny-cases.ts +59 -13
  87. package/src/synth/evaluate.ts +70 -118
  88. package/src/synth/harness.ts +29 -1
  89. package/src/synth/index.ts +1 -0
  90. package/src/synth/synthesize-from-recording.ts +209 -192
  91. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -1,33 +1,23 @@
1
- // src/synth/evaluate.ts - the model evaluator.
1
+ // src/synth/evaluate.ts - the model evaluator (TS model of interpreter semantics).
2
2
  //
3
- // Pure function. The single semantic evaluator every deny-case runs through
4
- // (TS model of the interpreter's semantics; real-semantics wiring is Phase 03).
5
- // Determinism: same `(predicate, ctx)` -> byte-identical result, no clock, no
6
- // randomness.
3
+ // Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
4
+ // no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
5
+ // 1. ledger expiry -> 'EXPIRED'
6
+ // 2. `now` past valid_until -> 'EXPIRED'
7
+ // 3. contract mismatch -> 'CONTRACT_SCOPE'
8
+ // 4. per-ScVal equality on fn/args; EXACT ordered vector
9
+ // equality; fail-closed on opaque args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
10
+ // 5. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
11
+ // 6. amount / window_spent via BigInt -> 'AMOUNT_BOUND'
12
+ // 7. invocation_count_in_window -> 'FREQUENCY'
13
+ // 8. oracle_price: fatal -> throw OracleError, deny with
14
+ // underlying ORACLE_* reason -> 'ORACLE_*'
15
+ // 9. boolean nodes (and/or/not)
16
+ // 10. signer threshold gate -> 'THRESHOLD_NOT_MET'
17
+ // 11. otherwise permit.
7
18
  //
8
- // Deny order (deny on FIRST violation, stable `reason` string):
9
- // 1. `atLedger > validUntilLedger` (when set) -> 'EXPIRED'
10
- // 2. a `now` leaf compared past `valid_until` semantics -> 'EXPIRED'
11
- // 3. a `call_contract` eq whose literal != ctx.contract -> 'CONTRACT_SCOPE'
12
- // 4. per-ScVal equality on `call_fn` / `call_arg[i]`;
13
- // `eq(selector, literal_vec)` = EXACT ordered vector equality
14
- // (compare element-by-element, deny if length or any differs);
15
- // fail-closed on opaque/undecodable args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
16
- // 5. `in` membership: needle NOT in haystack; an EMPTY
17
- // haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
18
- // 6. amount / window_spent comparisons via BigInt -> 'AMOUNT_BOUND'
19
- // 7. `invocation_count_in_window` compare -> 'FREQUENCY'
20
- // 8. `oracle_price`: missing/stale/deviation/etc. is FATAL -
21
- // throw `OracleError`, catch at the top, deny with the
22
- // underlying `ORACLE_*` reason -> 'ORACLE_*'
23
- // 9. boolean nodes: `and` = all children permit;
24
- // `or` = any child permits; `not` = invert a non-oracle child
25
- // 10. signer threshold gate (when `signerWeights` provided,
26
- // no positive-weight signer -> deny 'THRESHOLD_NOT_MET')
27
- // 11. otherwise `{ permit: true }`.
28
- //
29
- // Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
30
- // modelled by throwing (never a boolean-false that `not`/`or` could mask).
19
+ // Amounts: BigInt on decimal strings. Oracle errors thrown (never
20
+ // boolean-false `not`/`or` could mask).
31
21
  import { literalNumericBigInt } from "./predicate-literals.js";
32
22
  /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
33
23
  * in oracle.rs. A threshold on any other basis must say so. */
@@ -35,8 +25,8 @@ const NORMALISED_DECIMALS = 9;
35
25
  /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
36
26
  const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
37
27
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
38
- * and converted to the matching `ORACLE_*` deny reason. NOT a
39
- * boolean-false that `not` / `or` could mask. */
28
+ * and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
29
+ * that `not` / `or` could mask. */
40
30
  class OracleError extends Error {
41
31
  code;
42
32
  constructor(code) {
@@ -90,12 +80,9 @@ function walk(node, ctx) {
90
80
  for (const child of node.children) {
91
81
  const r = walk(child, ctx);
92
82
  if (!r.permit) {
93
- // deny-on-first: short-circuit. The "active branch" is the failing
94
- // child, so the reason we surface is from that child.
83
+ // deny-on-first: short-circuit on the failing child.
95
84
  return r;
96
85
  }
97
- // keep a reference to the last permit so unused-variable analysis
98
- // doesn't complain; this is purely structural.
99
86
  lastDeny = r;
100
87
  }
101
88
  return lastDeny ?? { permit: true };
@@ -111,11 +98,9 @@ function walk(node, ctx) {
111
98
  return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' };
112
99
  }
113
100
  case 'not': {
114
- // `not` structurally inverts the child UNLESS the child contains an
115
- // oracle leaf (compile-time rule: no oracle leaf under not/or). Here we
116
- // walk the child; if it throws `OracleError`, the catch at the top of
117
- // `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
118
- // deny is inverted to a permit, and a permit is inverted to a deny.
101
+ // `not` structurally inverts the child unless the child contains an
102
+ // oracle leaf (compile-time rule: no oracle leaf under not/or). A
103
+ // child `OracleError` is re-thrown to the catch at the top of `evaluate`.
119
104
  const r = walk(node.child, ctx);
120
105
  if (r.permit)
121
106
  return { permit: false, reason: 'FN_MISMATCH' };
@@ -136,17 +121,14 @@ function evalCompare(op, left, right, ctx) {
136
121
  // --- step 2: `now` vs `valid_until` semantics ---
137
122
  if (left.kind === 'now' && right.kind === 'valid_until') {
138
123
  const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0;
139
- // only `gt` / `gte` / `lt` / `lte` are meaningful here. Treat `eq` /
140
- // any other combo as: "expired iff nowSeconds > validUntilSeconds".
141
- // We model `valid_until` as a synthetic future timestamp far past
142
- // `nowSeconds` so the only true-positive expired path is the
143
- // `gt`/`gte` shapes callers actually write.
124
+ // `valid_until` is modelled as a synthetic future timestamp far past
125
+ // `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
126
+ // shapes callers actually write.
144
127
  if (op === 'gt' || op === 'gte') {
145
128
  if (ctx.nowSeconds > 0)
146
129
  return { permit: false, reason: 'EXPIRED' };
147
130
  }
148
131
  else if (op === 'lt' || op === 'lte') {
149
- // permits when valid_until is in the future
150
132
  return { permit: true };
151
133
  }
152
134
  return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true };
@@ -178,11 +160,6 @@ function evalCompare(op, left, right, ctx) {
178
160
  return evalScaledArgCompare(op, left, right, ctx);
179
161
  }
180
162
  const actual = ctx.args[left.index];
181
- // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
182
- // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
183
- // cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
184
- // in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
185
- // a non-numeric arg or literal.
186
163
  if (op !== 'eq')
187
164
  return evalArgOrderedCompare(op, actual, right);
188
165
  return evalArgEq(op, actual, right, ctx);
@@ -192,11 +169,11 @@ function evalCompare(op, left, right, ctx) {
192
169
  if (left.kind === 'call_arg_scaled') {
193
170
  return evalScaledArgCompare(op, right, left, ctx);
194
171
  }
195
- // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
196
- // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
172
+ // --- step 4c: call_arg_len: length of a vec-typed argument as u32.
173
+ // Fails closed on a non-vec / absent arg or a non-u32 literal.
197
174
  if (left.kind === 'call_arg_len') {
198
175
  const actual = ctx.args[left.index];
199
- if (!actual || actual.type !== 'vec')
176
+ if (actual?.type !== 'vec')
200
177
  return { permit: false, reason: 'ARG_MISMATCH' };
201
178
  if (right.kind !== 'literal_u32')
202
179
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -204,16 +181,14 @@ function evalCompare(op, left, right, ctx) {
204
181
  ? { permit: true }
205
182
  : { permit: false, reason: 'ARG_MISMATCH' };
206
183
  }
207
- // --- step 4d: call_arg_field: the value of a field in the map at element i
208
- // of the vec at argument index. Fails closed on a non-vec arg, an
209
- // out-of-range element, a missing field, a non-map element, or a type
210
- // mismatch between the field ScVal and the literal leaf.
184
+ // --- step 4d: call_arg_field: value of a field in the map at element i of
185
+ // the vec at argument index. Fails closed on shape / type / range issues.
211
186
  if (left.kind === 'call_arg_field') {
212
187
  const actual = ctx.args[left.index];
213
- if (!actual || actual.type !== 'vec')
188
+ if (actual?.type !== 'vec')
214
189
  return { permit: false, reason: 'ARG_MISMATCH' };
215
190
  const element = actual.value[left.element];
216
- if (!element || element.type !== 'map')
191
+ if (element?.type !== 'map')
217
192
  return { permit: false, reason: 'ARG_MISMATCH' };
218
193
  if (!Array.isArray(element.value))
219
194
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -226,7 +201,7 @@ function evalCompare(op, left, right, ctx) {
226
201
  }
227
202
  // --- step 6: AMOUNT_BOUND ---
228
203
  if (left.kind === 'amount' && op !== 'eq') {
229
- return evalAmountCompare(op, left.token, right, ctx);
204
+ return evalAmountCompare(op, left.token, right, ctx, 'amountByToken');
230
205
  }
231
206
  if (left.kind === 'window_spent' && op !== 'eq') {
232
207
  return evalWindowSpentCompare(op, left.token, right, ctx);
@@ -247,7 +222,7 @@ function evalCompare(op, left, right, ctx) {
247
222
  * sequence: compare element-by-element in order; deny if length or any
248
223
  * element differs. Opaque args (`type: 'other'`) fail closed. */
249
224
  function evalArgEq(op, actual, right, ctx) {
250
- // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
225
+ // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
251
226
  if (op === 'eq' && right.kind === 'literal_vec') {
252
227
  if (actual?.type !== 'vec')
253
228
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -255,7 +230,6 @@ function evalArgEq(op, actual, right, ctx) {
255
230
  ? { permit: true }
256
231
  : { permit: false, reason: 'ARG_MISMATCH' };
257
232
  }
258
- // eq(call_arg[i], literal_address) -> address compare
259
233
  if (op === 'eq' && right.kind === 'literal_address') {
260
234
  if (!actual)
261
235
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -265,7 +239,6 @@ function evalArgEq(op, actual, right, ctx) {
265
239
  ? { permit: true }
266
240
  : { permit: false, reason: 'ARG_MISMATCH' };
267
241
  }
268
- // eq(call_arg[i], literal_i128) -> BigInt compare
269
242
  if (op === 'eq' && right.kind === 'literal_i128') {
270
243
  if (actual?.type !== 'i128')
271
244
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -273,7 +246,6 @@ function evalArgEq(op, actual, right, ctx) {
273
246
  ? { permit: true }
274
247
  : { permit: false, reason: 'ARG_MISMATCH' };
275
248
  }
276
- // eq(call_arg[i], literal_symbol)
277
249
  if (op === 'eq' && right.kind === 'literal_symbol') {
278
250
  if (!actual)
279
251
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -281,7 +253,6 @@ function evalArgEq(op, actual, right, ctx) {
281
253
  ? { permit: true }
282
254
  : { permit: false, reason: 'ARG_MISMATCH' };
283
255
  }
284
- // eq(call_arg[i], literal_u32)
285
256
  if (op === 'eq' && right.kind === 'literal_u32') {
286
257
  if (!actual)
287
258
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -289,7 +260,6 @@ function evalArgEq(op, actual, right, ctx) {
289
260
  ? { permit: true }
290
261
  : { permit: false, reason: 'ARG_MISMATCH' };
291
262
  }
292
- // eq(call_arg[i], literal_u64)
293
263
  if (op === 'eq' && right.kind === 'literal_u64') {
294
264
  if (!actual)
295
265
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -297,7 +267,6 @@ function evalArgEq(op, actual, right, ctx) {
297
267
  ? { permit: true }
298
268
  : { permit: false, reason: 'ARG_MISMATCH' };
299
269
  }
300
- // eq(call_arg[i], literal_bytes)
301
270
  if (op === 'eq' && right.kind === 'literal_bytes') {
302
271
  if (!actual)
303
272
  return { permit: false, reason: 'ARG_MISMATCH' };
@@ -310,20 +279,14 @@ function evalArgEq(op, actual, right, ctx) {
310
279
  return { permit: false, reason: 'ARG_MISMATCH' };
311
280
  return { permit: false, reason: 'ARG_MISMATCH' };
312
281
  }
313
- /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
314
- * path: the scaled leaf is `args[index] * num / den` (truncating toward
315
- * zero). On `checked_mul` / `checked_div` failure (overflow or
316
- * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
317
- * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
318
- * rather than the generic `ARG_MISMATCH`.
319
- *
320
- * `left` is whichever side of the compare is NOT the scaled leaf. A
321
- * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
322
- * leaves has no definable semantics). The other operand must be a
323
- * numeric shape (`call_arg` carrying a number, or a numeric literal);
324
- * anything else is `ARG_MISMATCH`. */
282
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
283
+ * the scaled leaf is `args[index] * num / den` (truncating toward zero). On
284
+ * overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
285
+ * denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
286
+ * `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
287
+ * (no definable semantics). The non-scaled operand must be numeric
288
+ * (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
325
289
  function evalScaledArgCompare(op, left, right, ctx) {
326
- // Identify which side is scaled, and pull the other side as a number.
327
290
  let scaled;
328
291
  let other;
329
292
  let scaledOnRight;
@@ -338,14 +301,12 @@ function evalScaledArgCompare(op, left, right, ctx) {
338
301
  scaledOnRight = true;
339
302
  }
340
303
  else {
341
- // Neither side is scaled - this is a programming error in the
342
- // dispatcher; the contract returns ARG_MISMATCH to fail closed.
304
+ // Programming error in the dispatcher; fail closed.
343
305
  return { permit: false, reason: 'ARG_MISMATCH' };
344
306
  }
345
- // Source `args[index]` -> BigInt. An out-of-bounds index or a
346
- // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
347
- // a violated floor is the wrong code when the operand itself could
348
- // not be read.
307
+ // Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
308
+ // SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
309
+ // itself could not be read.
349
310
  if (scaled.index >= ctx.args.length) {
350
311
  return { permit: false, reason: 'ARG_MISMATCH' };
351
312
  }
@@ -362,16 +323,11 @@ function evalScaledArgCompare(op, left, right, ctx) {
362
323
  return { permit: false, reason: 'ARG_MISMATCH' };
363
324
  }
364
325
  // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
365
- // check is a defensive belt-and-braces - a future validator
366
- // regression cannot panic the frame on a divide-by-zero.
326
+ // check is defensive belt-and-braces so a validator regression cannot
327
+ // panic the frame on a divide-by-zero.
367
328
  if (den === 0n)
368
329
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
369
330
  const product = input * num;
370
- // BigInt overflow is silent in JS (it does not throw on multiplication).
371
- // We can detect over-range by dividing and checking the result against
372
- // i128 bounds, but the more practical check is whether the result
373
- // divides cleanly. We rely on BigInt's arbitrary precision and then
374
- // surface ARITHMETIC_OVERFLOW if the result is outside i128.
375
331
  let scaledValue;
376
332
  try {
377
333
  // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
@@ -380,18 +336,13 @@ function evalScaledArgCompare(op, left, right, ctx) {
380
336
  catch {
381
337
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
382
338
  }
383
- // i128 range check: if the scaled value is outside i128, the contract
384
- // would have wrapped on i128 arithmetic; surface the same deny.
339
+ // i128 range check: the contract would have wrapped on i128 arithmetic;
340
+ // surface the same deny.
385
341
  const I128_MAX = (1n << 127n) - 1n;
386
342
  const I128_MIN = -(1n << 127n);
387
343
  if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
388
344
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
389
345
  }
390
- // Resolving the operand when the scaled side is on the right: the
391
- // operand is `left`; the scaled value is the RHS. The AST order is
392
- // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
393
- // When the scaled side is on the left, the order is `scaled <op> other`,
394
- // i.e. `scaled <op> other_val`.
395
346
  let otherVal;
396
347
  if (other.kind === 'call_arg') {
397
348
  otherVal = argNumericBigInt(ctx.args[other.index]);
@@ -459,7 +410,6 @@ function compareVecExact(actual, expected, ctx) {
459
410
  function evalIn(needle, haystack, ctx) {
460
411
  if (haystack.length === 0)
461
412
  return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
462
- // Resolve the needle's ScVal against the candidate call.
463
413
  const actual = resolveLeaf(needle, ctx);
464
414
  if (!actual || actual.type === 'other')
465
415
  return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
@@ -486,10 +436,10 @@ function resolveLeaf(leaf, ctx) {
486
436
  return undefined;
487
437
  case 'call_arg_field': {
488
438
  const actual = ctx.args[leaf.index];
489
- if (!actual || actual.type !== 'vec')
439
+ if (actual?.type !== 'vec')
490
440
  return undefined;
491
441
  const element = actual.value[leaf.element];
492
- if (!element || element.type !== 'map')
442
+ if (element?.type !== 'map')
493
443
  return undefined;
494
444
  if (!Array.isArray(element.value))
495
445
  return undefined;
@@ -520,25 +470,21 @@ function resolveLeaf(leaf, ctx) {
520
470
  return undefined;
521
471
  }
522
472
  }
523
- /** Step 6: amount compare on BigInt. */
524
- function evalAmountCompare(op, token, right, ctx) {
473
+ /** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
474
+ * per-token BigInt record (current vs. rolling) to an i128 literal; the
475
+ * compare + denial reason are identical, so a single helper handles both. */
476
+ function evalAmountCompare(op, token, right, ctx, record) {
525
477
  const literal = right.kind === 'literal_i128' ? right.value : null;
526
478
  if (literal === null)
527
479
  return { permit: false, reason: 'AMOUNT_BOUND' };
528
- const actual = ctx.amountByToken[token] ?? '0';
480
+ const actual = ctx[record][token] ?? '0';
529
481
  return bigintCmp(op, actual, literal)
530
482
  ? { permit: true }
531
483
  : { permit: false, reason: 'AMOUNT_BOUND' };
532
484
  }
533
485
  /** Step 6: window_spent compare on BigInt. */
534
486
  function evalWindowSpentCompare(op, token, right, ctx) {
535
- const literal = right.kind === 'literal_i128' ? right.value : null;
536
- if (literal === null)
537
- return { permit: false, reason: 'AMOUNT_BOUND' };
538
- const actual = ctx.windowSpentByToken[token] ?? '0';
539
- return bigintCmp(op, actual, literal)
540
- ? { permit: true }
541
- : { permit: false, reason: 'AMOUNT_BOUND' };
487
+ return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken');
542
488
  }
543
489
  /** Step 7: invocation_count_in_window compare. */
544
490
  function evalFrequencyCompare(op, windowSecs, right, ctx) {
@@ -562,24 +508,28 @@ function evalOracleCompare(op, asset, right, ctx) {
562
508
  throw new OracleError(mapped);
563
509
  }
564
510
  // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
565
- // decimal basis: prices are on the normalised 9-dp basis, and assuming a
566
- // bare literal shares it is what let a raw 14-dp threshold permit
567
- // everything. A bare literal is refused rather than assumed.
511
+ // decimal basis: prices are on the normalised 9-dp basis, and a bare literal
512
+ // is refused (not assumed) because a raw 14-dp threshold that assumed the
513
+ // 9-dp basis would permit everything.
568
514
  if (right.kind !== 'oracle_threshold')
569
515
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
570
516
  if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
571
517
  throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
572
518
  }
573
- // Scale BOTH sides up to the wider basis rather than dividing the threshold
574
- // down: dividing truncates, and a truncated bound moves the permit boundary.
519
+ // Scale BOTH sides up to the wider basis; dividing the threshold down
520
+ // truncates and moves the permit boundary.
575
521
  const decimals = BigInt(right.decimals);
576
522
  const normalised = BigInt(NORMALISED_DECIMALS);
577
523
  const common = decimals > normalised ? decimals : normalised;
578
524
  const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
579
525
  const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
526
+ // A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
527
+ // `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
528
+ // the same reason so a future TS/Rust reason divergence fails CI; the
529
+ // cross-layer harness in this file now asserts reason codes match.
580
530
  return bigintCmp(op, String(priceScaled), String(literalScaled))
581
531
  ? { permit: true }
582
- : { permit: false, reason: 'FN_MISMATCH' };
532
+ : { permit: false, reason: 'STATEFUL_BOUND' };
583
533
  }
584
534
  /** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
585
535
  * 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;
@@ -1,5 +1,10 @@
1
1
  import { evaluate } from "./evaluate.js";
2
- /** Evaluate the intended call and every generated denial without side effects. */
2
+ /** Evaluate the intended call and every generated denial without side effects.
3
+ * Asserts both the boolean decision AND, for deny cases that carry an
4
+ * `expectedReason`, the concrete reason the TS evaluator emits. The reason
5
+ * is what the Rust interpreter returns; the cross-layer conformance test in
6
+ * `contracts/policy-interpreter/tests/conformance` mirrors the same contract
7
+ * for the verifyLive path. */
3
8
  export function runHarness(predicate, cases) {
4
9
  const failures = [];
5
10
  const permitResult = evaluate(predicate, cases.permit);
@@ -20,6 +25,22 @@ export function runHarness(predicate, cases) {
20
25
  got: 'permit',
21
26
  reason: 'DENY_CASE_FAILURE',
22
27
  });
28
+ continue;
29
+ }
30
+ // Reason-code assertion (cross-layer contract). When a deny case carries
31
+ // an expected reason, the TS evaluator MUST emit that exact string; a
32
+ // divergence means the TS model and the Rust interpreter disagree on
33
+ // what the user sees in CI - the harness fails so the diff is visible
34
+ // here, not at runtime in production.
35
+ if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
36
+ failures.push({
37
+ dimension: deny.dimension,
38
+ expected: 'deny',
39
+ got: 'deny',
40
+ reason: 'REASON_MISMATCH',
41
+ expectedReason: deny.expectedReason,
42
+ actualReason: result.reason,
43
+ });
23
44
  }
24
45
  }
25
46
  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
+ }