@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
@@ -6,6 +6,11 @@ import { literalNumericBigInt } from './predicate-literals.ts'
6
6
  export interface DenyCase {
7
7
  dimension: string
8
8
  ctx: EvalContext
9
+ /** Canonical reason the Rust interpreter returns for this dimension.
10
+ * Optional: when set, `runHarness` asserts the TS evaluator emits this
11
+ * exact string, so a future TS/Rust reason divergence fails CI. When
12
+ * absent the case is only checked on the boolean decision. */
13
+ expectedReason?: string
9
14
  }
10
15
 
11
16
  export interface GeneratedCases {
@@ -39,18 +44,32 @@ const ORACLE_CASES = [
39
44
  ['oracle_paused', 'paused'],
40
45
  ] as const
41
46
 
47
+ // Cross-layer reason contract: the TS evaluator's deny reason for each
48
+ // oracle error category must match the Rust interpreter's `DenyReason` code
49
+ // string (the same strings surface in the conformance fixture). The harness
50
+ // asserts them via the per-case `expectedReason`; a future divergence
51
+ // surfaces as a REASON_MISMATCH failure.
52
+ const ORACLE_ERROR_REASON: Record<(typeof ORACLE_CASES)[number][1], string> = {
53
+ stale: 'ORACLE_STALE',
54
+ missing: 'ORACLE_MISSING',
55
+ deviation: 'ORACLE_DEVIATION_EXCEEDED',
56
+ paused: 'ORACLE_PAUSED',
57
+ }
58
+
42
59
  // Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
43
60
  const ADJACENT_ASSETS = [
44
61
  'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
45
62
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
46
63
  ] as const
47
64
 
48
- // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
65
+ // Property-harness mutation dimensions excluded from the synth pipeline's
49
66
  // self-verify call so existing fixtures still emit policies. The harness tests
50
67
  // them as FINDINGS against the already-emitted policy.
51
68
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
69
 
53
- // The 15 dimensions the synth pipeline uses for self-verify and minimise.
70
+ // The dimensions the synth pipeline uses for self-verify and minimise. No count
71
+ // is written here on purpose: this comment said 15 while the array held 17, and
72
+ // anyone checking the number should read the array rather than the prose.
54
73
  // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
55
74
  // alongside the existing dimensions so the per-element binds emitted for
56
75
  // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
@@ -106,7 +125,7 @@ export function generateCases(
106
125
  comparison.left.token,
107
126
  comparison
108
127
  )
109
- if (mutated) denies.push({ dimension: 'amount', ctx: mutated })
128
+ if (mutated) denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
110
129
  }
111
130
 
112
131
  const movedTokens = new Set<string>()
@@ -116,6 +135,15 @@ export function generateCases(
116
135
  }
117
136
  }
118
137
  for (const token of movedTokens) {
138
+ // The `asset` deny case mutates BOTH the amount record AND the contract
139
+ // binding (token's value moved to an adjacent asset; the same swap applied
140
+ // to any address-typed `call_arg` / `call_contract` literal). The actual
141
+ // deny reason is therefore predicate-dependent - the order of evaluation
142
+ // is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
143
+ // that has both bindings denies with CONTRACT_SCOPE, while a policy with
144
+ // only an amount binding denies with AMOUNT_BOUND. The reason assertion
145
+ // would be brittle here; the boolean decision is what we strictly need
146
+ // to pin. Leave `expectedReason` unset for this dimension.
119
147
  denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) })
120
148
  }
121
149
 
@@ -131,7 +159,7 @@ export function generateCases(
131
159
  for (const _constraint of contractConstraints) {
132
160
  const ctx = cloneContext(permitCtx)
133
161
  ctx.contract = distinctText(permitCtx.contract, 'contract')
134
- denies.push({ dimension: 'contract', ctx })
162
+ denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' })
135
163
  }
136
164
 
137
165
  const functionConstraints = [
@@ -144,13 +172,13 @@ export function generateCases(
144
172
  for (const _constraint of functionConstraints) {
145
173
  const ctx = cloneContext(permitCtx)
146
174
  ctx.fn = distinctText(permitCtx.fn, 'function')
147
- denies.push({ dimension: 'function', ctx })
175
+ denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' })
148
176
  }
149
177
 
150
178
  if (permitCtx.validUntilLedger !== undefined) {
151
179
  const ctx = cloneContext(permitCtx)
152
180
  ctx.atLedger = permitCtx.validUntilLedger + 1
153
- denies.push({ dimension: 'timing', ctx })
181
+ denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' })
154
182
  }
155
183
 
156
184
  for (const comparison of facts.comparisons) {
@@ -162,7 +190,8 @@ export function generateCases(
162
190
  comparison,
163
191
  false
164
192
  )
165
- if (mutated) denies.push({ dimension: 'time_window', ctx: mutated })
193
+ if (mutated)
194
+ denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
166
195
  }
167
196
 
168
197
  for (const comparison of facts.comparisons) {
@@ -173,7 +202,7 @@ export function generateCases(
173
202
  comparison.op,
174
203
  comparison.right.value
175
204
  )
176
- denies.push({ dimension: 'invocation_count', ctx })
205
+ denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' })
177
206
  }
178
207
 
179
208
  // Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
@@ -187,7 +216,7 @@ export function generateCases(
187
216
  if (!violating) continue
188
217
  const ctx = cloneContext(permitCtx)
189
218
  ctx.args[comparison.left.index] = violating
190
- denies.push({ dimension: 'arg_amount_bound', ctx })
219
+ denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' })
191
220
  }
192
221
 
193
222
  const argumentConstraints: Array<{ index: number }> = []
@@ -208,6 +237,15 @@ export function generateCases(
208
237
  for (const constraint of argumentConstraints) {
209
238
  const ctx = cloneContext(permitCtx)
210
239
  ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' }
240
+ // The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
241
+ // depends on the predicate shape:
242
+ // - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
243
+ // - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
244
+ // (opaque needles fail-closed at the `in` membership gate, step 5)
245
+ // Both are correct in the TS evaluator; the reason is predicate-dependent
246
+ // so we cannot pin a single canonical reason here. The boolean decision
247
+ // is what we strictly need to assert; the reason is recorded (not asserted)
248
+ // when the harness runs this case.
211
249
  denies.push({ dimension: 'arg_bound', ctx })
212
250
  }
213
251
 
@@ -232,6 +270,14 @@ export function generateCases(
232
270
  for (const index of scopedArgumentIndices) {
233
271
  ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' }
234
272
  }
273
+ // `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
274
+ // The first failing child of the AND decides the reason, and the contract
275
+ // check (step 3) does fire first in the evaluator. The reason is therefore
276
+ // CONTRACT_SCOPE for the canonical case, but a predicate that lists the
277
+ // call_fn leaf first (or that has a `call_arg_field` for that arg index)
278
+ // can flip the order. Pin the assertion here only when no `in` /
279
+ // call_arg_field binds the same arg index exist - the canonical blend
280
+ // case does have those binds, so the assertion would fire there.
235
281
  denies.push({ dimension: 'scope_contract_fn_arg', ctx })
236
282
  }
237
283
 
@@ -241,7 +287,7 @@ export function generateCases(
241
287
  if (comparison.left.kind !== 'oracle_price') continue
242
288
  const ctx = cloneContext(permitCtx)
243
289
  ctx.oraclePriceByAsset[comparison.left.asset] = { error }
244
- denies.push({ dimension, ctx })
290
+ denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] })
245
291
  }
246
292
  }
247
293
 
@@ -298,9 +344,9 @@ export function generateCases(
298
344
  const sel = comparison.left
299
345
  if (sel.kind !== 'call_arg_field') continue
300
346
  const arg = permitCtx.args[sel.index]
301
- if (!arg || arg.type !== 'vec') continue
347
+ if (arg?.type !== 'vec') continue
302
348
  const element = arg.value[sel.element]
303
- if (!element || element.type !== 'map' || !Array.isArray(element.value)) continue
349
+ if (element?.type !== 'map' || !Array.isArray(element.value)) continue
304
350
  const entry = element.value.find((e) => e.key === sel.field)
305
351
  if (!entry) continue
306
352
  const flipped = flipFieldValue(entry.val)
@@ -326,7 +372,7 @@ export function generateCases(
326
372
  const sel = comparison.left
327
373
  if (sel.kind !== 'call_arg_len') continue
328
374
  const arg = permitCtx.args[sel.index]
329
- if (!arg || arg.type !== 'vec') continue
375
+ if (arg?.type !== 'vec') continue
330
376
  const ctx = cloneContext(permitCtx)
331
377
  ctx.args[sel.index] = {
332
378
  type: 'vec',
@@ -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
 
32
22
  import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
33
23
  import { literalNumericBigInt } from './predicate-literals.ts'
@@ -70,8 +60,8 @@ export interface EvalContext {
70
60
  export type EvalResult = { permit: true } | { permit: false; reason: string }
71
61
 
72
62
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
73
- * and converted to the matching `ORACLE_*` deny reason. NOT a
74
- * boolean-false that `not` / `or` could mask. */
63
+ * and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
64
+ * that `not` / `or` could mask. */
75
65
  class OracleError extends Error {
76
66
  readonly code: string
77
67
  constructor(code: string) {
@@ -129,12 +119,9 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
129
119
  for (const child of node.children) {
130
120
  const r = walk(child, ctx)
131
121
  if (!r.permit) {
132
- // deny-on-first: short-circuit. The "active branch" is the failing
133
- // child, so the reason we surface is from that child.
122
+ // deny-on-first: short-circuit on the failing child.
134
123
  return r
135
124
  }
136
- // keep a reference to the last permit so unused-variable analysis
137
- // doesn't complain; this is purely structural.
138
125
  lastDeny = r
139
126
  }
140
127
  return lastDeny ?? { permit: true }
@@ -149,11 +136,9 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
149
136
  return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' }
150
137
  }
151
138
  case 'not': {
152
- // `not` structurally inverts the child UNLESS the child contains an
153
- // oracle leaf (compile-time rule: no oracle leaf under not/or). Here we
154
- // walk the child; if it throws `OracleError`, the catch at the top of
155
- // `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
156
- // deny is inverted to a permit, and a permit is inverted to a deny.
139
+ // `not` structurally inverts the child unless the child contains an
140
+ // oracle leaf (compile-time rule: no oracle leaf under not/or). A
141
+ // child `OracleError` is re-thrown to the catch at the top of `evaluate`.
157
142
  const r = walk(node.child, ctx)
158
143
  if (r.permit) return { permit: false, reason: 'FN_MISMATCH' }
159
144
  return { permit: true }
@@ -179,15 +164,12 @@ function evalCompare(
179
164
  // --- step 2: `now` vs `valid_until` semantics ---
180
165
  if (left.kind === 'now' && right.kind === 'valid_until') {
181
166
  const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0
182
- // only `gt` / `gte` / `lt` / `lte` are meaningful here. Treat `eq` /
183
- // any other combo as: "expired iff nowSeconds > validUntilSeconds".
184
- // We model `valid_until` as a synthetic future timestamp far past
185
- // `nowSeconds` so the only true-positive expired path is the
186
- // `gt`/`gte` shapes callers actually write.
167
+ // `valid_until` is modelled as a synthetic future timestamp far past
168
+ // `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
169
+ // shapes callers actually write.
187
170
  if (op === 'gt' || op === 'gte') {
188
171
  if (ctx.nowSeconds > 0) return { permit: false, reason: 'EXPIRED' }
189
172
  } else if (op === 'lt' || op === 'lte') {
190
- // permits when valid_until is in the future
191
173
  return { permit: true }
192
174
  }
193
175
  return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true }
@@ -220,11 +202,6 @@ function evalCompare(
220
202
  return evalScaledArgCompare(op, left, right, ctx)
221
203
  }
222
204
  const actual = ctx.args[left.index]
223
- // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
224
- // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
225
- // cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
226
- // in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
227
- // a non-numeric arg or literal.
228
205
  if (op !== 'eq') return evalArgOrderedCompare(op, actual, right)
229
206
  return evalArgEq(op, actual, right, ctx)
230
207
  }
@@ -235,26 +212,24 @@ function evalCompare(
235
212
  return evalScaledArgCompare(op, right, left, ctx)
236
213
  }
237
214
 
238
- // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
239
- // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
215
+ // --- step 4c: call_arg_len: length of a vec-typed argument as u32.
216
+ // Fails closed on a non-vec / absent arg or a non-u32 literal.
240
217
  if (left.kind === 'call_arg_len') {
241
218
  const actual = ctx.args[left.index]
242
- if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
219
+ if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
243
220
  if (right.kind !== 'literal_u32') return { permit: false, reason: 'ARG_MISMATCH' }
244
221
  return actual.value.length === right.value
245
222
  ? { permit: true }
246
223
  : { permit: false, reason: 'ARG_MISMATCH' }
247
224
  }
248
225
 
249
- // --- step 4d: call_arg_field: the value of a field in the map at element i
250
- // of the vec at argument index. Fails closed on a non-vec arg, an
251
- // out-of-range element, a missing field, a non-map element, or a type
252
- // mismatch between the field ScVal and the literal leaf.
226
+ // --- step 4d: call_arg_field: value of a field in the map at element i of
227
+ // the vec at argument index. Fails closed on shape / type / range issues.
253
228
  if (left.kind === 'call_arg_field') {
254
229
  const actual = ctx.args[left.index]
255
- if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
230
+ if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
256
231
  const element = actual.value[left.element]
257
- if (!element || element.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
232
+ if (element?.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
258
233
  if (!Array.isArray(element.value)) return { permit: false, reason: 'ARG_MISMATCH' }
259
234
  const entry = element.value.find((e) => e.key === left.field)
260
235
  if (!entry) return { permit: false, reason: 'ARG_MISMATCH' }
@@ -264,7 +239,7 @@ function evalCompare(
264
239
 
265
240
  // --- step 6: AMOUNT_BOUND ---
266
241
  if (left.kind === 'amount' && op !== 'eq') {
267
- return evalAmountCompare(op, left.token, right, ctx)
242
+ return evalAmountCompare(op, left.token, right, ctx, 'amountByToken')
268
243
  }
269
244
  if (left.kind === 'window_spent' && op !== 'eq') {
270
245
  return evalWindowSpentCompare(op, left.token, right, ctx)
@@ -294,7 +269,7 @@ function evalArgEq(
294
269
  right: PredicateLeaf,
295
270
  ctx: EvalContext
296
271
  ): EvalResult {
297
- // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
272
+ // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
298
273
  if (op === 'eq' && right.kind === 'literal_vec') {
299
274
  if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
300
275
  return compareVecExact(actual.value, right.elements, ctx)
@@ -302,7 +277,6 @@ function evalArgEq(
302
277
  : { permit: false, reason: 'ARG_MISMATCH' }
303
278
  }
304
279
 
305
- // eq(call_arg[i], literal_address) -> address compare
306
280
  if (op === 'eq' && right.kind === 'literal_address') {
307
281
  if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
308
282
  if (actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
@@ -311,7 +285,6 @@ function evalArgEq(
311
285
  : { permit: false, reason: 'ARG_MISMATCH' }
312
286
  }
313
287
 
314
- // eq(call_arg[i], literal_i128) -> BigInt compare
315
288
  if (op === 'eq' && right.kind === 'literal_i128') {
316
289
  if (actual?.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
317
290
  return BigInt(actual.value) === BigInt(right.value)
@@ -319,7 +292,6 @@ function evalArgEq(
319
292
  : { permit: false, reason: 'ARG_MISMATCH' }
320
293
  }
321
294
 
322
- // eq(call_arg[i], literal_symbol)
323
295
  if (op === 'eq' && right.kind === 'literal_symbol') {
324
296
  if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
325
297
  return actual.type === 'symbol' && actual.value === right.value
@@ -327,7 +299,6 @@ function evalArgEq(
327
299
  : { permit: false, reason: 'ARG_MISMATCH' }
328
300
  }
329
301
 
330
- // eq(call_arg[i], literal_u32)
331
302
  if (op === 'eq' && right.kind === 'literal_u32') {
332
303
  if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
333
304
  return actual.type === 'u32' && actual.value === String(right.value)
@@ -335,7 +306,6 @@ function evalArgEq(
335
306
  : { permit: false, reason: 'ARG_MISMATCH' }
336
307
  }
337
308
 
338
- // eq(call_arg[i], literal_u64)
339
309
  if (op === 'eq' && right.kind === 'literal_u64') {
340
310
  if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
341
311
  return actual.type === 'u64' && actual.value === right.value
@@ -343,7 +313,6 @@ function evalArgEq(
343
313
  : { permit: false, reason: 'ARG_MISMATCH' }
344
314
  }
345
315
 
346
- // eq(call_arg[i], literal_bytes)
347
316
  if (op === 'eq' && right.kind === 'literal_bytes') {
348
317
  if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
349
318
  return actual.type === 'bytes' && actual.value === right.value
@@ -356,25 +325,19 @@ function evalArgEq(
356
325
  return { permit: false, reason: 'ARG_MISMATCH' }
357
326
  }
358
327
 
359
- /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
360
- * path: the scaled leaf is `args[index] * num / den` (truncating toward
361
- * zero). On `checked_mul` / `checked_div` failure (overflow or
362
- * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
363
- * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
364
- * rather than the generic `ARG_MISMATCH`.
365
- *
366
- * `left` is whichever side of the compare is NOT the scaled leaf. A
367
- * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
368
- * leaves has no definable semantics). The other operand must be a
369
- * numeric shape (`call_arg` carrying a number, or a numeric literal);
370
- * anything else is `ARG_MISMATCH`. */
328
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
329
+ * the scaled leaf is `args[index] * num / den` (truncating toward zero). On
330
+ * overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
331
+ * denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
332
+ * `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
333
+ * (no definable semantics). The non-scaled operand must be numeric
334
+ * (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
371
335
  function evalScaledArgCompare(
372
336
  op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
373
337
  left: PredicateLeaf,
374
338
  right: PredicateLeaf,
375
339
  ctx: EvalContext
376
340
  ): EvalResult {
377
- // Identify which side is scaled, and pull the other side as a number.
378
341
  let scaled: { index: number; num: string; den: string }
379
342
  let other: PredicateLeaf
380
343
  let scaledOnRight: boolean
@@ -387,14 +350,12 @@ function evalScaledArgCompare(
387
350
  other = left
388
351
  scaledOnRight = true
389
352
  } else {
390
- // Neither side is scaled - this is a programming error in the
391
- // dispatcher; the contract returns ARG_MISMATCH to fail closed.
353
+ // Programming error in the dispatcher; fail closed.
392
354
  return { permit: false, reason: 'ARG_MISMATCH' }
393
355
  }
394
- // Source `args[index]` -> BigInt. An out-of-bounds index or a
395
- // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
396
- // a violated floor is the wrong code when the operand itself could
397
- // not be read.
356
+ // Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
357
+ // SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
358
+ // itself could not be read.
398
359
  if (scaled.index >= ctx.args.length) {
399
360
  return { permit: false, reason: 'ARG_MISMATCH' }
400
361
  }
@@ -409,15 +370,10 @@ function evalScaledArgCompare(
409
370
  return { permit: false, reason: 'ARG_MISMATCH' }
410
371
  }
411
372
  // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
412
- // check is a defensive belt-and-braces - a future validator
413
- // regression cannot panic the frame on a divide-by-zero.
373
+ // check is defensive belt-and-braces so a validator regression cannot
374
+ // panic the frame on a divide-by-zero.
414
375
  if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
415
376
  const product = input * num
416
- // BigInt overflow is silent in JS (it does not throw on multiplication).
417
- // We can detect over-range by dividing and checking the result against
418
- // i128 bounds, but the more practical check is whether the result
419
- // divides cleanly. We rely on BigInt's arbitrary precision and then
420
- // surface ARITHMETIC_OVERFLOW if the result is outside i128.
421
377
  let scaledValue: bigint
422
378
  try {
423
379
  // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
@@ -425,18 +381,13 @@ function evalScaledArgCompare(
425
381
  } catch {
426
382
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
427
383
  }
428
- // i128 range check: if the scaled value is outside i128, the contract
429
- // would have wrapped on i128 arithmetic; surface the same deny.
384
+ // i128 range check: the contract would have wrapped on i128 arithmetic;
385
+ // surface the same deny.
430
386
  const I128_MAX = (1n << 127n) - 1n
431
387
  const I128_MIN = -(1n << 127n)
432
388
  if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
433
389
  return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
434
390
  }
435
- // Resolving the operand when the scaled side is on the right: the
436
- // operand is `left`; the scaled value is the RHS. The AST order is
437
- // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
438
- // When the scaled side is on the left, the order is `scaled <op> other`,
439
- // i.e. `scaled <op> other_val`.
440
391
  let otherVal: bigint | null
441
392
  if (other.kind === 'call_arg') {
442
393
  otherVal = argNumericBigInt(ctx.args[other.index])
@@ -504,7 +455,6 @@ function compareVecExact(actual: ScVal[], expected: PredicateLeaf[], ctx: EvalCo
504
455
  * fails closed. */
505
456
  function evalIn(needle: PredicateLeaf, haystack: PredicateLeaf[], ctx: EvalContext): EvalResult {
506
457
  if (haystack.length === 0) return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
507
- // Resolve the needle's ScVal against the candidate call.
508
458
  const actual = resolveLeaf(needle, ctx)
509
459
  if (!actual || actual.type === 'other') return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
510
460
  for (const h of haystack) {
@@ -530,9 +480,9 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
530
480
  return undefined
531
481
  case 'call_arg_field': {
532
482
  const actual = ctx.args[leaf.index]
533
- if (!actual || actual.type !== 'vec') return undefined
483
+ if (actual?.type !== 'vec') return undefined
534
484
  const element = actual.value[leaf.element]
535
- if (!element || element.type !== 'map') return undefined
485
+ if (element?.type !== 'map') return undefined
536
486
  if (!Array.isArray(element.value)) return undefined
537
487
  const entry = element.value.find((e) => e.key === leaf.field)
538
488
  return entry ? entry.val : undefined
@@ -562,16 +512,19 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
562
512
  }
563
513
  }
564
514
 
565
- /** Step 6: amount compare on BigInt. */
515
+ /** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
516
+ * per-token BigInt record (current vs. rolling) to an i128 literal; the
517
+ * compare + denial reason are identical, so a single helper handles both. */
566
518
  function evalAmountCompare(
567
519
  op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
568
520
  token: string,
569
521
  right: PredicateLeaf,
570
- ctx: EvalContext
522
+ ctx: EvalContext,
523
+ record: 'amountByToken' | 'windowSpentByToken'
571
524
  ): EvalResult {
572
525
  const literal = right.kind === 'literal_i128' ? right.value : null
573
526
  if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
574
- const actual = ctx.amountByToken[token] ?? '0'
527
+ const actual = ctx[record][token] ?? '0'
575
528
  return bigintCmp(op, actual, literal)
576
529
  ? { permit: true }
577
530
  : { permit: false, reason: 'AMOUNT_BOUND' }
@@ -584,12 +537,7 @@ function evalWindowSpentCompare(
584
537
  right: PredicateLeaf,
585
538
  ctx: EvalContext
586
539
  ): EvalResult {
587
- const literal = right.kind === 'literal_i128' ? right.value : null
588
- if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
589
- const actual = ctx.windowSpentByToken[token] ?? '0'
590
- return bigintCmp(op, actual, literal)
591
- ? { permit: true }
592
- : { permit: false, reason: 'AMOUNT_BOUND' }
540
+ return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken')
593
541
  }
594
542
 
595
543
  /** Step 7: invocation_count_in_window compare. */
@@ -622,23 +570,27 @@ function evalOracleCompare(
622
570
  throw new OracleError(mapped)
623
571
  }
624
572
  // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
625
- // decimal basis: prices are on the normalised 9-dp basis, and assuming a
626
- // bare literal shares it is what let a raw 14-dp threshold permit
627
- // everything. A bare literal is refused rather than assumed.
573
+ // decimal basis: prices are on the normalised 9-dp basis, and a bare literal
574
+ // is refused (not assumed) because a raw 14-dp threshold that assumed the
575
+ // 9-dp basis would permit everything.
628
576
  if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
629
577
  if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
630
578
  throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
631
579
  }
632
- // Scale BOTH sides up to the wider basis rather than dividing the threshold
633
- // down: dividing truncates, and a truncated bound moves the permit boundary.
580
+ // Scale BOTH sides up to the wider basis; dividing the threshold down
581
+ // truncates and moves the permit boundary.
634
582
  const decimals = BigInt(right.decimals)
635
583
  const normalised = BigInt(NORMALISED_DECIMALS)
636
584
  const common = decimals > normalised ? decimals : normalised
637
585
  const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
638
586
  const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
587
+ // A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
588
+ // `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
589
+ // the same reason so a future TS/Rust reason divergence fails CI; the
590
+ // cross-layer harness in this file now asserts reason codes match.
639
591
  return bigintCmp(op, String(priceScaled), String(literalScaled))
640
592
  ? { permit: true }
641
- : { permit: false, reason: 'FN_MISMATCH' }
593
+ : { permit: false, reason: 'STATEFUL_BOUND' }
642
594
  }
643
595
 
644
596
  /** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
@@ -7,11 +7,23 @@ export interface HarnessFailure {
7
7
  expected: 'permit' | 'deny'
8
8
  got: 'permit' | 'deny'
9
9
  reason?: string
10
+ /** When `expectedReason` is set on the deny case and the case actually
11
+ * denies, this carries the canonical reason the TS evaluator MUST emit
12
+ * to stay in lockstep with the Rust interpreter (the cross-layer
13
+ * reason-code contract). A future TS/Rust reason divergence surfaces
14
+ * here in CI. */
15
+ expectedReason?: string
16
+ actualReason?: string
10
17
  }
11
18
 
12
19
  export type HarnessResult = { ok: true } | { ok: false; failures: HarnessFailure[] }
13
20
 
14
- /** Evaluate the intended call and every generated denial without side effects. */
21
+ /** Evaluate the intended call and every generated denial without side effects.
22
+ * Asserts both the boolean decision AND, for deny cases that carry an
23
+ * `expectedReason`, the concrete reason the TS evaluator emits. The reason
24
+ * is what the Rust interpreter returns; the cross-layer conformance test in
25
+ * `contracts/policy-interpreter/tests/conformance` mirrors the same contract
26
+ * for the verifyLive path. */
15
27
  export function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult {
16
28
  const failures: HarnessFailure[] = []
17
29
  const permitResult = evaluate(predicate, cases.permit)
@@ -33,6 +45,22 @@ export function runHarness(predicate: PredicateNode, cases: GeneratedCases): Har
33
45
  got: 'permit',
34
46
  reason: 'DENY_CASE_FAILURE',
35
47
  })
48
+ continue
49
+ }
50
+ // Reason-code assertion (cross-layer contract). When a deny case carries
51
+ // an expected reason, the TS evaluator MUST emit that exact string; a
52
+ // divergence means the TS model and the Rust interpreter disagree on
53
+ // what the user sees in CI - the harness fails so the diff is visible
54
+ // here, not at runtime in production.
55
+ if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
56
+ failures.push({
57
+ dimension: deny.dimension,
58
+ expected: 'deny',
59
+ got: 'deny',
60
+ reason: 'REASON_MISMATCH',
61
+ expectedReason: deny.expectedReason,
62
+ actualReason: result.reason,
63
+ })
36
64
  }
37
65
  }
38
66
 
@@ -30,6 +30,7 @@ export {
30
30
  } from './scope.ts'
31
31
  export { synthesizeFromMandate } from './synthesize-from-mandate.ts'
32
32
  export {
33
+ type __TestInterpreterAdapterOptions,
33
34
  type SynthesizeFromRecordingOptions,
34
35
  synthesizeFromRecording,
35
36
  } from './synthesize-from-recording.ts'