@crediolabs/policy-synth 0.2.0 → 0.3.1

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 (249) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/index.d.ts +1 -4
  6. package/dist/index.js +1 -4
  7. package/dist/install/build-add-context-rule.d.ts +3 -19
  8. package/dist/install/build-add-context-rule.js +3 -98
  9. package/dist/install/build-install-policy.d.ts +4 -53
  10. package/dist/install/build-install-policy.js +44 -123
  11. package/dist/install/index.d.ts +0 -2
  12. package/dist/install/index.js +0 -7
  13. package/dist/predicate/decode.d.ts +1 -1
  14. package/dist/predicate/decode.js +2 -70
  15. package/dist/predicate/encode.js +45 -229
  16. package/dist/predicate/from-json.js +1 -42
  17. package/dist/record/decode.js +3 -8
  18. package/dist/record/freshness.d.ts +17 -11
  19. package/dist/record/freshness.js +28 -18
  20. package/dist/review-card/builder.d.ts +4 -3
  21. package/dist/review-card/builder.js +18 -162
  22. package/dist/review-card/cross-check.js +2 -105
  23. package/dist/review-card/render-leaf.d.ts +4 -0
  24. package/dist/review-card/render-leaf.js +47 -0
  25. package/dist/run/index.d.ts +36 -139
  26. package/dist/run/index.js +125 -437
  27. package/dist/run/schemas.d.ts +168 -1929
  28. package/dist/run/schemas.js +40 -269
  29. package/dist/{synth → simulate}/deny-cases.d.ts +4 -8
  30. package/dist/simulate/deny-cases.js +282 -0
  31. package/dist/simulate/evaluate.d.ts +18 -0
  32. package/dist/simulate/evaluate.js +271 -0
  33. package/dist/simulate/index.d.ts +4 -0
  34. package/dist/simulate/index.js +8 -0
  35. package/dist/synth/compose-from-recording.d.ts +24 -51
  36. package/dist/synth/compose-from-recording.js +120 -282
  37. package/dist/synth/index.d.ts +0 -5
  38. package/dist/synth/index.js +0 -5
  39. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  40. package/dist/synth/synthesize-from-recording.js +41 -356
  41. package/dist/types.d.ts +8 -60
  42. package/dist/types.js +5 -2
  43. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  44. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  45. package/dist-cjs/errors.d.ts +1 -1
  46. package/dist-cjs/index.d.ts +1 -4
  47. package/dist-cjs/index.js +1 -4
  48. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  49. package/dist-cjs/install/build-add-context-rule.js +1 -97
  50. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  51. package/dist-cjs/install/build-install-policy.js +44 -122
  52. package/dist-cjs/install/index.d.ts +0 -2
  53. package/dist-cjs/install/index.js +2 -23
  54. package/dist-cjs/predicate/decode.d.ts +1 -1
  55. package/dist-cjs/predicate/decode.js +2 -70
  56. package/dist-cjs/predicate/encode.js +45 -229
  57. package/dist-cjs/predicate/from-json.js +1 -42
  58. package/dist-cjs/record/decode.js +3 -8
  59. package/dist-cjs/record/freshness.d.ts +17 -11
  60. package/dist-cjs/record/freshness.js +28 -18
  61. package/dist-cjs/review-card/builder.d.ts +4 -3
  62. package/dist-cjs/review-card/builder.js +23 -167
  63. package/dist-cjs/review-card/cross-check.js +7 -110
  64. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  65. package/dist-cjs/review-card/render-leaf.js +52 -0
  66. package/dist-cjs/run/index.d.ts +36 -139
  67. package/dist-cjs/run/index.js +125 -444
  68. package/dist-cjs/run/schemas.d.ts +168 -1929
  69. package/dist-cjs/run/schemas.js +41 -270
  70. package/dist-cjs/{synth → simulate}/deny-cases.d.ts +4 -8
  71. package/dist-cjs/simulate/deny-cases.js +286 -0
  72. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  73. package/dist-cjs/simulate/evaluate.js +274 -0
  74. package/dist-cjs/simulate/index.d.ts +4 -0
  75. package/dist-cjs/simulate/index.js +13 -0
  76. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  77. package/dist-cjs/synth/compose-from-recording.js +120 -282
  78. package/dist-cjs/synth/index.d.ts +0 -5
  79. package/dist-cjs/synth/index.js +1 -11
  80. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  81. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  82. package/dist-cjs/types.d.ts +8 -60
  83. package/dist-cjs/types.js +6 -3
  84. package/package.json +5 -5
  85. package/src/adapters/interpreter/adapter.ts +76 -572
  86. package/src/errors.ts +0 -19
  87. package/src/index.ts +1 -4
  88. package/src/install/build-add-context-rule.ts +5 -139
  89. package/src/install/build-install-policy.ts +93 -214
  90. package/src/install/index.ts +0 -34
  91. package/src/predicate/decode.ts +2 -70
  92. package/src/predicate/encode.ts +49 -261
  93. package/src/predicate/from-json.ts +1 -43
  94. package/src/record/decode.ts +3 -8
  95. package/src/record/freshness.ts +29 -18
  96. package/src/review-card/builder.ts +19 -168
  97. package/src/review-card/cross-check.ts +3 -105
  98. package/src/review-card/render-leaf.ts +48 -0
  99. package/src/run/index.ts +144 -572
  100. package/src/run/schemas.ts +42 -291
  101. package/src/simulate/deny-cases.ts +334 -0
  102. package/src/simulate/evaluate.ts +284 -0
  103. package/src/simulate/index.ts +11 -0
  104. package/src/synth/compose-from-recording.ts +148 -347
  105. package/src/synth/index.ts +0 -13
  106. package/src/synth/synthesize-from-recording.ts +43 -456
  107. package/src/types.ts +13 -49
  108. package/dist/adapters/interpreter/index.d.ts +0 -1
  109. package/dist/adapters/interpreter/index.js +0 -2
  110. package/dist/adapters/oz/adapter.d.ts +0 -20
  111. package/dist/adapters/oz/adapter.js +0 -295
  112. package/dist/adapters/oz/index.d.ts +0 -1
  113. package/dist/adapters/oz/index.js +0 -2
  114. package/dist/codegen/compile-gate.d.ts +0 -33
  115. package/dist/codegen/compile-gate.js +0 -124
  116. package/dist/codegen/index.d.ts +0 -2
  117. package/dist/codegen/index.js +0 -8
  118. package/dist/codegen/template.d.ts +0 -18
  119. package/dist/codegen/template.js +0 -148
  120. package/dist/install/authority-overlap.d.ts +0 -134
  121. package/dist/install/authority-overlap.js +0 -0
  122. package/dist/install/build-install-predicate.d.ts +0 -96
  123. package/dist/install/build-install-predicate.js +0 -444
  124. package/dist/install/build-merge-policy.d.ts +0 -70
  125. package/dist/install/build-merge-policy.js +0 -130
  126. package/dist/install/plan-merge-policy.d.ts +0 -49
  127. package/dist/install/plan-merge-policy.js +0 -86
  128. package/dist/install/read-account-rules.d.ts +0 -100
  129. package/dist/install/read-account-rules.js +0 -283
  130. package/dist/ir/index.d.ts +0 -1
  131. package/dist/ir/index.js +0 -2
  132. package/dist/ir/types.d.ts +0 -140
  133. package/dist/ir/types.js +0 -11
  134. package/dist/mandate/index.d.ts +0 -2
  135. package/dist/mandate/index.js +0 -2
  136. package/dist/mandate/to-ir.d.ts +0 -3
  137. package/dist/mandate/to-ir.js +0 -60
  138. package/dist/mandate/types.d.ts +0 -20
  139. package/dist/mandate/types.js +0 -8
  140. package/dist/seams/index.d.ts +0 -1
  141. package/dist/seams/index.js +0 -2
  142. package/dist/seams/types.d.ts +0 -66
  143. package/dist/seams/types.js +0 -11
  144. package/dist/synth/deny-cases.js +0 -562
  145. package/dist/synth/evaluate.d.ts +0 -39
  146. package/dist/synth/evaluate.js +0 -551
  147. package/dist/synth/harness.d.ts +0 -28
  148. package/dist/synth/harness.js +0 -47
  149. package/dist/synth/minimize.d.ts +0 -4
  150. package/dist/synth/minimize.js +0 -38
  151. package/dist/synth/permit-context.d.ts +0 -15
  152. package/dist/synth/permit-context.js +0 -116
  153. package/dist/synth/predicate-literals.d.ts +0 -5
  154. package/dist/synth/predicate-literals.js +0 -25
  155. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  156. package/dist/synth/synthesize-from-mandate.js +0 -59
  157. package/dist/verify/envelope.d.ts +0 -15
  158. package/dist/verify/envelope.js +0 -22
  159. package/dist/verify/index.d.ts +0 -3
  160. package/dist/verify/index.js +0 -3
  161. package/dist/verify/simulate.d.ts +0 -31
  162. package/dist/verify/simulate.js +0 -258
  163. package/dist/verify/verify.d.ts +0 -21
  164. package/dist/verify/verify.js +0 -189
  165. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  166. package/dist-cjs/adapters/interpreter/index.js +0 -8
  167. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  168. package/dist-cjs/adapters/oz/adapter.js +0 -300
  169. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  170. package/dist-cjs/adapters/oz/index.js +0 -8
  171. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  172. package/dist-cjs/codegen/compile-gate.js +0 -128
  173. package/dist-cjs/codegen/index.d.ts +0 -2
  174. package/dist-cjs/codegen/index.js +0 -14
  175. package/dist-cjs/codegen/template.d.ts +0 -18
  176. package/dist-cjs/codegen/template.js +0 -151
  177. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  178. package/dist-cjs/install/authority-overlap.js +0 -0
  179. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  180. package/dist-cjs/install/build-install-predicate.js +0 -479
  181. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  182. package/dist-cjs/install/build-merge-policy.js +0 -134
  183. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  184. package/dist-cjs/install/plan-merge-policy.js +0 -90
  185. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  186. package/dist-cjs/install/read-account-rules.js +0 -296
  187. package/dist-cjs/ir/index.d.ts +0 -1
  188. package/dist-cjs/ir/index.js +0 -3
  189. package/dist-cjs/ir/types.d.ts +0 -140
  190. package/dist-cjs/ir/types.js +0 -12
  191. package/dist-cjs/mandate/index.d.ts +0 -2
  192. package/dist-cjs/mandate/index.js +0 -6
  193. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  194. package/dist-cjs/mandate/to-ir.js +0 -63
  195. package/dist-cjs/mandate/types.d.ts +0 -20
  196. package/dist-cjs/mandate/types.js +0 -9
  197. package/dist-cjs/seams/index.d.ts +0 -1
  198. package/dist-cjs/seams/index.js +0 -3
  199. package/dist-cjs/seams/types.d.ts +0 -66
  200. package/dist-cjs/seams/types.js +0 -12
  201. package/dist-cjs/synth/deny-cases.js +0 -568
  202. package/dist-cjs/synth/evaluate.d.ts +0 -39
  203. package/dist-cjs/synth/evaluate.js +0 -554
  204. package/dist-cjs/synth/harness.d.ts +0 -28
  205. package/dist-cjs/synth/harness.js +0 -50
  206. package/dist-cjs/synth/minimize.d.ts +0 -4
  207. package/dist-cjs/synth/minimize.js +0 -41
  208. package/dist-cjs/synth/permit-context.d.ts +0 -15
  209. package/dist-cjs/synth/permit-context.js +0 -119
  210. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  211. package/dist-cjs/synth/predicate-literals.js +0 -28
  212. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  213. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  214. package/dist-cjs/verify/envelope.d.ts +0 -15
  215. package/dist-cjs/verify/envelope.js +0 -23
  216. package/dist-cjs/verify/index.d.ts +0 -3
  217. package/dist-cjs/verify/index.js +0 -8
  218. package/dist-cjs/verify/simulate.d.ts +0 -31
  219. package/dist-cjs/verify/simulate.js +0 -261
  220. package/dist-cjs/verify/verify.d.ts +0 -21
  221. package/dist-cjs/verify/verify.js +0 -192
  222. package/src/adapters/interpreter/index.ts +0 -8
  223. package/src/adapters/oz/adapter.ts +0 -376
  224. package/src/adapters/oz/index.ts +0 -9
  225. package/src/codegen/compile-gate.ts +0 -167
  226. package/src/codegen/index.ts +0 -17
  227. package/src/codegen/template.ts +0 -165
  228. package/src/install/authority-overlap.ts +0 -0
  229. package/src/install/build-merge-policy.ts +0 -219
  230. package/src/install/plan-merge-policy.ts +0 -133
  231. package/src/install/read-account-rules.ts +0 -376
  232. package/src/ir/index.ts +0 -13
  233. package/src/ir/types.ts +0 -132
  234. package/src/mandate/index.ts +0 -4
  235. package/src/mandate/to-ir.ts +0 -71
  236. package/src/mandate/types.ts +0 -21
  237. package/src/seams/index.ts +0 -11
  238. package/src/seams/types.ts +0 -81
  239. package/src/synth/deny-cases.ts +0 -663
  240. package/src/synth/evaluate.ts +0 -613
  241. package/src/synth/harness.ts +0 -68
  242. package/src/synth/minimize.ts +0 -48
  243. package/src/synth/permit-context.ts +0 -136
  244. package/src/synth/predicate-literals.ts +0 -27
  245. package/src/synth/synthesize-from-mandate.ts +0 -82
  246. package/src/verify/envelope.ts +0 -28
  247. package/src/verify/index.ts +0 -5
  248. package/src/verify/simulate.ts +0 -311
  249. package/src/verify/verify.ts +0 -243
@@ -1,16 +1,10 @@
1
1
  // src/adapters/interpreter/adapter.ts - the interpreter-policy CustodyAdapter.
2
2
  //
3
3
  // Compiles a PolicyIR to a single interpreter `PolicyDocument` + `PolicyRef`
4
- // carrying the canonical predicate encoding from `predicate/encode.ts`. Second
5
- // backend (the OZ built-in adapter is the first); the compose step (P3) routes
6
- // IR constructs between them.
4
+ // carrying the canonical predicate encoding from `predicate/encode.ts`. This is
5
+ // the only backend; the compose step lowers every constraint to it.
7
6
  //
8
7
  // Three fail-closed enforcement gates (per spec):
9
- // - oracle_price leaves MUST sit directly under the top-level `and`; nesting
10
- // under `not` / `or` / inside `in` throws ORACLE_LEAF_INVALID_POSITION.
11
- // - oracleParams overrides may only TIGHTEN vs wasm defaults
12
- // (maxStalenessSeconds <= 600, maxDeviationBps <= 200); a widening value
13
- // throws ORACLE_PARAMS_OUT_OF_RANGE at compile time.
14
8
  // - an `in` allowlist (or a `compare eq` vs an address, or any value in an
15
9
  // `eq_seq`) that targets the smart account's own address throws
16
10
  // SCOPE_SELF_CALL.
@@ -19,43 +13,24 @@
19
13
  // expiry, etc.) is named in `uncovered` rather than silently dropped.
20
14
 
21
15
  import type { ToolError } from '../../errors.ts'
22
- import type {
23
- IRCompare,
24
- IRCondition,
25
- IRPolicyRule,
26
- IRScalarType,
27
- IRSelector,
28
- PolicyIR,
29
- } from '../../ir/types.ts'
30
16
  import { encodePredicate } from '../../predicate/encode.ts'
31
- import type {
32
- CompileResult,
33
- CustodyAdapter,
34
- CustodyCapabilities,
35
- SimulationResult,
36
- } from '../../seams/types.ts'
37
- import type {
38
- ContextRuleDraft,
39
- Network,
40
- ParseConfidence,
41
- PolicyDocument,
42
- PolicyRef,
43
- PredicateLeaf,
44
- PredicateNode,
45
- ProposedPolicy,
17
+ import type { ComposedRule } from '../../synth/compose-from-recording.ts'
18
+ import {
19
+ type ContextRuleDraft,
20
+ GRAMMAR_VERSION,
21
+ type Network,
22
+ type ParseConfidence,
23
+ type PolicyDocument,
24
+ type PolicyRef,
25
+ type PredicateLeaf,
26
+ type PredicateNode,
27
+ type ProposedPolicy,
46
28
  } from '../../types.ts'
47
29
 
48
30
  /** [VERIFY] NOT a real deployed address. The interpreter is a per-network
49
31
  * deploy artifact we do not have yet; install is a later phase. */
50
32
  export const PLACEHOLDER_INTERPRETER_ADDRESS = 'VERIFY-interpreter-address'
51
33
 
52
- /** Wasm-level oracle defaults (mirrors the interpreter's `OracleParams`).
53
- * Per-policy overrides may TIGHTEN only - they may never exceed these. */
54
- export const ORACLE_DEFAULTS = {
55
- maxStalenessSeconds: 600,
56
- maxDeviationBps: 200,
57
- } as const
58
-
59
34
  export interface InterpreterAdapterConfig {
60
35
  network: Network
61
36
  /** Per-rule install nonce (first install = 1; replay-protected). */
@@ -64,25 +39,20 @@ export interface InterpreterAdapterConfig {
64
39
  * by the self-call gate: any `in`-allowlist containing this address is
65
40
  * rejected as `SCOPE_SELF_CALL`. */
66
41
  smartAccountAddress: string
67
- /** Optional per-policy oracle overrides. Must TIGHTEN vs ORACLE_DEFAULTS
68
- * (i.e. <= each default). A widening value throws ORACLE_PARAMS_OUT_OF_RANGE
69
- * at compile time. */
70
- oracleParams?: { maxStalenessSeconds?: number; maxDeviationBps?: number }
71
42
  }
72
43
 
73
- const CAPABILITIES: CustodyCapabilities = {
74
- supportsSpendWindow: true,
75
- supportsThreshold: false, // thresholds are the OZ adapter's job
76
- // Expiry is via the context rule's validUntilLedger, not a predicate - the
77
- // interpreter refuses a `valid_until` leaf at install.
78
- supportsTimeExpiry: false,
79
- supportsOraclePrice: true,
80
- supportsInvocationCount: true,
81
- supportsGeneralPredicate: true,
44
+ /** The result of compiling a PolicyIR for this backend. */
45
+ export interface CompileResult {
46
+ /** false => some IR construct this backend cannot express (see `uncovered`). */
47
+ covered: boolean
48
+ /** Human-readable list of unsupported constructs. */
49
+ uncovered: string[]
50
+ /** The installable policy, assembled when a rule lowered. */
51
+ proposed?: ProposedPolicy
82
52
  }
83
53
 
84
- /** Parse confidence for a deterministic (non-decoded) input: full (1.0). A
85
- * mandate needs no decoding, so the gate is not applicable. */
54
+ /** Parse confidence for a deterministic (non-decoded) input: full (1.0). An
55
+ * input that needs no decoding has nothing for the gate to judge. */
86
56
  const FULL_PARSE_CONFIDENCE: ParseConfidence = {
87
57
  overall: 1,
88
58
  knownContracts: [],
@@ -91,61 +61,34 @@ const FULL_PARSE_CONFIDENCE: ParseConfidence = {
91
61
  thresholdUsed: 1,
92
62
  }
93
63
 
94
- export function createInterpreterAdapter(config: InterpreterAdapterConfig): CustodyAdapter {
95
- return {
96
- name: 'interpreter',
97
- mode: 'enforce',
98
- capabilities: () => ({ ...CAPABILITIES }),
99
- compile: (ir) => compile(ir, config),
100
- simulate: () => simulateStub(),
101
- export: (ir) => canonicalStringify(ir),
102
- }
103
- }
104
-
105
- /** Lower a single IR rule to the canonical pre-encoding `PredicateNode`. The
106
- * orchestrator uses this to wire the self-verify + minimise pipeline: after
107
- * `compile(ir)` succeeds it re-derives the PredicateNode via this helper to
108
- * drive `minimize` and `runHarness` on the SAME shape the encoder saw. Pure
109
- * and deterministic: same `rule + config` -> byte-identical PredicateNode.
110
- * The `uncovered` list is NOT re-derived - callers needing it must use
111
- * `compile(ir)`. */
64
+ /** Lower a single rule to the canonical pre-encoding `PredicateNode`. The
65
+ * orchestrator re-derives the PredicateNode through this helper so it holds the
66
+ * SAME shape the encoder saw. Pure and deterministic: same `rule + config` ->
67
+ * byte-identical PredicateNode. The `uncovered` list is NOT re-derived -
68
+ * callers needing it must use `compileInterpreterPolicy(rule, config)`. */
112
69
  export function lowerRuleToPredicate(
113
- rule: IRPolicyRule,
70
+ rule: ComposedRule,
114
71
  config: InterpreterAdapterConfig
115
72
  ): PredicateNode {
116
73
  return lowerRule(rule, config).predicate
117
74
  }
118
75
 
119
- function compile(ir: PolicyIR, config: InterpreterAdapterConfig): CompileResult {
120
- // Tighten-only oracle-params gate (config-time). Widening -> throw at compile
121
- // time before any lowering happens, so a misuse fails closed loudly.
122
- assertOracleParamsTighten(config.oracleParams)
123
-
124
- const firstRule = ir.rules[0]
125
- if (!firstRule) {
126
- return { covered: false, uncovered: ['empty PolicyIR (no rules to compile)'] }
127
- }
128
- if (ir.rules.length > 1) {
129
- return {
130
- covered: false,
131
- uncovered: [
132
- `multi-rule PolicyIR: ${ir.rules.length - 1} rule(s) beyond the first are not compiled (the interpreter adapter handles one rule per document in this slice)`,
133
- ],
134
- }
135
- }
136
-
137
- const lowered = lowerRule(firstRule, config)
76
+ /** Compile a composed rule to an installable interpreter policy. */
77
+ export function compileInterpreterPolicy(
78
+ rule: ComposedRule,
79
+ config: InterpreterAdapterConfig
80
+ ): CompileResult {
81
+ const lowered = lowerRule(rule, config)
138
82
  if (lowered.uncovered.length > 0) {
139
83
  return { covered: false, uncovered: lowered.uncovered }
140
84
  }
141
85
 
142
86
  const { encodedPredicate, predicateHash } = encodePredicate(lowered.predicate)
143
87
  const policyDocument: PolicyDocument = {
144
- grammarVersion: 1,
88
+ grammarVersion: GRAMMAR_VERSION,
145
89
  installNonce: config.installNonce,
146
90
  encodedPredicate,
147
91
  predicateHash,
148
- ...(config.oracleParams ? { oracleParams: config.oracleParams } : {}),
149
92
  }
150
93
  const policyRef: PolicyRef = {
151
94
  kind: 'interpreter',
@@ -169,75 +112,24 @@ interface LoweredRule {
169
112
  uncovered: string[]
170
113
  }
171
114
 
172
- function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): LoweredRule {
115
+ function lowerRule(rule: ComposedRule, config: InterpreterAdapterConfig): LoweredRule {
173
116
  const uncovered: string[] = []
174
117
 
175
118
  // scope -> context rule + sibling predicates. contract/method each become
176
119
  // their own `eq` leaf and are merged into the top-level `and` alongside the
177
- // constraints. The top-level MUST be `and` so oracle leaves sit directly
178
- // under it (mandatory per the oracle position rule + canonical hash stability).
120
+ // constraints. The top-level MUST be `and` for canonical hash stability.
179
121
  const scopeContract: string | undefined = rule.scope.contract
180
122
  const scopeMethod: string | undefined = rule.scope.method
181
- if (rule.scope.chainId !== undefined) {
182
- uncovered.push(
183
- `chainId \`${rule.scope.chainId}\` not bindable by the interpreter adapter (network is per-context, not per-rule)`
184
- )
185
- }
186
-
187
- if (rule.roles.length > 0) {
188
- uncovered.push(
189
- `roles [${rule.roles.join(', ')}] dropped (role-to-signer mapping is a later phase; interpreter policies reference addresses, not role names)`
190
- )
191
- }
192
-
193
- if (rule.guard) {
194
- uncovered.push(
195
- `guard: ${describeCondition(rule.guard)} (interpreter adapter currently treats guards as uncovered)`
196
- )
197
- }
198
-
199
- // expiry -> context rule validUntilLedger only. Unix-timestamp expiry is
200
- // honoured by the interpreter (the `valid_until` selector), but we surface
201
- // it here as Path-B because the IR contract already says "OZ context rules
202
- // expire by ledger sequence" and we keep one expiry model for both adapters.
203
- let validUntilLedger: number | null = null
204
- if (rule.expiry) {
205
- if (rule.expiry.validUntilLedger !== undefined) {
206
- validUntilLedger = rule.expiry.validUntilLedger
207
- } else if (rule.expiry.validUntilUnixSeconds !== undefined) {
208
- uncovered.push(
209
- 'time expiry given as a unix timestamp (interpreter adapter currently lowers expiry only via the OZ context-rule validUntilLedger; supply expiry.validUntilLedger)'
210
- )
211
- }
212
- }
213
-
214
- // approval is the OZ adapter's job (it lowers to simple_threshold /
215
- // weighted_threshold primitives). Surface it here so a caller that drops it
216
- // through to the interpreter adapter sees an explicit uncovered entry
217
- // instead of a silently-dropped M-of-N gate.
218
- if (rule.approval) {
219
- uncovered.push(
220
- `approval threshold ${rule.approval.threshold} not emitted by the interpreter adapter (thresholds lower to OZ built-in primitives in the OZ adapter)`
221
- )
222
- }
123
+ // expiry -> the context rule's own validUntilLedger. The interpreter has no
124
+ // expiry selector; a policy's lifetime is the OZ context rule's lifetime.
125
+ const validUntilLedger: number | null = rule.expiry?.validUntilLedger ?? null
223
126
 
224
127
  // Pre-scan constraints: anything the interpreter adapter cannot express is
225
128
  // named in `uncovered` and skipped from the predicate lowering. We surface
226
129
  // these BEFORE lowering so the predicate is built from only the
227
130
  // expressible subset.
228
- const expressibleConstraints: IRCondition[] = []
229
- for (const c of rule.constraints) {
230
- const unsupp = unsupportedConstruct(c)
231
- if (unsupp !== null) {
232
- uncovered.push(unsupp)
233
- } else {
234
- expressibleConstraints.push(c)
235
- }
236
- }
237
-
238
131
  // Build the top-level `and` of scope + expressible constraints. The
239
- // top-level MUST be `and` so oracle leaves sit directly under it - the
240
- // position rule is enforced inside `lowerCondition`.
132
+ // top-level MUST be `and` for canonical hash stability.
241
133
  const topChildren: PredicateNode[] = []
242
134
  if (scopeContract !== undefined) {
243
135
  topChildren.push({
@@ -253,8 +145,13 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
253
145
  right: { kind: 'literal_symbol', value: scopeMethod },
254
146
  })
255
147
  }
256
- for (const c of expressibleConstraints) {
257
- topChildren.push(lowerCondition(c, config))
148
+ topChildren.push(...rule.constraints)
149
+
150
+ // One walk over the whole tree rejects any literal naming the smart account
151
+ // itself. Previously three separate call sites did this per construct, which
152
+ // meant a new construct could be added without one.
153
+ for (const node of topChildren) {
154
+ assertNoSelfCall(node, config)
258
155
  }
259
156
 
260
157
  const predicate: PredicateNode =
@@ -262,8 +159,14 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
262
159
  ? topChildren[0]
263
160
  : { op: 'and', children: topChildren }
264
161
 
162
+ // Scope the context rule to the contract when the rule names one, matching
163
+ // what OZ's CallContract scoping did. A `default` rule would route EVERY
164
+ // call through this policy instead of only calls to the scoped contract.
265
165
  const contextRule: ContextRuleDraft = {
266
- contextRuleType: { kind: 'default' },
166
+ contextRuleType:
167
+ scopeContract !== undefined
168
+ ? { kind: 'call_contract', contract: scopeContract }
169
+ : { kind: 'default' },
267
170
  name: 'interpreter',
268
171
  validUntilLedger,
269
172
  signers: [],
@@ -273,435 +176,36 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
273
176
  return { predicate, contextRule, uncovered }
274
177
  }
275
178
 
276
- /** Detect IR constructs the interpreter adapter cannot express; return a
277
- * human-readable descriptor (or null if the construct IS expressible). Used
278
- * by `lowerRule` to populate `uncovered` before lowering. */
279
- function unsupportedConstruct(cond: IRCondition): string | null {
280
- switch (cond.op) {
281
- // Expressible: `call_arg_scaled` is exactly this construct.
282
- case 'slippage_floor':
283
- return null
284
- case 'in':
285
- // The oracle position rule is a hard error and must be raised before a
286
- // construct can be written off as uncovered - otherwise a subtree that
287
- // is BOTH misplaced-oracle and unsourceable would be silently dropped
288
- // instead of rejected.
289
- assertNoOracleDescendants(cond)
290
- return (
291
- unsourceableSelector(cond.selector) ??
292
- (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
293
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
294
- : null)
295
- )
296
- case 'eq_seq':
297
- return (
298
- unsourceableSelector(cond.selector) ??
299
- (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
300
- ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
301
- : null)
302
- )
303
- case 'compare': {
304
- const s = cond.compare.selector
305
- if (s.kind === 'calldata') return 'EVM calldata comparison (predicate DSL)'
306
- if (s.kind === 'value') return 'tx.value comparison (predicate DSL)'
307
- // The on-chain interpreter sees ONE authorized call - no
308
- // `Context.sub_invocations` in v1 - so it cannot observe token
309
- // movements. `amount` has no value to read, and `window_spent`
310
- // accumulates BY that amount, so its counter would never move.
311
- // Deriving either from the call payload would quietly swap "value
312
- // actually moved" for "value the caller declared" - a weaker guarantee
313
- // than the review card would be claiming.
314
- //
315
- // Rolling spend caps belong to the OZ `spending_limit` primitive (already
316
- // audited, emitted by the OZ adapter). A per-call cap is expressible here
317
- // as `arg_field`; bounding it with `invocation_count` gives an enforceable
318
- // ceiling per window.
319
- return unsourceableSelector(s)
320
- }
321
- // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
322
- // pre-scan, which only sees top-level constraints.
323
- case 'and':
324
- return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
325
- case 'or':
326
- assertNoOracleDescendants(cond)
327
- return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
328
- case 'not':
329
- assertNoOracleDescendants(cond)
330
- return unsupportedConstruct(cond.child)
331
- }
332
- }
333
-
334
- /** Selectors whose value the on-chain interpreter has no way to obtain. */
335
- function unsourceableSelector(s: IRSelector): string | null {
336
- if (s.kind === 'amount') {
337
- return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`
338
- }
339
- if (s.kind === 'window_spent') {
340
- return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`
341
- }
342
- return null
343
- }
344
-
345
- /** Lower one IR condition to a PredicateNode. Enforces the oracle position
346
- * rule (oracle_price leaves must sit directly under the top-level `and`) and
347
- * the self-call rule (`in` allowlist / `compare eq` vs the smart account
348
- * address -> SCOPE_SELF_CALL). */
349
- function lowerCondition(cond: IRCondition, config: InterpreterAdapterConfig): PredicateNode {
350
- switch (cond.op) {
351
- // `out >= in * num/den`. The contract refuses den == 0 or a non-positive
352
- // ratio at install, so a malformed floor fails loudly rather than
353
- // silently inverting the comparison.
354
- case 'slippage_floor':
355
- return {
356
- op: 'gte',
357
- left: { kind: 'call_arg', index: cond.outArgIndex },
358
- right: {
359
- kind: 'call_arg_scaled',
360
- index: cond.inArgIndex,
361
- num: cond.num,
362
- den: cond.den,
363
- },
364
- }
365
- case 'and':
366
- return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) }
367
- case 'or':
368
- // `or` at this depth: oracle_price anywhere inside is invalid (would
369
- // not be a direct child of the top-level `and`).
370
- assertNoOracleDescendants(cond)
371
- return { op: 'or', children: cond.children.map((c) => lowerCondition(c, config)) }
372
- case 'not':
373
- // `not` at this depth: oracle_price anywhere inside is invalid.
374
- assertNoOracleDescendants(cond)
375
- return { op: 'not', child: lowerCondition(cond.child, config) }
376
- case 'in': {
377
- assertNoOracleDescendants(cond)
378
- for (const v of cond.values) {
379
- assertNotSelfCallAddress(v, config)
380
- }
381
- // `in` is PURE set membership; the encoder sorts the haystack. An exact
382
- // ordered sequence (e.g. swap hop path) is expressed as `eq_seq` and
383
- // lowers to `eq(selector, literal_vec)` instead - never to `in` with an
384
- // `ordered` flag.
385
- return {
386
- op: 'in',
387
- needle: lowerSelector(cond.selector),
388
- haystack: cond.values.map((v) => literalFromScalar(v, selectorScalarType(cond.selector))),
389
- }
390
- }
391
- case 'eq_seq': {
392
- assertNoOracleDescendants(cond)
393
- for (const v of cond.values) {
394
- assertNotSelfCallAddress(v, config)
395
- }
396
- // Exact ordered sequence equality: the right-hand side is a literal_vec
397
- // whose element order is preserved verbatim by the encoder. `eq` does
398
- // deep equality at evaluate time - this is the ONLY way to express an
399
- // exact ordered sequence in the predicate grammar (the path of a swap).
400
- return {
401
- op: 'eq',
402
- left: lowerSelector(cond.selector),
403
- right: {
404
- kind: 'literal_vec',
405
- elements: cond.values.map((v) => literalFromScalar(v, selectorScalarType(cond.selector))),
406
- },
407
- }
408
- }
409
- case 'compare': {
410
- // Self-call on an address eq.
411
- if (
412
- cond.compare.operator === 'eq' &&
413
- cond.compare.selector.kind === 'arg' &&
414
- cond.compare.selector.scalarType === 'address'
415
- ) {
416
- assertNotSelfCallAddress(cond.compare.value, config)
417
- }
418
- return {
419
- op: cond.compare.operator,
420
- left: lowerSelector(cond.compare.selector),
421
- right: literalFromIRCompare(cond.compare),
422
- }
179
+ /** Reject a value that targets the smart account's own address (a self-call
180
+ * is a structural privilege-escalation hole the interpreter forbids). */
181
+ /** Reject any address literal anywhere in the predicate that names the smart
182
+ * account itself: a policy permitting the account to call itself would let an
183
+ * agent re-enter the guarded account and escape the scope the rule pins. */
184
+ function assertNoSelfCall(node: PredicateNode, config: InterpreterAdapterConfig): void {
185
+ const checkLeaf = (leaf: PredicateLeaf): void => {
186
+ if (leaf.kind === 'literal_vec') {
187
+ for (const e of leaf.elements) checkLeaf(e)
188
+ return
423
189
  }
424
- }
425
- }
426
-
427
- /** Lower an IR selector to the matching PredicateLeaf. */
428
- function lowerSelector(s: IRSelector): PredicateLeaf {
429
- switch (s.kind) {
430
- case 'arg':
431
- return { kind: 'call_arg', index: s.argIndex }
432
- case 'arg_len':
433
- return { kind: 'call_arg_len', index: s.argIndex }
434
- case 'arg_field':
435
- return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
436
- // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
437
- // before lowering - the interpreter cannot source either on chain.
438
- // Reaching here means the pre-scan was bypassed; fail loudly rather than
439
- // emit a leaf the contract will refuse.
440
- case 'amount':
441
- case 'window_spent':
442
- throw new Error(
443
- `interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`
444
- )
445
- case 'invocation_count':
446
- return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds }
447
- case 'now':
448
- return { kind: 'now' }
449
- case 'valid_until':
450
- return { kind: 'valid_until' }
451
- case 'oracle_price':
452
- return { kind: 'oracle_price', asset: s.asset }
453
- case 'calldata':
454
- case 'value':
455
- // Unreachable: the caller flagged these as Path-B before reaching here.
190
+ if (leaf.kind === 'literal_address' && leaf.value === config.smartAccountAddress) {
456
191
  throw toolError(
457
- 'SYNTHESIS_ERROR',
458
- `selector kind \`${s.kind}\` is not lowerable to a predicate leaf`
459
- )
460
- }
461
- }
462
-
463
- /** Build a literal leaf from the right-hand side of an IRCompare, mapping the
464
- * selector kind to the matching `literal_*` kind. The IR compare value is a
465
- * raw string (i128-safe); the selector kind fixes the canonical wire type:
466
- * - arg -> IR scalarType (set by the recorder/parser)
467
- * - arg_len -> u32 (vec length is a small non-negative integer)
468
- * - arg_field -> IR scalarType (the field's recorded type)
469
- * - amount -> i128 (canonical Stellar token amount encoding)
470
- * - window_spent -> i128 (canonical amount encoding)
471
- * - oracle_price -> i128 (canonical price encoding)
472
- * - invocation_count -> u32 (counts are small non-negative integers)
473
- * - now / valid_until -> u64 (unix timestamps in seconds) */
474
- function literalFromIRCompare(c: IRCompare): PredicateLeaf {
475
- // An oracle comparand is not a bare literal: the interpreter refuses one,
476
- // because assuming it shares the normalised 9-dp basis is what made a raw
477
- // 14-dp threshold permit everything it was written to deny.
478
- if (c.selector.kind === 'oracle_price') {
479
- if (c.valueDecimals === undefined) {
480
- throw toolError(
481
- 'ORACLE_THRESHOLD_BASIS_REQUIRED',
482
- 'An oracle price bound must declare the decimal basis of its threshold ' +
483
- '(oracle prices normalise to 9 decimals).'
192
+ 'SCOPE_SELF_CALL',
193
+ `value \`${leaf.value}\` is the smart account's own address (self-call in an allowlist / compare is rejected)`
484
194
  )
485
195
  }
486
- return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals }
487
- }
488
- const scalarType: IRScalarType = selectorScalarType(c.selector)
489
- return literalFromScalar(c.value, scalarType)
490
- }
491
-
492
- function literalScalarForSelector(kind: IRSelector['kind']): IRScalarType {
493
- switch (kind) {
494
- case 'amount':
495
- case 'window_spent':
496
- case 'oracle_price':
497
- return 'i128'
498
- case 'invocation_count':
499
- case 'arg_len':
500
- return 'u32'
501
- case 'now':
502
- case 'valid_until':
503
- return 'u64'
504
- case 'arg':
505
- case 'arg_field':
506
- case 'calldata':
507
- case 'value':
508
- // arg / arg_field -> caller handles scalarType; calldata/value -> unreachable (Path-B).
509
- return 'i128'
510
- }
511
- }
512
-
513
- /** Build a literal leaf from a raw string value + an IRScalarType hint. */
514
- function literalFromScalar(value: string, scalarType: IRScalarType): PredicateLeaf {
515
- switch (scalarType) {
516
- case 'address':
517
- return { kind: 'literal_address', value }
518
- case 'i128':
519
- case 'u128':
520
- return { kind: 'literal_i128', value }
521
- case 'u32':
522
- return { kind: 'literal_u32', value: Number.parseInt(value, 10) }
523
- case 'u64':
524
- case 'i64':
525
- return { kind: 'literal_u64', value }
526
- case 'symbol':
527
- return { kind: 'literal_symbol', value }
528
- case 'bytes':
529
- return { kind: 'literal_bytes', value }
530
- case 'bool':
531
- throw toolError(
532
- 'MALFORMED_PREDICATE',
533
- `boolean literal not supported in v1 predicate grammar`
534
- )
535
- }
536
- }
537
-
538
- /** Scalar type of an IRSelector for the purpose of building literal leaves
539
- * (the right-hand side of `eq` / elements of an `in` haystack / elements of
540
- * a `literal_vec`). Mirrors `literalScalarForSelector` for OZ extensions and
541
- * uses the selector's own `scalarType` for `arg` and `arg_field` selectors. */
542
- function selectorScalarType(selector: IRSelector): IRScalarType {
543
- if (selector.kind === 'arg') return selector.scalarType
544
- if (selector.kind === 'arg_field') return selector.scalarType
545
- return literalScalarForSelector(selector.kind)
546
- }
547
-
548
- /** Walk a condition tree and throw if any oracle_price leaf is found anywhere
549
- * inside. Used by `lowerCondition` for `not`, `or`, and `in` - the three
550
- * positions where oracle leaves would not be direct children of the
551
- * top-level `and`. */
552
- function assertNoOracleDescendants(node: IRCondition): void {
553
- if (containsOracle(node)) {
554
- throw toolError(
555
- 'ORACLE_LEAF_INVALID_POSITION',
556
- 'oracle_price leaves must sit directly under the top-level `and`'
557
- )
558
196
  }
559
- }
560
-
561
- function containsOracle(node: IRCondition): boolean {
562
197
  switch (node.op) {
563
- case 'slippage_floor':
564
- return false
565
- case 'compare':
566
- return node.compare.selector.kind === 'oracle_price'
567
- case 'in':
568
- return node.selector.kind === 'oracle_price'
569
- case 'eq_seq':
570
- return node.selector.kind === 'oracle_price'
571
- case 'not':
572
- return containsOracle(node.child)
573
198
  case 'and':
574
- case 'or':
575
- return node.children.some(containsOracle)
576
- }
577
- }
578
-
579
- /** Reject a value that targets the smart account's own address (a self-call
580
- * is a structural privilege-escalation hole the interpreter forbids). */
581
- function assertNotSelfCallAddress(value: string, config: InterpreterAdapterConfig): void {
582
- if (value === config.smartAccountAddress) {
583
- throw toolError(
584
- 'SCOPE_SELF_CALL',
585
- `value \`${value}\` is the smart account's own address (self-call in an allowlist / compare is rejected)`
586
- )
587
- }
588
- }
589
-
590
- /** Tighten-only oracle-params gate. Any widening value (above the wasm-level
591
- * defaults) throws ORACLE_PARAMS_OUT_OF_RANGE at compile time. */
592
- function assertOracleParamsTighten(params: InterpreterAdapterConfig['oracleParams']): void {
593
- if (!params) return
594
- if (
595
- params.maxStalenessSeconds !== undefined &&
596
- params.maxStalenessSeconds > ORACLE_DEFAULTS.maxStalenessSeconds
597
- ) {
598
- throw toolError(
599
- 'ORACLE_PARAMS_OUT_OF_RANGE',
600
- `maxStalenessSeconds ${params.maxStalenessSeconds} exceeds default ${ORACLE_DEFAULTS.maxStalenessSeconds}`
601
- )
602
- }
603
- if (
604
- params.maxDeviationBps !== undefined &&
605
- params.maxDeviationBps > ORACLE_DEFAULTS.maxDeviationBps
606
- ) {
607
- throw toolError(
608
- 'ORACLE_PARAMS_OUT_OF_RANGE',
609
- `maxDeviationBps ${params.maxDeviationBps} exceeds default ${ORACLE_DEFAULTS.maxDeviationBps}`
610
- )
611
- }
612
- }
613
-
614
- /** Human-readable descriptor for a construct the interpreter adapter cannot
615
- * express. Mirrors the OZ adapter's `describeCondition` for parity. */
616
- function describeCondition(cond: IRCondition): string {
617
- switch (cond.op) {
618
- case 'slippage_floor':
619
- return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`
199
+ for (const c of node.children) assertNoSelfCall(c, config)
200
+ return
620
201
  case 'in':
621
- return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`
622
- case 'eq_seq':
623
- return `exact ordered sequence on ${describeSelector(cond.selector)} (predicate DSL)`
624
- case 'not':
625
- return 'negated condition (predicate DSL)'
626
- case 'and':
627
- case 'or':
628
- return `nested ${cond.op} condition (predicate DSL)`
629
- case 'compare': {
630
- const s = cond.compare.selector
631
- switch (s.kind) {
632
- case 'oracle_price':
633
- return `oracle price condition on ${s.asset}`
634
- case 'invocation_count':
635
- return `invocation-count window (${s.windowSeconds}s) condition`
636
- case 'window_spent':
637
- return `spend-window comparison with operator '${cond.compare.operator}'`
638
- case 'amount':
639
- return `per-call amount comparison on ${s.token}`
640
- case 'arg':
641
- return `argument comparison on arg ${s.argIndex}`
642
- case 'arg_len':
643
- return `vec length comparison on arg ${s.argIndex}`
644
- case 'arg_field':
645
- return `map field comparison on arg ${s.argIndex}.${s.field}`
646
- case 'calldata':
647
- return 'EVM calldata comparison'
648
- case 'value':
649
- return 'tx.value comparison'
650
- case 'now':
651
- case 'valid_until':
652
- return 'time comparison'
653
- }
654
- }
655
- }
656
- }
657
-
658
- function describeSelector(s: IRSelector): string {
659
- switch (s.kind) {
660
- case 'arg':
661
- return `arg ${s.argIndex}`
662
- case 'arg_len':
663
- return `arg_len(${s.argIndex})`
664
- case 'arg_field':
665
- return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`
666
- case 'amount':
667
- return `amount(${s.token})`
668
- case 'window_spent':
669
- return `window_spent(${s.token})`
670
- case 'oracle_price':
671
- return `oracle_price(${s.asset})`
672
- case 'invocation_count':
673
- return `invocation_count(${s.windowSeconds}s)`
674
- case 'calldata':
675
- return `calldata[${s.offset}:${s.offset + s.length}]`
202
+ // The needle is a selector; only the haystack carries literals.
203
+ for (const h of node.haystack) checkLeaf(h)
204
+ return
676
205
  default:
677
- return s.kind
678
- }
679
- }
680
-
681
- function simulateStub(): SimulationResult {
682
- return {
683
- backend: 'ts-model',
684
- permitted: null,
685
- evaluations: [],
686
- notes: ['stub: real permit/deny semantics wiring is a later phase'],
687
- }
688
- }
689
-
690
- /** Canonical JSON with recursively sorted object keys (stable across runs). */
691
- function canonicalStringify(value: unknown): string {
692
- return JSON.stringify(sortKeys(value))
693
- }
694
-
695
- function sortKeys(value: unknown): unknown {
696
- if (Array.isArray(value)) return value.map(sortKeys)
697
- if (value && typeof value === 'object') {
698
- const out: Record<string, unknown> = {}
699
- for (const key of Object.keys(value as Record<string, unknown>).sort()) {
700
- out[key] = sortKeys((value as Record<string, unknown>)[key])
701
- }
702
- return out
206
+ checkLeaf(node.left)
207
+ checkLeaf(node.right)
703
208
  }
704
- return value
705
209
  }
706
210
 
707
211
  /** Build a synthetic Error carrying the ToolError code/severity/retryable