@crediolabs/policy-synth 0.1.3 → 0.1.5

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -5
  3. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  4. package/dist/adapters/interpreter/adapter.js +522 -0
  5. package/dist/adapters/interpreter/index.d.ts +1 -0
  6. package/dist/adapters/interpreter/index.js +2 -0
  7. package/dist/adapters/oz/adapter.js +2 -0
  8. package/dist/codegen/compile-gate.d.ts +33 -0
  9. package/dist/codegen/compile-gate.js +119 -0
  10. package/dist/codegen/index.d.ts +2 -0
  11. package/dist/codegen/index.js +8 -0
  12. package/dist/codegen/template.d.ts +18 -0
  13. package/dist/codegen/template.js +131 -0
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/ir/types.d.ts +13 -2
  18. package/dist/predicate/encode.d.ts +10 -0
  19. package/dist/predicate/encode.js +249 -0
  20. package/dist/predicate/index.d.ts +1 -0
  21. package/dist/predicate/index.js +2 -0
  22. package/dist/record/decode.d.ts +6 -3
  23. package/dist/record/decode.js +13 -3
  24. package/dist/review-card/builder.d.ts +14 -0
  25. package/dist/review-card/builder.js +261 -0
  26. package/dist/review-card/conflict.d.ts +40 -0
  27. package/dist/review-card/conflict.js +111 -0
  28. package/dist/review-card/cross-check.d.ts +11 -0
  29. package/dist/review-card/cross-check.js +148 -0
  30. package/dist/review-card/index.d.ts +3 -0
  31. package/dist/review-card/index.js +4 -0
  32. package/dist/run/index.d.ts +31 -0
  33. package/dist/run/index.js +164 -0
  34. package/dist/run/schemas.d.ts +2003 -0
  35. package/dist/run/schemas.js +215 -0
  36. package/dist/synth/compose-from-recording.d.ts +35 -7
  37. package/dist/synth/compose-from-recording.js +225 -34
  38. package/dist/synth/deny-cases.d.ts +12 -0
  39. package/dist/synth/deny-cases.js +377 -0
  40. package/dist/synth/evaluate.d.ts +39 -0
  41. package/dist/synth/evaluate.js +425 -0
  42. package/dist/synth/harness.d.ts +16 -0
  43. package/dist/synth/harness.js +26 -0
  44. package/dist/synth/index.d.ts +4 -0
  45. package/dist/synth/index.js +4 -0
  46. package/dist/synth/minimize.d.ts +4 -0
  47. package/dist/synth/minimize.js +38 -0
  48. package/dist/synth/predicate-literals.d.ts +5 -0
  49. package/dist/synth/predicate-literals.js +25 -0
  50. package/dist/synth/synthesize-from-recording.d.ts +37 -5
  51. package/dist/synth/synthesize-from-recording.js +550 -18
  52. package/dist/types.d.ts +40 -1
  53. package/dist/types.js +17 -0
  54. package/dist/verify/envelope.d.ts +15 -0
  55. package/dist/verify/envelope.js +22 -0
  56. package/dist/verify/index.d.ts +3 -0
  57. package/dist/verify/index.js +3 -0
  58. package/dist/verify/simulate.d.ts +31 -0
  59. package/dist/verify/simulate.js +258 -0
  60. package/dist/verify/verify.d.ts +21 -0
  61. package/dist/verify/verify.js +189 -0
  62. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  63. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  64. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  65. package/dist-cjs/adapters/interpreter/index.js +8 -0
  66. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  67. package/dist-cjs/adapters/oz/adapter.js +289 -0
  68. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  69. package/dist-cjs/adapters/oz/index.js +8 -0
  70. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  71. package/dist-cjs/codegen/compile-gate.js +123 -0
  72. package/dist-cjs/codegen/index.d.ts +2 -0
  73. package/dist-cjs/codegen/index.js +14 -0
  74. package/dist-cjs/codegen/template.d.ts +18 -0
  75. package/dist-cjs/codegen/template.js +134 -0
  76. package/dist-cjs/errors.d.ts +37 -0
  77. package/dist-cjs/errors.js +3 -0
  78. package/dist-cjs/index.d.ts +11 -0
  79. package/dist-cjs/index.js +27 -0
  80. package/dist-cjs/ir/index.d.ts +1 -0
  81. package/dist-cjs/ir/index.js +3 -0
  82. package/dist-cjs/ir/types.d.ts +108 -0
  83. package/dist-cjs/ir/types.js +12 -0
  84. package/dist-cjs/mandate/index.d.ts +2 -0
  85. package/dist-cjs/mandate/index.js +6 -0
  86. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  87. package/dist-cjs/mandate/to-ir.js +63 -0
  88. package/dist-cjs/mandate/types.d.ts +20 -0
  89. package/dist-cjs/mandate/types.js +9 -0
  90. package/dist-cjs/package.json +3 -0
  91. package/dist-cjs/predicate/encode.d.ts +10 -0
  92. package/dist-cjs/predicate/encode.js +252 -0
  93. package/dist-cjs/predicate/index.d.ts +1 -0
  94. package/dist-cjs/predicate/index.js +6 -0
  95. package/dist-cjs/record/decode.d.ts +79 -0
  96. package/dist-cjs/record/decode.js +398 -0
  97. package/dist-cjs/record/freshness.d.ts +17 -0
  98. package/dist-cjs/record/freshness.js +55 -0
  99. package/dist-cjs/record/index.d.ts +21 -0
  100. package/dist-cjs/record/index.js +166 -0
  101. package/dist-cjs/record/movements.d.ts +20 -0
  102. package/dist-cjs/record/movements.js +192 -0
  103. package/dist-cjs/record/rpc.d.ts +22 -0
  104. package/dist-cjs/record/rpc.js +74 -0
  105. package/dist-cjs/record/validate.d.ts +22 -0
  106. package/dist-cjs/record/validate.js +63 -0
  107. package/dist-cjs/registry/identify.d.ts +11 -0
  108. package/dist-cjs/registry/identify.js +87 -0
  109. package/dist-cjs/registry/index.d.ts +3 -0
  110. package/dist-cjs/registry/index.js +15 -0
  111. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  112. package/dist-cjs/registry/known-addresses.js +53 -0
  113. package/dist-cjs/registry/protocols.d.ts +38 -0
  114. package/dist-cjs/registry/protocols.js +153 -0
  115. package/dist-cjs/review-card/builder.d.ts +14 -0
  116. package/dist-cjs/review-card/builder.js +264 -0
  117. package/dist-cjs/review-card/conflict.d.ts +40 -0
  118. package/dist-cjs/review-card/conflict.js +114 -0
  119. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  120. package/dist-cjs/review-card/cross-check.js +151 -0
  121. package/dist-cjs/review-card/index.d.ts +3 -0
  122. package/dist-cjs/review-card/index.js +10 -0
  123. package/dist-cjs/run/index.d.ts +31 -0
  124. package/dist-cjs/run/index.js +178 -0
  125. package/dist-cjs/run/schemas.d.ts +2003 -0
  126. package/dist-cjs/run/schemas.js +218 -0
  127. package/dist-cjs/seams/index.d.ts +1 -0
  128. package/dist-cjs/seams/index.js +3 -0
  129. package/dist-cjs/seams/types.d.ts +66 -0
  130. package/dist-cjs/seams/types.js +12 -0
  131. package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
  132. package/dist-cjs/synth/compose-from-recording.js +356 -0
  133. package/dist-cjs/synth/deny-cases.d.ts +12 -0
  134. package/dist-cjs/synth/deny-cases.js +380 -0
  135. package/dist-cjs/synth/evaluate.d.ts +39 -0
  136. package/dist-cjs/synth/evaluate.js +428 -0
  137. package/dist-cjs/synth/harness.d.ts +16 -0
  138. package/dist-cjs/synth/harness.js +29 -0
  139. package/dist-cjs/synth/index.d.ts +9 -0
  140. package/dist-cjs/synth/index.js +23 -0
  141. package/dist-cjs/synth/lower.d.ts +23 -0
  142. package/dist-cjs/synth/lower.js +119 -0
  143. package/dist-cjs/synth/minimize.d.ts +4 -0
  144. package/dist-cjs/synth/minimize.js +41 -0
  145. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  146. package/dist-cjs/synth/predicate-literals.js +28 -0
  147. package/dist-cjs/synth/scope.d.ts +26 -0
  148. package/dist-cjs/synth/scope.js +81 -0
  149. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  150. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  151. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  152. package/dist-cjs/synth/synthesize-from-recording.js +711 -0
  153. package/dist-cjs/types.d.ts +288 -0
  154. package/dist-cjs/types.js +55 -0
  155. package/dist-cjs/verify/envelope.d.ts +15 -0
  156. package/dist-cjs/verify/envelope.js +23 -0
  157. package/dist-cjs/verify/index.d.ts +3 -0
  158. package/dist-cjs/verify/index.js +8 -0
  159. package/dist-cjs/verify/simulate.d.ts +31 -0
  160. package/dist-cjs/verify/simulate.js +261 -0
  161. package/dist-cjs/verify/verify.d.ts +21 -0
  162. package/dist-cjs/verify/verify.js +192 -0
  163. package/package.json +43 -3
  164. package/src/adapters/interpreter/adapter.ts +642 -0
  165. package/src/adapters/interpreter/index.ts +8 -0
  166. package/src/adapters/oz/adapter.ts +2 -0
  167. package/src/codegen/compile-gate.ts +162 -0
  168. package/src/codegen/index.ts +17 -0
  169. package/src/codegen/template.ts +148 -0
  170. package/src/errors.ts +2 -0
  171. package/src/index.ts +2 -0
  172. package/src/ir/types.ts +9 -2
  173. package/src/predicate/encode.ts +307 -0
  174. package/src/predicate/index.ts +3 -0
  175. package/src/record/decode.ts +21 -10
  176. package/src/review-card/builder.ts +303 -0
  177. package/src/review-card/conflict.ts +143 -0
  178. package/src/review-card/cross-check.ts +158 -0
  179. package/src/review-card/index.ts +12 -0
  180. package/src/run/index.ts +213 -0
  181. package/src/run/schemas.ts +242 -0
  182. package/src/synth/compose-from-recording.ts +277 -43
  183. package/src/synth/deny-cases.ts +466 -0
  184. package/src/synth/evaluate.ts +493 -0
  185. package/src/synth/harness.ts +40 -0
  186. package/src/synth/index.ts +12 -0
  187. package/src/synth/minimize.ts +44 -0
  188. package/src/synth/predicate-literals.ts +27 -0
  189. package/src/synth/synthesize-from-recording.ts +655 -19
  190. package/src/types.ts +38 -2
  191. package/src/verify/envelope.ts +28 -0
  192. package/src/verify/index.ts +5 -0
  193. package/src/verify/simulate.ts +311 -0
  194. package/src/verify/verify.ts +243 -0
@@ -1,27 +1,53 @@
1
1
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
2
2
  //
3
3
  // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
4
- // bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
5
- // `createOzAdapter` pair used by the deterministic Mandate path. The flow:
4
+ // bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
5
+ // pair used by the deterministic Mandate path. The flow:
6
6
  //
7
7
  // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
8
8
  // the caller may relax via `confidenceOverride.threshold`).
9
9
  // 2. `lower(tx)` -> IntentFacts.
10
10
  // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
11
- // 4. `composeFromRecording(facts, scope, opts)` -> PolicyIR + ambiguities.
11
+ // 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
12
+ // interpreterIr (predicate-shape), ambiguities, warnings }.
12
13
  // 5. `ozAdapter.compile(ir)` -> CompileResult.
13
- // 6. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
14
- // (the OZ `uncovered` markers, prefixed), and ambiguities
15
- // (DURATION_UNSPECIFIED etc.).
14
+ // 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
15
+ // opted in to the interpreter adapter:
16
+ // a. Run `interpreterAdapter.compile(interpreterIr)`.
17
+ // b. Throw -> SYNTHESIS_ERROR carrying the gate code
18
+ // (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
19
+ // ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
20
+ // policy - a warning can be ignored and yields an over-broad rule.
21
+ // c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
22
+ // descriptor (the user asked for the interpreter to enforce these
23
+ // constraints; refusing them silently is the audit failure mode).
24
+ // d. `covered === true` -> merge its PolicyDocument + PolicyRef into
25
+ // the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
26
+ // is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
27
+ // `OZ_LIMITS.maxPoliciesPerRule` (= 5).
28
+ // 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
29
+ // (the OZ `uncovered` markers, prefixed), and ambiguities.
16
30
  //
17
31
  // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
18
32
  // No randomness, no clock, no globals.
19
33
 
34
+ import type { InterpreterAdapterConfig } from '../adapters/interpreter/adapter.ts'
35
+ import {
36
+ createInterpreterAdapter,
37
+ lowerRuleToPredicate,
38
+ PLACEHOLDER_INTERPRETER_ADDRESS,
39
+ } from '../adapters/interpreter/adapter.ts'
20
40
  import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
21
41
  import { createOzAdapter } from '../adapters/oz/adapter.ts'
22
42
  import type { ToolError, ToolResponse } from '../errors.ts'
43
+ import { encodePredicate } from '../predicate/encode.ts'
23
44
  import {
45
+ type ContractInvocation,
46
+ MAX_SCVAL_CLONE_DEPTH,
24
47
  type Network,
48
+ OZ_LIMITS,
49
+ type PredicateLeaf,
50
+ type PredicateNode,
25
51
  type ProposedPolicy,
26
52
  type RecordedTransaction,
27
53
  SOROBAN_LIMITS,
@@ -31,31 +57,132 @@ import {
31
57
  type ComposeUserResponses,
32
58
  composeFromRecording,
33
59
  } from './compose-from-recording.ts'
60
+ import { generateCases } from './deny-cases.ts'
61
+ import { type EvalContext, evaluate } from './evaluate.ts'
62
+ import { runHarness } from './harness.ts'
34
63
  import { lower } from './lower.ts'
64
+ import { minimize } from './minimize.ts'
35
65
  import { decideScope, type ScopeDecision } from './scope.ts'
36
66
 
37
67
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
38
68
 
69
+ /** Per-policy interpreter adapter config the caller opts into. Absent ->
70
+ * recording path runs in week-1 mode (OZ adapter only, warnings for the
71
+ * constraints OZ cannot express). */
72
+ export interface InterpreterAdapterOptions {
73
+ /** The smart account this interpreter policy will be installed against.
74
+ * Must be a C... contract address - the on-chain policy-bound account.
75
+ * The G... source account from the recording is a separate concept. */
76
+ smartAccountAddress: string
77
+ /** Per-rule install nonce (first install = 1). Defaults to 1. */
78
+ installNonce?: number
79
+ /** Per-policy oracle overrides. Tighten-only vs the wasm defaults
80
+ * (maxStalenessSeconds <= 600, maxDeviationBps <= 200); widening is
81
+ * rejected at the options boundary. */
82
+ oracleParams?: { maxStalenessSeconds?: number; maxDeviationBps?: number }
83
+ /** @internal Test-only seam. When set, the orchestrator uses this
84
+ * PredicateNode as the starting point for self-verify + minimise instead
85
+ * of calling the interpreter adapter's compile(). Production callers MUST
86
+ * NOT set this; the field exists so the self-verify pipeline can be
87
+ * exercised on hand-crafted predicates (redundant conjuncts for
88
+ * minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
89
+ * `smartAccountAddress` + `installNonce` + `oracleParams` are still
90
+ * carried through so the emitted PolicyRef + PolicyDocument match the
91
+ * recorded call shape. */
92
+ __testPredicateNode?: PredicateNode
93
+ }
94
+
39
95
  /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
40
96
  * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
41
- * limitAmount, invocationLimit). `confidenceOverride` relaxes the recorder's
42
- * gate. */
97
+ * limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
98
+ * `interpreter` opts the recording path into the interpreter adapter. */
43
99
  export interface SynthesizeFromRecordingOptions {
44
100
  network: Network
45
101
  userResponses?: ComposeUserResponses
46
102
  confidenceOverride?: { threshold: number }
103
+ interpreter?: InterpreterAdapterOptions
47
104
  }
48
105
 
49
- /** Synthesize a ProposedPolicy from a recorded transaction. */
50
106
  export function synthesizeFromRecording(
51
107
  tx: RecordedTransaction,
52
108
  opts: SynthesizeFromRecordingOptions,
53
109
  ozConfig: OzAdapterConfig
110
+ ): ToolResponse<ProposedPolicy> {
111
+ // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
112
+ // with a string `.code`) inside the body is converted to a structured
113
+ // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
114
+ // instead of being silently swallowed. The envelope must wrap the entire
115
+ // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
116
+ // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
117
+ // a structured ToolError rather than a thrown RangeError.
118
+ try {
119
+ return synthesizeFromRecordingInner(tx, opts, ozConfig)
120
+ } catch (e) {
121
+ if (isToolErrorShape(e)) {
122
+ return {
123
+ ok: false,
124
+ error: {
125
+ code: e.code as ToolError['code'],
126
+ message: e.message,
127
+ severity: 'error',
128
+ retryable: false,
129
+ },
130
+ }
131
+ }
132
+ throw e
133
+ }
134
+ }
135
+
136
+ /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
137
+ * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
138
+ * depth-cap throws). Detected by a string `code` field — the only contract
139
+ * the body's internal helpers agree on. */
140
+ function isToolErrorShape(e: unknown): e is { code: string; message: string } {
141
+ if (e === null || typeof e !== 'object') return false
142
+ const code = (e as { code?: unknown }).code
143
+ const message = (e as { message?: unknown }).message
144
+ return typeof code === 'string' && typeof message === 'string'
145
+ }
146
+
147
+ /** ToolError-shaped error helper used by the body to surface a structured
148
+ * failure that the envelope converts to `{ok:false, error}`. */
149
+ function throwToolError(code: ToolError['code'], message: string): never {
150
+ const err = new Error(message) as Error & { code: string; severity: string; retryable: boolean }
151
+ err.code = code
152
+ err.severity = 'error'
153
+ err.retryable = false
154
+ throw err
155
+ }
156
+
157
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
158
+ function synthesizeFromRecordingInner(
159
+ tx: RecordedTransaction,
160
+ opts: SynthesizeFromRecordingOptions,
161
+ ozConfig: OzAdapterConfig
54
162
  ): ToolResponse<ProposedPolicy> {
55
163
  // 0. validate inputs (fail closed - never synthesize from garbage).
56
164
  const invalid = validateOptions(opts)
57
165
  if (invalid) return { ok: false, error: invalid }
58
166
 
167
+ // 0a. per-movement amount validation (item 2). Synthesizing from a recording
168
+ // whose `tokenMovements[].amount` does not match the canonical positive
169
+ // decimal integer format is a malformed-input failure, not a runtime
170
+ // one - reject it here so the downstream `BigInt(m.amount)` in
171
+ // `buildPermitContext` cannot throw a SyntaxError past the envelope.
172
+ for (const [i, m] of tx.tokenMovements.entries()) {
173
+ if (!/^[0-9]+$/.test(m.amount)) {
174
+ return {
175
+ ok: false,
176
+ error: {
177
+ code: 'RECORDING_VALIDATION_FAILED',
178
+ message: `tokenMovements[${i}].amount must be a positive decimal integer string, got: ${m.amount}`,
179
+ severity: 'error',
180
+ retryable: false,
181
+ },
182
+ }
183
+ }
184
+ }
185
+
59
186
  // 1. parseConfidence gate.
60
187
  const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
61
188
  if (tx.parseConfidence.overall < threshold) {
@@ -98,9 +225,6 @@ export function synthesizeFromRecording(
98
225
  if (!scopeRes.ok) return scopeRes
99
226
  const scope: ScopeDecision = scopeRes.data
100
227
  if (scope.kind !== 'call_contract') {
101
- // A default scope on the recording-path is not currently a covered flow;
102
- // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
103
- // over-broad default-scoped policy.
104
228
  return {
105
229
  ok: false,
106
230
  error: {
@@ -116,13 +240,14 @@ export function synthesizeFromRecording(
116
240
  const topLevel = tx.invocations[0] ?? null
117
241
  const composeOpts: ComposeOptions = {
118
242
  network: opts.network,
243
+ interpreterEnabled: opts.interpreter !== undefined,
119
244
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
120
245
  }
121
246
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
122
247
 
123
- // 5. OZ compile.
124
- const adapter = createOzAdapter(ozConfig)
125
- const compileRes = adapter.compile(composed.ir)
248
+ // 5. OZ compile (always runs).
249
+ const ozAdapter = createOzAdapter(ozConfig)
250
+ const compileRes = ozAdapter.compile(composed.ir)
126
251
 
127
252
  if (!compileRes.proposed) {
128
253
  return {
@@ -137,12 +262,263 @@ export function synthesizeFromRecording(
137
262
  }
138
263
  }
139
264
 
140
- // 6. assemble ProposedPolicy.
265
+ // 6. Interpreter compile (opt-in; fail-closed when the user routed real
266
+ // restrictions to the interpreter).
267
+ const interpreterOpts = opts.interpreter
268
+
269
+ let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
270
+ let interpreterPolicyRef: ProposedPolicy['policyRefs'][number] | null = null
271
+
272
+ if (interpreterOpts) {
273
+ const interpreterConfig: InterpreterAdapterConfig = {
274
+ network: opts.network,
275
+ installNonce: interpreterOpts.installNonce ?? 1,
276
+ smartAccountAddress: interpreterOpts.smartAccountAddress,
277
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
278
+ }
279
+
280
+ let startingPredicate: PredicateNode | null = null
281
+ const testSeam = interpreterOpts.__testPredicateNode
282
+ if (testSeam !== undefined) {
283
+ startingPredicate = testSeam
284
+ } else {
285
+ const interpreterAdapter = createInterpreterAdapter(interpreterConfig)
286
+
287
+ let interpreterRes: Awaited<ReturnType<typeof interpreterAdapter.compile>>
288
+ try {
289
+ interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
290
+ } catch (e) {
291
+ const code = (e as { code?: ToolError['code'] }).code
292
+ const allowedCodes: ToolError['code'][] = [
293
+ 'SCOPE_SELF_CALL',
294
+ 'ORACLE_LEAF_INVALID_POSITION',
295
+ 'ORACLE_PARAMS_OUT_OF_RANGE',
296
+ 'MALFORMED_PREDICATE',
297
+ 'PREDICATE_TOO_LARGE',
298
+ 'PREDICATE_TOO_DEEP',
299
+ 'TOO_MANY_LEAVES',
300
+ 'IN_OPERAND_LIMIT',
301
+ 'PREDICATE_ORACLE_OVER_LIMIT',
302
+ ]
303
+ const surfacedCode = code && allowedCodes.includes(code) ? code : 'SYNTHESIS_ERROR'
304
+ return {
305
+ ok: false,
306
+ error: {
307
+ code: surfacedCode,
308
+ message: `interpreter predicate could not be compiled: ${(e as Error).message}`,
309
+ severity: 'error',
310
+ retryable: false,
311
+ },
312
+ }
313
+ }
314
+
315
+ if (!interpreterRes.covered) {
316
+ return {
317
+ ok: false,
318
+ error: {
319
+ code: 'SYNTHESIS_ERROR',
320
+ message: `interpreter predicate is not fully covered: ${interpreterRes.uncovered.join('; ')}`,
321
+ severity: 'error',
322
+ retryable: false,
323
+ details: { uncovered: interpreterRes.uncovered },
324
+ },
325
+ }
326
+ }
327
+
328
+ if (!interpreterRes.proposed) {
329
+ return {
330
+ ok: false,
331
+ error: {
332
+ code: 'SYNTHESIS_ERROR',
333
+ message: 'interpreter adapter returned no installable policy',
334
+ severity: 'error',
335
+ retryable: false,
336
+ },
337
+ }
338
+ }
339
+
340
+ interpreterPolicyDocument = interpreterRes.proposed.policyDocuments[0] ?? null
341
+ interpreterPolicyRef = interpreterRes.proposed.policyRefs[0] ?? null
342
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
343
+ return {
344
+ ok: false,
345
+ error: {
346
+ code: 'SYNTHESIS_ERROR',
347
+ message: 'interpreter adapter returned a policy missing the document or ref',
348
+ severity: 'error',
349
+ retryable: false,
350
+ },
351
+ }
352
+ }
353
+
354
+ const firstInterpreterRule = composed.interpreterIr.rules[0]
355
+ if (!firstInterpreterRule) {
356
+ return {
357
+ ok: false,
358
+ error: {
359
+ code: 'SYNTHESIS_ERROR',
360
+ message: 'interpreter IR has no rules to lower',
361
+ severity: 'error',
362
+ retryable: false,
363
+ },
364
+ }
365
+ }
366
+ startingPredicate = lowerRuleToPredicate(firstInterpreterRule, interpreterConfig)
367
+ }
368
+
369
+ if (!startingPredicate) {
370
+ return {
371
+ ok: false,
372
+ error: {
373
+ code: 'SYNTHESIS_ERROR',
374
+ message: 'interpreter predicate was not derived',
375
+ severity: 'error',
376
+ retryable: false,
377
+ },
378
+ }
379
+ }
380
+
381
+ // 6b. Self-verify + minimise.
382
+ if (!topLevel) {
383
+ return {
384
+ ok: false,
385
+ error: {
386
+ code: 'SYNTHESIS_ERROR',
387
+ message: 'self-verify requires a recorded top-level invocation',
388
+ severity: 'error',
389
+ retryable: false,
390
+ },
391
+ }
392
+ }
393
+ if (scope.kind !== 'call_contract') {
394
+ return {
395
+ ok: false,
396
+ error: {
397
+ code: 'SYNTHESIS_ERROR',
398
+ message: 'self-verify requires a call_contract scope',
399
+ severity: 'error',
400
+ retryable: false,
401
+ },
402
+ }
403
+ }
404
+ const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
405
+
406
+ const finalPredicate: PredicateNode =
407
+ startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
408
+
409
+ const harnessCases = generateCases(finalPredicate, permitCtx)
410
+ const harnessResult = runHarness(finalPredicate, harnessCases)
411
+ if (!harnessResult.ok) {
412
+ return {
413
+ ok: false,
414
+ error: {
415
+ code: 'DENY_CASE_FAILURE',
416
+ message: `self-verify harness failed for the interpreter predicate (${harnessResult.failures.length} failure(s))`,
417
+ severity: 'error',
418
+ retryable: false,
419
+ details: { failures: harnessResult.failures },
420
+ },
421
+ }
422
+ }
423
+
424
+ const evalResult = evaluate(finalPredicate, permitCtx)
425
+ if (!evalResult.permit) {
426
+ return {
427
+ ok: false,
428
+ error: {
429
+ code: 'DENY_CASE_FAILURE',
430
+ message: `intended recorded call was denied by the interpreter predicate: ${evalResult.reason}`,
431
+ severity: 'error',
432
+ retryable: false,
433
+ details: { reason: evalResult.reason },
434
+ },
435
+ }
436
+ }
437
+
438
+ // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
439
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
440
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
441
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
442
+ // envelope converts them to a structured `{ok:false, error}`.
443
+ const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
444
+
445
+ if (testSeam !== undefined) {
446
+ interpreterPolicyDocument = {
447
+ grammarVersion: 1,
448
+ installNonce: interpreterOpts.installNonce ?? 1,
449
+ encodedPredicate,
450
+ predicateHash,
451
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
452
+ }
453
+ interpreterPolicyRef = {
454
+ kind: 'interpreter',
455
+ interpreterAddress: PLACEHOLDER_INTERPRETER_ADDRESS,
456
+ predicateBlobBase64: encodedPredicate,
457
+ }
458
+ } else {
459
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
460
+ return {
461
+ ok: false,
462
+ error: {
463
+ code: 'SYNTHESIS_ERROR',
464
+ message: 'interpreter policy document or ref was lost before re-encode',
465
+ severity: 'error',
466
+ retryable: false,
467
+ },
468
+ }
469
+ }
470
+ interpreterPolicyDocument = {
471
+ ...interpreterPolicyDocument,
472
+ encodedPredicate,
473
+ predicateHash,
474
+ }
475
+ if (interpreterPolicyRef.kind === 'interpreter') {
476
+ interpreterPolicyRef = {
477
+ ...interpreterPolicyRef,
478
+ predicateBlobBase64: encodedPredicate,
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // When the interpreter succeeds, OZ-side `uncovered` warnings that the
485
+ // interpreter actually lowered are misleading: OZ really did not lower them,
486
+ // but the interpreter did. Drop those so the user-facing warnings reflect
487
+ // what is still UN-enforced, not what OZ alone could not do.
488
+ const ozUncovered = interpreterPolicyRef
489
+ ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
490
+ : compileRes.uncovered
491
+
492
+ // 7. Merge into the OZ-shaped ProposedPolicy.
493
+ const ozRefs = compileRes.proposed.policyRefs
494
+ const mergedRefs: ProposedPolicy['policyRefs'] = []
495
+ if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
496
+ for (const r of ozRefs) mergedRefs.push(r)
497
+
498
+ if (mergedRefs.length > OZ_LIMITS.maxPoliciesPerRule) {
499
+ return {
500
+ ok: false,
501
+ error: {
502
+ code: 'POLICY_CAP_EXCEEDED',
503
+ message: `merged policy count ${mergedRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`,
504
+ severity: 'error',
505
+ retryable: false,
506
+ },
507
+ }
508
+ }
509
+
510
+ const mergedContextRule = {
511
+ ...compileRes.proposed.contextRule,
512
+ policies: mergedRefs,
513
+ }
514
+
141
515
  const proposed: ProposedPolicy = {
142
- ...compileRes.proposed,
516
+ contextRule: mergedContextRule,
517
+ policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
518
+ policyRefs: mergedRefs,
143
519
  parseConfidence: { ...tx.parseConfidence },
144
520
  warnings: [
145
- ...compileRes.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
521
+ ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
146
522
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
147
523
  ],
148
524
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
@@ -150,9 +526,27 @@ export function synthesizeFromRecording(
150
526
  return { ok: true, data: proposed }
151
527
  }
152
528
 
529
+ // Re-export the throwToolError helper for callers that need to surface a
530
+ // ToolError-shaped throw inside the body (e.g. tests).
531
+ export { throwToolError }
532
+
533
+ /** OZ-side `uncovered` warning patterns that the interpreter adapter
534
+ * actually lowers when wired in. When the interpreter adapter succeeds, we
535
+ * drop matching entries from the OZ uncovered list so the user-facing
536
+ * warnings reflect what is still UN-enforced rather than what OZ alone
537
+ * could not do. Match the exact descriptor strings the OZ adapter emits
538
+ * (see `src/adapters/oz/adapter.ts#describeCondition` /
539
+ * `describeSelector`). */
540
+ const INTERPRETER_COVERED_OZ_PATTERN =
541
+ /^per-method scoping to|^value allowlist on arg|^exact ordered sequence on arg|^oracle price condition on|^invocation-count window|^spending_limit on token .+ needs a CallContract context scoped to that token/
542
+
153
543
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
154
544
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
155
- * positive i128 decimal string; network mainnet|testnet. */
545
+ * positive i128 decimal string; network mainnet|testnet. When the caller
546
+ * opts into the interpreter adapter, `smartAccountAddress` must be a C...
547
+ * contract address (the on-chain policy-bound account, NOT the G... source
548
+ * account from the recording), `installNonce` must be a positive integer
549
+ * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
156
550
  function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
157
551
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
158
552
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
@@ -189,9 +583,71 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
189
583
  )
190
584
  }
191
585
  }
586
+ if (opts.interpreter) {
587
+ const sa = opts.interpreter.smartAccountAddress
588
+ if (typeof sa !== 'string' || sa.length === 0) {
589
+ return synthesisError(
590
+ `interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
591
+ )
592
+ }
593
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
594
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
595
+ // with the specific placeholder error (not a generic "must be a
596
+ // C... contract" message). A real install routes a C... contract
597
+ // address derivable from the on-chain account; any
598
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
599
+ // fixture marker that must never reach the install payload.
600
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
601
+ return synthesisError(
602
+ `interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
603
+ )
604
+ }
605
+ if (!isContractAddress(sa)) {
606
+ return synthesisError(
607
+ `interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
608
+ )
609
+ }
610
+ const nonce = opts.interpreter.installNonce
611
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
612
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
613
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
614
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
615
+ return synthesisError(
616
+ `interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
617
+ )
618
+ }
619
+ const op = opts.interpreter.oracleParams
620
+ if (op) {
621
+ const MAX_STALE = 600
622
+ const MAX_DEV = 200
623
+ if (
624
+ op.maxStalenessSeconds !== undefined &&
625
+ (!isPositiveInt(op.maxStalenessSeconds) || op.maxStalenessSeconds > MAX_STALE)
626
+ ) {
627
+ return synthesisError(
628
+ `interpreter.oracleParams.maxStalenessSeconds must be a positive integer <= ${MAX_STALE} (tighten-only), got: ${op.maxStalenessSeconds}`
629
+ )
630
+ }
631
+ if (
632
+ op.maxDeviationBps !== undefined &&
633
+ (!isPositiveInt(op.maxDeviationBps) || op.maxDeviationBps > MAX_DEV)
634
+ ) {
635
+ return synthesisError(
636
+ `interpreter.oracleParams.maxDeviationBps must be a positive integer <= ${MAX_DEV} (tighten-only), got: ${op.maxDeviationBps}`
637
+ )
638
+ }
639
+ }
640
+ }
192
641
  return null
193
642
  }
194
643
 
644
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
645
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
646
+ * the interpreter-contract strkey; a real install must point at a C...
647
+ * contract address derivable from the on-chain account, never a
648
+ * fixture/LLM-seam marker. */
649
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
650
+
195
651
  function isPositiveInt(n: number): boolean {
196
652
  return Number.isInteger(n) && n > 0
197
653
  }
@@ -206,6 +662,13 @@ function isPositiveI128(s: string): boolean {
206
662
  }
207
663
  }
208
664
 
665
+ /** True when `s` looks like a Stellar C... contract address (strkey). The
666
+ * source account from the recording is a G... account; the smart account is
667
+ * a separate C... contract. */
668
+ function isContractAddress(s: string): boolean {
669
+ return s.startsWith('C') && s.length === 56
670
+ }
671
+
209
672
  function synthesisError(message: string): ToolError {
210
673
  return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false }
211
674
  }
@@ -224,3 +687,176 @@ function mergeAmbiguities(
224
687
  }
225
688
  return out
226
689
  }
690
+
691
+ /** Build the permit `EvalContext` the self-verify harness drives. The shape
692
+ * mirrors the intended recorded call (the only call the user actually
693
+ * performed) so:
694
+ * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
695
+ * permits the recorded call it was derived from); a failure surfaces
696
+ * as DENY_CASE_FAILURE.
697
+ * - `generateCases(predicate, ctx)` produces a deny battery that reflects
698
+ * the actual recorded move (real amount, real args, real window start).
699
+ * Amounts are summed per-token over all movements of the recorded tx
700
+ * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
701
+ * contains a price+timestamp satisfying each `oracle_price` leaf in the
702
+ * predicate so the intended call permits under every bound; the harness
703
+ * then mutates those entries (stale / missing / deviation / paused) to
704
+ * exercise the ORACLE_* deny paths. */
705
+ function buildPermitContext(
706
+ tx: RecordedTransaction,
707
+ scope: Extract<ScopeDecision, { kind: 'call_contract' }>,
708
+ topLevel: ContractInvocation,
709
+ userResponses: ComposeUserResponses | undefined,
710
+ predicate: PredicateNode
711
+ ): EvalContext {
712
+ const amountByToken: Record<string, string> = {}
713
+ const totals = new Map<string, bigint>()
714
+ for (const m of tx.tokenMovements) {
715
+ const current = totals.get(m.token) ?? 0n
716
+ totals.set(m.token, current + BigInt(m.amount))
717
+ }
718
+ for (const [token, total] of totals) {
719
+ amountByToken[token] = total.toString()
720
+ }
721
+
722
+ const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = oracleSatisfyingPrices(
723
+ predicate,
724
+ tx.fetchedAt
725
+ )
726
+
727
+ const ctx: EvalContext = {
728
+ contract: scope.contract,
729
+ fn: topLevel.fn,
730
+ args: topLevel.args.map(cloneScVal),
731
+ atLedger: tx.ledgerSequence,
732
+ nowSeconds: tx.fetchedAt,
733
+ amountByToken,
734
+ windowSpentByToken: {},
735
+ invocationCountByWindow: {},
736
+ oraclePriceByAsset,
737
+ }
738
+ if (userResponses?.validUntilLedger !== undefined) {
739
+ ctx.validUntilLedger = userResponses.validUntilLedger
740
+ }
741
+ return ctx
742
+ }
743
+
744
+ function cloneScVal(
745
+ value: { type: string; value: unknown },
746
+ depth = 0
747
+ ): ContractInvocation['args'][number] {
748
+ // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
749
+ // (decoded by the recorder). We clone top-level shells so the harness can
750
+ // mutate deny cases without aliasing the recorded call. Recursion is
751
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
752
+ // cannot RangeError the JS stack; the over-depth branch throws a
753
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
754
+ // (item 3) converts to `{ok:false, error}`.
755
+ if (value.type === 'vec') {
756
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
757
+ throw cloneDepthError(value)
758
+ }
759
+ return {
760
+ type: 'vec',
761
+ value: (value.value as ContractInvocation['args'][number][]).map((v) =>
762
+ cloneScVal(v, depth + 1)
763
+ ),
764
+ }
765
+ }
766
+ return { ...value } as ContractInvocation['args'][number]
767
+ }
768
+
769
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
770
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
771
+ * envelope detects and converts to a structured `{ok:false, error}`. */
772
+ function cloneDepthError(value: { type: string; value: unknown }): never {
773
+ const err = new Error(
774
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
775
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
776
+ err.code = 'SYNTHESIS_ERROR'
777
+ err.severity = 'error'
778
+ err.retryable = false
779
+ // Tag the offending value so failures are diagnosable.
780
+ err.depthContext = value.type
781
+ throw err
782
+ }
783
+
784
+ /** Walk every `oracle_price` leaf in the predicate and return a price map
785
+ * whose entries satisfy the bound so the intended call permits. The
786
+ * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
787
+ * fresh-oracle deny case in `generateCases` is the only path that flips
788
+ * this map. Negatives are clamped at 0 - oracle prices are non-negative on
789
+ * Stellar. */
790
+ function oracleSatisfyingPrices(
791
+ predicate: PredicateNode,
792
+ nowSeconds: number
793
+ ): EvalContext['oraclePriceByAsset'] {
794
+ const out: EvalContext['oraclePriceByAsset'] = {}
795
+ visitOracleLeaves(predicate, (asset, op, bound) => {
796
+ let price: bigint
797
+ switch (op) {
798
+ case 'lt':
799
+ case 'gt':
800
+ price = op === 'lt' ? bound - 1n : bound + 1n
801
+ break
802
+ case 'lte':
803
+ case 'gte':
804
+ case 'eq':
805
+ price = bound
806
+ break
807
+ }
808
+ if (price < 0n) price = 0n
809
+ out[asset] = { price: price.toString(), timestampSeconds: nowSeconds }
810
+ })
811
+ return out
812
+ }
813
+
814
+ function visitOracleLeaves(
815
+ node: PredicateNode,
816
+ visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
817
+ ): void {
818
+ switch (node.op) {
819
+ case 'and':
820
+ case 'or':
821
+ for (const child of node.children) visitOracleLeaves(child, visit)
822
+ return
823
+ case 'not':
824
+ visitOracleLeaves(node.child, visit)
825
+ return
826
+ case 'eq':
827
+ case 'lt':
828
+ case 'lte':
829
+ case 'gt':
830
+ case 'gte': {
831
+ const leftLeaf: PredicateLeaf = node.left
832
+ const rightLeaf: PredicateLeaf = node.right
833
+ const leftIsOracle = leftLeaf.kind === 'oracle_price'
834
+ const rightIsOracle = rightLeaf.kind === 'oracle_price'
835
+ let oracleAsset: string | undefined
836
+ let literal: bigint | undefined
837
+ if (leftIsOracle) {
838
+ oracleAsset = leftLeaf.asset
839
+ literal = oracleLiteralFromLeaf(rightLeaf)
840
+ } else if (rightIsOracle) {
841
+ oracleAsset = rightLeaf.asset
842
+ literal = oracleLiteralFromLeaf(leftLeaf)
843
+ }
844
+ if (oracleAsset === undefined || literal === undefined) return
845
+ visit(oracleAsset, node.op, literal)
846
+ return
847
+ }
848
+ case 'in':
849
+ // `in` is pure membership; oracle leaves inside haystacks are
850
+ // forbidden by the position rule, so there's nothing to set up here.
851
+ return
852
+ }
853
+ }
854
+
855
+ function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
856
+ if (leaf.kind !== 'literal_i128') return undefined
857
+ try {
858
+ return BigInt(leaf.value)
859
+ } catch {
860
+ return undefined
861
+ }
862
+ }