@crediolabs/policy-synth 0.1.8 → 0.1.10

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 (51) hide show
  1. package/dist/adapters/interpreter/adapter.js +21 -3
  2. package/dist/adapters/oz/adapter.js +8 -0
  3. package/dist/ir/types.d.ts +9 -0
  4. package/dist/predicate/encode.js +9 -0
  5. package/dist/review-card/builder.js +32 -0
  6. package/dist/review-card/cross-check.js +40 -0
  7. package/dist/run/index.d.ts +8 -2
  8. package/dist/run/index.js +2 -1
  9. package/dist/run/schemas.d.ts +12 -0
  10. package/dist/run/schemas.js +14 -0
  11. package/dist/synth/compose-from-recording.js +89 -0
  12. package/dist/synth/deny-cases.js +89 -1
  13. package/dist/synth/evaluate.js +49 -0
  14. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  15. package/dist/synth/synthesize-from-mandate.js +27 -2
  16. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  17. package/dist/synth/synthesize-from-recording.js +66 -1
  18. package/dist/types.d.ts +8 -0
  19. package/dist-cjs/adapters/interpreter/adapter.js +21 -3
  20. package/dist-cjs/adapters/oz/adapter.js +8 -0
  21. package/dist-cjs/ir/types.d.ts +9 -0
  22. package/dist-cjs/predicate/encode.js +9 -0
  23. package/dist-cjs/review-card/builder.js +32 -0
  24. package/dist-cjs/review-card/cross-check.js +40 -0
  25. package/dist-cjs/run/index.d.ts +8 -2
  26. package/dist-cjs/run/index.js +2 -1
  27. package/dist-cjs/run/schemas.d.ts +12 -0
  28. package/dist-cjs/run/schemas.js +14 -0
  29. package/dist-cjs/synth/compose-from-recording.js +89 -0
  30. package/dist-cjs/synth/deny-cases.js +89 -1
  31. package/dist-cjs/synth/evaluate.js +49 -0
  32. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  34. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist-cjs/synth/synthesize-from-recording.js +66 -1
  36. package/dist-cjs/types.d.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/adapters/interpreter/adapter.ts +20 -4
  39. package/src/adapters/oz/adapter.ts +8 -0
  40. package/src/ir/types.ts +8 -0
  41. package/src/predicate/encode.ts +9 -0
  42. package/src/review-card/builder.ts +28 -0
  43. package/src/review-card/cross-check.ts +40 -0
  44. package/src/run/index.ts +16 -2
  45. package/src/run/schemas.ts +14 -0
  46. package/src/synth/compose-from-recording.ts +87 -0
  47. package/src/synth/deny-cases.ts +86 -1
  48. package/src/synth/evaluate.ts +41 -0
  49. package/src/synth/synthesize-from-mandate.ts +40 -4
  50. package/src/synth/synthesize-from-recording.ts +103 -4
  51. package/src/types.ts +10 -0
@@ -171,6 +171,32 @@ function renderComparison(
171
171
  return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
172
172
  }
173
173
 
174
+ // eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
175
+ // Binds the OUTER vec length so a caller cannot append an element to
176
+ // defeat a per-element pin. Rendered as a single readable line so the
177
+ // review card surfaces the implicit "no new elements" guarantee.
178
+ if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
179
+ return `Length of arg[${left.index}] is ${right.value}`
180
+ }
181
+
182
+ // eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
183
+ // The structured-argument bind pins a scalar field inside a vec element.
184
+ // For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
185
+ // is visible; eq reads "= <value>".
186
+ if (left.kind === 'call_arg_field') {
187
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`
188
+ const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
189
+ if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
190
+ if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
191
+ if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
192
+ if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
193
+ if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
194
+ if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
195
+ if (right.kind === 'literal_vec') {
196
+ return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`
197
+ }
198
+ }
199
+
174
200
  // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
175
201
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
176
202
  return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
@@ -222,6 +248,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
222
248
  case 'call_contract':
223
249
  case 'call_fn':
224
250
  case 'call_arg':
251
+ case 'call_arg_len':
252
+ case 'call_arg_field':
225
253
  case 'amount':
226
254
  case 'window_spent':
227
255
  case 'now':
@@ -80,6 +80,44 @@ function pushComparison(
80
80
  out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
81
81
  return
82
82
  }
83
+ if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
84
+ out.push(`Length of arg[${left.index}] is ${right.value}`)
85
+ return
86
+ }
87
+ // call_arg_field: must mirror the builder's templates byte-for-byte so
88
+ // the cross-check can assert a faithful summary.
89
+ if (left.kind === 'call_arg_field') {
90
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`
91
+ const sep = op === 'eq' ? '=' : comparisonOpText(op)
92
+ if (right.kind === 'literal_address') {
93
+ out.push(`${head} ${sep} ${right.value}`)
94
+ return
95
+ }
96
+ if (right.kind === 'literal_symbol') {
97
+ out.push(`${head} ${sep} ${right.value}`)
98
+ return
99
+ }
100
+ if (right.kind === 'literal_bytes') {
101
+ out.push(`${head} ${sep} ${right.value}`)
102
+ return
103
+ }
104
+ if (right.kind === 'literal_u64') {
105
+ out.push(`${head} ${sep} ${right.value}`)
106
+ return
107
+ }
108
+ if (right.kind === 'literal_i128') {
109
+ out.push(`${head} ${sep} ${right.value}`)
110
+ return
111
+ }
112
+ if (right.kind === 'literal_u32') {
113
+ out.push(`${head} ${sep} ${right.value}`)
114
+ return
115
+ }
116
+ if (right.kind === 'literal_vec') {
117
+ out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`)
118
+ return
119
+ }
120
+ }
83
121
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
84
122
  out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`)
85
123
  return
@@ -119,6 +157,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
119
157
  case 'call_contract':
120
158
  case 'call_fn':
121
159
  case 'call_arg':
160
+ case 'call_arg_len':
161
+ case 'call_arg_field':
122
162
  case 'amount':
123
163
  case 'window_spent':
124
164
  case 'now':
package/src/run/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type ErrorCode,
22
22
  type MandateSpec,
23
23
  type OzAdapterConfig,
24
+ type PredicateNode,
24
25
  type ProposedPolicy,
25
26
  placeholderOzConfig,
26
27
  type RecordedTransaction,
@@ -31,6 +32,7 @@ import {
31
32
  type ToolError,
32
33
  type ToolResponse,
33
34
  } from '../index.ts'
35
+ import type { SimulationResult } from '../verify/envelope.ts'
34
36
  import {
35
37
  type RecordTransactionInput,
36
38
  RecordTransactionInputSchema,
@@ -112,7 +114,14 @@ export async function runRecordTransaction(
112
114
  * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
113
115
  * XDR decode error in the adapter) would otherwise surface as
114
116
  * "[object Object]" in the MCP transport. */
115
- export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>> {
117
+ export async function runSynthesizePolicy(raw: unknown): Promise<
118
+ ToolResponse<ProposedPolicy> & {
119
+ explain?: {
120
+ predicateTree: PredicateNode | null
121
+ simulation: SimulationResult
122
+ }
123
+ }
124
+ > {
116
125
  const parsed = SynthesizePolicyInputSchema.safeParse(raw)
117
126
  if (!parsed.success) {
118
127
  return {
@@ -128,7 +137,11 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
128
137
  // Zod's optional fields widen to `T | undefined`, which the core's
129
138
  // exact-optional MandateSpec rejects; the schema already validated the
130
139
  // shape, so assert it (same pattern as the recordedTx cast below).
131
- return await synthesizeFromMandate(input.mandate as MandateSpec, ozConfig)
140
+ return await synthesizeFromMandate(
141
+ input.mandate as MandateSpec,
142
+ ozConfig,
143
+ input.explain === true ? { explain: true } : {}
144
+ )
132
145
  }
133
146
  // recording source
134
147
  const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
@@ -141,6 +154,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
141
154
  ? { confidenceOverride: input.confidenceOverride }
142
155
  : {}),
143
156
  ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
157
+ ...(input.explain === true ? { explain: true } : {}),
144
158
  } as SynthesizeFromRecordingOptions,
145
159
  ozConfig
146
160
  )
@@ -197,6 +197,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
197
197
  source: z.literal('mandate'),
198
198
  mandate: MandateSpecSchema,
199
199
  ozConfig: OzAdapterConfigSchema.optional(),
200
+ // --explain opt-in. When true, the orchestrator attaches the
201
+ // in-memory predicate tree (null for the mandate path) + a minimal
202
+ // honest SimulationResult to the success envelope. Absent or false
203
+ // -> the success envelope is unchanged (byte-identical to today).
204
+ explain: z.boolean().optional(),
200
205
  })
201
206
 
202
207
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
@@ -224,6 +229,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
224
229
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
225
230
  interpreter: InterpreterOptionsSchema.optional(),
226
231
  ozConfig: OzAdapterConfigSchema.optional(),
232
+ // --explain opt-in. When true, the orchestrator attaches the
233
+ // in-memory PredicateNode + the corresponding SimulationResult
234
+ // (real one from the self-verify pipeline when the interpreter is
235
+ // engaged, minimal honest value otherwise) to the success envelope.
236
+ // Absent or false -> the success envelope is unchanged (byte-identical
237
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
238
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
239
+ // explain.
240
+ explain: z.boolean().optional(),
227
241
  })
228
242
 
229
243
  export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
@@ -381,6 +381,93 @@ function appendProtocolSpecificConstraints(
381
381
  }
382
382
  }
383
383
 
384
+ // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
385
+ // reserve_token_ids - no map fields to bind). The `requests` vec
386
+ // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
387
+ // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
388
+ // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
389
+ // liquidation/auction fills. Pinning only one element is unsafe: a caller
390
+ // can append a second element with a different action (WithdrawCollateral
391
+ // -> Borrow on a different asset, any amount, then auction fills). Length
392
+ // + per-element pinning is total; a quantifier over elements is not. If we
393
+ // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
394
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
395
+ const requestsArg = topLevel.args[3]
396
+ if (requestsArg && requestsArg.type === 'vec') {
397
+ const elements = requestsArg.value
398
+ interpreterConstraints.push({
399
+ op: 'compare',
400
+ compare: {
401
+ selector: { kind: 'arg_len', argIndex: 3 },
402
+ operator: 'eq',
403
+ value: String(elements.length),
404
+ },
405
+ })
406
+ for (let i = 0; i < elements.length; i++) {
407
+ const element = elements[i]
408
+ if (!element || element.type !== 'map') continue
409
+ const fields = element.value
410
+ const fieldValue = (
411
+ name: string,
412
+ scalarType: 'address' | 'i128' | 'u32'
413
+ ): string | null => {
414
+ const entry = fields.find((e) => e.key === name)
415
+ if (!entry) return null
416
+ if (entry.val.type === 'address' && scalarType === 'address') return entry.val.value
417
+ if (entry.val.type === 'i128' && scalarType === 'i128') return entry.val.value
418
+ if (entry.val.type === 'u32' && scalarType === 'u32') return entry.val.value
419
+ return null
420
+ }
421
+ const address = fieldValue('address', 'address')
422
+ const amount = fieldValue('amount', 'i128')
423
+ const requestType = fieldValue('request_type', 'u32')
424
+ if (address === null || amount === null || requestType === null) continue
425
+ interpreterConstraints.push({
426
+ op: 'compare',
427
+ compare: {
428
+ selector: {
429
+ kind: 'arg_field',
430
+ argIndex: 3,
431
+ element: i,
432
+ field: 'request_type',
433
+ scalarType: 'u32',
434
+ },
435
+ operator: 'eq',
436
+ value: requestType,
437
+ },
438
+ })
439
+ interpreterConstraints.push({
440
+ op: 'compare',
441
+ compare: {
442
+ selector: {
443
+ kind: 'arg_field',
444
+ argIndex: 3,
445
+ element: i,
446
+ field: 'address',
447
+ scalarType: 'address',
448
+ },
449
+ operator: 'eq',
450
+ value: address,
451
+ },
452
+ })
453
+ interpreterConstraints.push({
454
+ op: 'compare',
455
+ compare: {
456
+ selector: {
457
+ kind: 'arg_field',
458
+ argIndex: 3,
459
+ element: i,
460
+ field: 'amount',
461
+ scalarType: 'i128',
462
+ },
463
+ operator: 'lte',
464
+ value: amount,
465
+ },
466
+ })
467
+ }
468
+ }
469
+ }
470
+
384
471
  // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
385
472
  // The interpreter adapter is the ONLY way to express an exact ordered
386
473
  // sequence (OZ built-ins cannot). Element order is preserved verbatim.
@@ -51,6 +51,15 @@ const ADJACENT_ASSETS = [
51
51
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
52
 
53
53
  // The 15 dimensions the synth pipeline uses for self-verify and minimise.
54
+ // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
55
+ // alongside the existing dimensions so the per-element binds emitted for
56
+ // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
57
+ // minimise. Without these, no deny case exercises the new leaves and the
58
+ // minimise pass drops them as "redundant" - reopening the element-append
59
+ // hole they were added to close. Both dimensions are no-ops for any fixture
60
+ // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
61
+ // generator short-circuits on leaf-kind), so they cannot make production
62
+ // synthesis refuse for fixtures that do not exercise the new grammar.
54
63
  const ORIGINAL_DIMENSIONS: string[] = [
55
64
  'amount',
56
65
  'asset',
@@ -67,6 +76,8 @@ const ORIGINAL_DIMENSIONS: string[] = [
67
76
  'oracle_deviation_exceeded',
68
77
  'oracle_paused',
69
78
  'soroswap_allowed_path',
79
+ 'vec_append',
80
+ 'map_field_flip',
70
81
  ]
71
82
 
72
83
  export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
@@ -263,7 +274,9 @@ export function generateCases(
263
274
  hasConstrainedArg &&
264
275
  permitCtx.args.length >= 2 &&
265
276
  permitCtx.args[0]?.type === 'address' &&
266
- permitCtx.args[1]?.type === 'address'
277
+ permitCtx.args[1]?.type === 'address' &&
278
+ (permitCtx.args[0] as { type: 'address'; value: string }).value !==
279
+ (permitCtx.args[1] as { type: 'address'; value: string }).value
267
280
  ) {
268
281
  const ctx = cloneContext(permitCtx)
269
282
  // Guard above guarantees args[0] and args[1] exist and are address-typed.
@@ -274,6 +287,55 @@ export function generateCases(
274
287
  denies.push({ dimension: 'argument_reorder', ctx })
275
288
  }
276
289
 
290
+ // --- map_field_flip: flip a bound map field to a different valid value of
291
+ // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
292
+ // Targets each `call_arg_field` leaf and produces a ctx whose vec element
293
+ // has a different value of the recorded field (different request_type,
294
+ // different amount, different address). The blend-submit case uses this
295
+ // to detect a missing request_type pin. ---
296
+ if (!dimensions || dimensions.includes('map_field_flip')) {
297
+ for (const comparison of facts.comparisons) {
298
+ const sel = comparison.left
299
+ if (sel.kind !== 'call_arg_field') continue
300
+ const arg = permitCtx.args[sel.index]
301
+ if (!arg || arg.type !== 'vec') continue
302
+ const element = arg.value[sel.element]
303
+ if (!element || element.type !== 'map' || !Array.isArray(element.value)) continue
304
+ const entry = element.value.find((e) => e.key === sel.field)
305
+ if (!entry) continue
306
+ const flipped = flipFieldValue(entry.val)
307
+ if (flipped === null) continue
308
+ const ctx = cloneContext(permitCtx)
309
+ const clonedVec = arg.value.map(cloneScVal)
310
+ const clonedElement = clonedVec[sel.element]
311
+ if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value)) continue
312
+ clonedElement.value = clonedElement.value.map((e) =>
313
+ e.key === sel.field ? { key: e.key, val: flipped } : e
314
+ )
315
+ clonedVec[sel.element] = clonedElement
316
+ ctx.args[sel.index] = { type: 'vec', value: clonedVec }
317
+ denies.push({ dimension: 'map_field_flip', ctx })
318
+ }
319
+ }
320
+
321
+ // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
322
+ // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
323
+ // length pin a caller can append an extra element to defeat per-element binds. ---
324
+ if (!dimensions || dimensions.includes('vec_append')) {
325
+ for (const comparison of facts.comparisons) {
326
+ const sel = comparison.left
327
+ if (sel.kind !== 'call_arg_len') continue
328
+ const arg = permitCtx.args[sel.index]
329
+ if (!arg || arg.type !== 'vec') continue
330
+ const ctx = cloneContext(permitCtx)
331
+ ctx.args[sel.index] = {
332
+ type: 'vec',
333
+ value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
334
+ }
335
+ denies.push({ dimension: 'vec_append', ctx })
336
+ }
337
+ }
338
+
277
339
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
278
340
  return { permit: cloneContext(permitCtx), denies }
279
341
  }
@@ -528,3 +590,26 @@ function cloneDepthError(value: ScVal): never {
528
590
  err.depthContext = value.type
529
591
  throw err
530
592
  }
593
+
594
+ /** Build a value of the SAME ScVal type that differs from the recorded one,
595
+ * used by the `map_field_flip` mutation to defeat a per-element pin without
596
+ * changing the wire type (a type/arity change is rejected by host dispatch
597
+ * before Policy::enforce, so it is not the predicate's job). Returns null
598
+ * when the ScVal type has no obvious different value (e.g. opaque/other). */
599
+ function flipFieldValue(val: ScVal): ScVal | null {
600
+ switch (val.type) {
601
+ case 'address': {
602
+ const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE'
603
+ const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5'
604
+ return { type: 'address', value: val.value === a ? b : a }
605
+ }
606
+ case 'i128':
607
+ case 'u64':
608
+ case 'u32':
609
+ return { type: val.type, value: String(BigInt(val.value) + 1n) }
610
+ case 'symbol':
611
+ return { type: 'symbol', value: `${val.value}x` }
612
+ default:
613
+ return null
614
+ }
615
+ }
@@ -216,6 +216,33 @@ function evalCompare(
216
216
  return evalArgEq(op, actual, right, ctx)
217
217
  }
218
218
 
219
+ // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
220
+ // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
221
+ if (left.kind === 'call_arg_len') {
222
+ const actual = ctx.args[left.index]
223
+ if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
224
+ if (right.kind !== 'literal_u32') return { permit: false, reason: 'ARG_MISMATCH' }
225
+ return actual.value.length === right.value
226
+ ? { permit: true }
227
+ : { permit: false, reason: 'ARG_MISMATCH' }
228
+ }
229
+
230
+ // --- step 4d: call_arg_field: the value of a field in the map at element i
231
+ // of the vec at argument index. Fails closed on a non-vec arg, an
232
+ // out-of-range element, a missing field, a non-map element, or a type
233
+ // mismatch between the field ScVal and the literal leaf.
234
+ if (left.kind === 'call_arg_field') {
235
+ const actual = ctx.args[left.index]
236
+ if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
237
+ const element = actual.value[left.element]
238
+ if (!element || element.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
239
+ if (!Array.isArray(element.value)) return { permit: false, reason: 'ARG_MISMATCH' }
240
+ const entry = element.value.find((e) => e.key === left.field)
241
+ if (!entry) return { permit: false, reason: 'ARG_MISMATCH' }
242
+ if (op === 'eq') return evalArgEq(op, entry.val, right, ctx)
243
+ return evalArgOrderedCompare(op, entry.val, right)
244
+ }
245
+
219
246
  // --- step 6: AMOUNT_BOUND ---
220
247
  if (left.kind === 'amount' && op !== 'eq') {
221
248
  return evalAmountCompare(op, left.token, right, ctx)
@@ -383,6 +410,20 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
383
410
  return { type: 'symbol', value: ctx.fn }
384
411
  case 'call_arg':
385
412
  return ctx.args[leaf.index]
413
+ case 'call_arg_len':
414
+ // No direct ScVal projection: the length is an integer the comparator
415
+ // resolves against the right-hand literal. Returning undefined keeps
416
+ // the `in` membership path structurally informed (no haystack match).
417
+ return undefined
418
+ case 'call_arg_field': {
419
+ const actual = ctx.args[leaf.index]
420
+ if (!actual || actual.type !== 'vec') return undefined
421
+ const element = actual.value[leaf.element]
422
+ if (!element || element.type !== 'map') return undefined
423
+ if (!Array.isArray(element.value)) return undefined
424
+ const entry = element.value.find((e) => e.key === leaf.field)
425
+ return entry ? entry.val : undefined
426
+ }
386
427
  case 'amount':
387
428
  case 'window_spent':
388
429
  case 'oracle_price':
@@ -13,14 +13,27 @@ import { createOzAdapter } from '../adapters/oz/adapter.ts'
13
13
  import type { ToolResponse } from '../errors.ts'
14
14
  import { mandateToPolicyIR } from '../mandate/to-ir.ts'
15
15
  import type { MandateSpec } from '../mandate/types.ts'
16
- import type { ProposedPolicy } from '../types.ts'
16
+ import type { PredicateNode, ProposedPolicy } from '../types.ts'
17
+ import type { SimulationResult } from '../verify/envelope.ts'
17
18
 
18
19
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
19
20
 
20
21
  export function synthesizeFromMandate(
21
22
  spec: MandateSpec,
22
- ozConfig: OzAdapterConfig
23
- ): ToolResponse<ProposedPolicy> {
23
+ ozConfig: OzAdapterConfig,
24
+ /** --explain opt-in. When true, the success envelope carries the
25
+ * in-memory predicate tree (always null for the mandate path - the
26
+ * declarative MandateSpec lowers to OZ built-ins, not to an
27
+ * interpreter predicate) + a minimal honest SimulationResult. The
28
+ * flag is ADDITIVE: the existing ProposedPolicy fields are never
29
+ * altered. */
30
+ opts?: { explain?: true }
31
+ ): ToolResponse<ProposedPolicy> & {
32
+ explain?: {
33
+ predicateTree: PredicateNode | null
34
+ simulation: SimulationResult
35
+ }
36
+ } {
24
37
  const ir = mandateToPolicyIR(spec)
25
38
  const adapter = createOzAdapter(ozConfig)
26
39
  const result = adapter.compile(ir)
@@ -42,5 +55,28 @@ export function synthesizeFromMandate(
42
55
  ...result.proposed,
43
56
  warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
44
57
  }
45
- return { ok: true, data: proposed }
58
+ // Same --explain envelope pattern as the recording path. The mandate path
59
+ // never produces an interpreter predicate, so predicateTree is null and
60
+ // the simulation is a minimal honest deny (no self-verify was performed).
61
+ const envelope: ToolResponse<ProposedPolicy> & {
62
+ explain?: {
63
+ predicateTree: PredicateNode | null
64
+ simulation: SimulationResult
65
+ }
66
+ } = { ok: true, data: proposed }
67
+ if (opts?.explain) {
68
+ envelope.explain = {
69
+ predicateTree: null,
70
+ simulation: {
71
+ permit: {
72
+ tx: 'deny',
73
+ reason: 'No self-verification was performed (mandate path is OZ-only)',
74
+ },
75
+ evaluatedCases: [],
76
+ backend: 'ts-model',
77
+ simulatorVersion: 'not-run',
78
+ },
79
+ }
80
+ }
81
+ return envelope
46
82
  }
@@ -52,6 +52,7 @@ import {
52
52
  type RecordedTransaction,
53
53
  SOROBAN_LIMITS,
54
54
  } from '../types.ts'
55
+ import type { SimulationResult } from '../verify/envelope.ts'
55
56
  import {
56
57
  type ComposeOptions,
57
58
  type ComposeUserResponses,
@@ -101,13 +102,36 @@ export interface SynthesizeFromRecordingOptions {
101
102
  userResponses?: ComposeUserResponses
102
103
  confidenceOverride?: { threshold: number }
103
104
  interpreter?: InterpreterAdapterOptions
105
+ /** --explain opt-in. When true, the orchestrator attaches the
106
+ * in-memory `PredicateNode` + the corresponding `SimulationResult`
107
+ * to the success envelope so the CLI can render a human-readable
108
+ * review card. Absent or false -> the success envelope is unchanged
109
+ * (byte-identical to today). The flag is ADDITIVE: the existing
110
+ * ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
111
+ * never altered by enabling explain. */
112
+ explain?: true
104
113
  }
105
114
 
106
115
  export function synthesizeFromRecording(
107
116
  tx: RecordedTransaction,
108
117
  opts: SynthesizeFromRecordingOptions,
109
118
  ozConfig: OzAdapterConfig
110
- ): ToolResponse<ProposedPolicy> {
119
+ ): ToolResponse<ProposedPolicy> & {
120
+ /** Present iff `opts.explain === true` and the synthesis succeeded. The
121
+ * `predicateTree` is the exact in-memory `PredicateNode` (canonical
122
+ * JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
123
+ * - reading the predicate tree from the policy document is therefore
124
+ * unnecessary; the orchestrator carries it through. The `simulation`
125
+ * is the verdict produced by the same self-verify pipeline that already
126
+ * runs in the recording path (runHarness + evaluate), so the value is
127
+ * REAL not synthetic. Absent when the synthesis did not engage the
128
+ * interpreter (no interpreter opts supplied) - in that case the CLI
129
+ * builds a minimal honest SimulationResult downstream. */
130
+ explain?: {
131
+ predicateTree: PredicateNode | null
132
+ simulation: SimulationResult
133
+ }
134
+ } {
111
135
  // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
112
136
  // with a string `.code`) inside the body is converted to a structured
113
137
  // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
@@ -159,7 +183,12 @@ function synthesizeFromRecordingInner(
159
183
  tx: RecordedTransaction,
160
184
  opts: SynthesizeFromRecordingOptions,
161
185
  ozConfig: OzAdapterConfig
162
- ): ToolResponse<ProposedPolicy> {
186
+ ): ToolResponse<ProposedPolicy> & {
187
+ explain?: {
188
+ predicateTree: PredicateNode | null
189
+ simulation: SimulationResult
190
+ }
191
+ } {
163
192
  // 0. validate inputs (fail closed - never synthesize from garbage).
164
193
  const invalid = validateOptions(opts)
165
194
  if (invalid) return { ok: false, error: invalid }
@@ -272,6 +301,15 @@ function synthesizeFromRecordingInner(
272
301
  }
273
302
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
274
303
 
304
+ // --explain hook: capture the in-memory predicate tree + the real
305
+ // self-verify verdict so the CLI can render a faithful review card.
306
+ // The verdict below is built from the SAME runHarness + evaluate that
307
+ // already gates the synthesis (it is not a parallel simulation); the
308
+ // intermediate inputs (harnessCases, evalResult) are otherwise discarded
309
+ // after the gate, so the explain hook reuses them - no extra work.
310
+ let explain: PredicateNode | null = null
311
+ let explainSim: SimulationResult | null = null
312
+
275
313
  // 5. OZ compile (always runs).
276
314
  const ozAdapter = createOzAdapter(ozConfig)
277
315
  const compileRes = ozAdapter.compile(composed.ir)
@@ -449,7 +487,6 @@ function synthesizeFromRecordingInner(
449
487
  },
450
488
  }
451
489
  }
452
-
453
490
  const evalResult = evaluate(finalPredicate, permitCtx)
454
491
  if (!evalResult.permit) {
455
492
  return {
@@ -464,6 +501,37 @@ function synthesizeFromRecordingInner(
464
501
  }
465
502
  }
466
503
 
504
+ // --explain capture: the interpreter path already produced the real
505
+ // self-verify verdict (runHarness passed, evalResult.permit is true).
506
+ // Build the SimulationResult from those outputs so the CLI card
507
+ // quotes the SAME verdict that gated the synthesis. We re-evaluate
508
+ // each deny case to surface its concrete reason; the harness only
509
+ // records whether the got-matches-expected boundary held.
510
+ if (opts.explain) {
511
+ explain = finalPredicate
512
+ const evaluatedCases: SimulationResult['evaluatedCases'] = [
513
+ {
514
+ dimension: 'permit',
515
+ outcome: evalResult.permit ? 'permit' : 'deny',
516
+ reason: 'matches recorded call',
517
+ },
518
+ ]
519
+ for (const deny of harnessCases.denies) {
520
+ const r = evaluate(finalPredicate, deny.ctx)
521
+ evaluatedCases.push({
522
+ dimension: deny.dimension,
523
+ outcome: r.permit ? 'permit' : 'deny',
524
+ reason: r.permit ? 'no matching deny' : r.reason,
525
+ })
526
+ }
527
+ explainSim = {
528
+ permit: { tx: 'permit' },
529
+ evaluatedCases,
530
+ backend: 'ts-model',
531
+ simulatorVersion: 'ts-model-1.0.0',
532
+ }
533
+ }
534
+
467
535
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
468
536
  // canonical bytes back onto the PolicyDocument + PolicyRef. The
469
537
  // `encodePredicate` helper throws ToolError-shaped errors on cap
@@ -566,7 +634,38 @@ function synthesizeFromRecordingInner(
566
634
  ],
567
635
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
568
636
  }
569
- return { ok: true, data: proposed }
637
+ // --explain success envelope. The interpreter path populated
638
+ // `explain` + `explainSim` from the real self-verify verdict above;
639
+ // the OZ-only path did not (no predicate tree exists). When opts.explain
640
+ // is set and the OZ-only path ran, construct the minimal honest
641
+ // SimulationResult: the verdict is NOT a passing simulation - the
642
+ // interpreter was never engaged, so permit is deny with a truthful
643
+ // reason and evaluatedCases is empty.
644
+ const envelope: ToolResponse<ProposedPolicy> & {
645
+ explain?: {
646
+ predicateTree: PredicateNode | null
647
+ simulation: SimulationResult
648
+ }
649
+ } = { ok: true, data: proposed }
650
+ if (opts.explain) {
651
+ if (explainSim) {
652
+ envelope.explain = { predicateTree: explain, simulation: explainSim }
653
+ } else {
654
+ envelope.explain = {
655
+ predicateTree: null,
656
+ simulation: {
657
+ permit: {
658
+ tx: 'deny',
659
+ reason: 'No self-verification was performed (interpreter adapter was not engaged)',
660
+ },
661
+ evaluatedCases: [],
662
+ backend: 'ts-model',
663
+ simulatorVersion: 'not-run',
664
+ },
665
+ }
666
+ }
667
+ }
668
+ return envelope
570
669
  }
571
670
 
572
671
  // Re-export the throwToolError helper for callers that need to surface a