@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
@@ -175,6 +175,38 @@ function evalCompare(op, left, right, ctx) {
175
175
  return evalArgOrderedCompare(op, actual, right);
176
176
  return evalArgEq(op, actual, right, ctx);
177
177
  }
178
+ // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
179
+ // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
180
+ if (left.kind === 'call_arg_len') {
181
+ const actual = ctx.args[left.index];
182
+ if (!actual || actual.type !== 'vec')
183
+ return { permit: false, reason: 'ARG_MISMATCH' };
184
+ if (right.kind !== 'literal_u32')
185
+ return { permit: false, reason: 'ARG_MISMATCH' };
186
+ return actual.value.length === right.value
187
+ ? { permit: true }
188
+ : { permit: false, reason: 'ARG_MISMATCH' };
189
+ }
190
+ // --- step 4d: call_arg_field: the value of a field in the map at element i
191
+ // of the vec at argument index. Fails closed on a non-vec arg, an
192
+ // out-of-range element, a missing field, a non-map element, or a type
193
+ // mismatch between the field ScVal and the literal leaf.
194
+ if (left.kind === 'call_arg_field') {
195
+ const actual = ctx.args[left.index];
196
+ if (!actual || actual.type !== 'vec')
197
+ return { permit: false, reason: 'ARG_MISMATCH' };
198
+ const element = actual.value[left.element];
199
+ if (!element || element.type !== 'map')
200
+ return { permit: false, reason: 'ARG_MISMATCH' };
201
+ if (!Array.isArray(element.value))
202
+ return { permit: false, reason: 'ARG_MISMATCH' };
203
+ const entry = element.value.find((e) => e.key === left.field);
204
+ if (!entry)
205
+ return { permit: false, reason: 'ARG_MISMATCH' };
206
+ if (op === 'eq')
207
+ return evalArgEq(op, entry.val, right, ctx);
208
+ return evalArgOrderedCompare(op, entry.val, right);
209
+ }
178
210
  // --- step 6: AMOUNT_BOUND ---
179
211
  if (left.kind === 'amount' && op !== 'eq') {
180
212
  return evalAmountCompare(op, left.token, right, ctx);
@@ -334,6 +366,23 @@ function resolveLeaf(leaf, ctx) {
334
366
  return { type: 'symbol', value: ctx.fn };
335
367
  case 'call_arg':
336
368
  return ctx.args[leaf.index];
369
+ case 'call_arg_len':
370
+ // No direct ScVal projection: the length is an integer the comparator
371
+ // resolves against the right-hand literal. Returning undefined keeps
372
+ // the `in` membership path structurally informed (no haystack match).
373
+ return undefined;
374
+ case 'call_arg_field': {
375
+ const actual = ctx.args[leaf.index];
376
+ if (!actual || actual.type !== 'vec')
377
+ return undefined;
378
+ const element = actual.value[leaf.element];
379
+ if (!element || element.type !== 'map')
380
+ return undefined;
381
+ if (!Array.isArray(element.value))
382
+ return undefined;
383
+ const entry = element.value.find((e) => e.key === leaf.field);
384
+ return entry ? entry.val : undefined;
385
+ }
337
386
  case 'amount':
338
387
  case 'window_spent':
339
388
  case 'oracle_price':
@@ -1,5 +1,20 @@
1
1
  import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
2
2
  import type { ToolResponse } from '../errors.ts';
3
3
  import type { MandateSpec } from '../mandate/types.ts';
4
- import type { ProposedPolicy } from '../types.ts';
5
- export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy>;
4
+ import type { PredicateNode, ProposedPolicy } from '../types.ts';
5
+ import type { SimulationResult } from '../verify/envelope.ts';
6
+ export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig,
7
+ /** --explain opt-in. When true, the success envelope carries the
8
+ * in-memory predicate tree (always null for the mandate path - the
9
+ * declarative MandateSpec lowers to OZ built-ins, not to an
10
+ * interpreter predicate) + a minimal honest SimulationResult. The
11
+ * flag is ADDITIVE: the existing ProposedPolicy fields are never
12
+ * altered. */
13
+ opts?: {
14
+ explain?: true;
15
+ }): ToolResponse<ProposedPolicy> & {
16
+ explain?: {
17
+ predicateTree: PredicateNode | null;
18
+ simulation: SimulationResult;
19
+ };
20
+ };
@@ -10,7 +10,14 @@
10
10
  import { createOzAdapter } from "../adapters/oz/adapter.js";
11
11
  import { mandateToPolicyIR } from "../mandate/to-ir.js";
12
12
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
13
- export function synthesizeFromMandate(spec, ozConfig) {
13
+ export function synthesizeFromMandate(spec, ozConfig,
14
+ /** --explain opt-in. When true, the success envelope carries the
15
+ * in-memory predicate tree (always null for the mandate path - the
16
+ * declarative MandateSpec lowers to OZ built-ins, not to an
17
+ * interpreter predicate) + a minimal honest SimulationResult. The
18
+ * flag is ADDITIVE: the existing ProposedPolicy fields are never
19
+ * altered. */
20
+ opts) {
14
21
  const ir = mandateToPolicyIR(spec);
15
22
  const adapter = createOzAdapter(ozConfig);
16
23
  const result = adapter.compile(ir);
@@ -30,5 +37,23 @@ export function synthesizeFromMandate(spec, ozConfig) {
30
37
  ...result.proposed,
31
38
  warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
32
39
  };
33
- return { ok: true, data: proposed };
40
+ // Same --explain envelope pattern as the recording path. The mandate path
41
+ // never produces an interpreter predicate, so predicateTree is null and
42
+ // the simulation is a minimal honest deny (no self-verify was performed).
43
+ const envelope = { ok: true, data: proposed };
44
+ if (opts?.explain) {
45
+ envelope.explain = {
46
+ predicateTree: null,
47
+ simulation: {
48
+ permit: {
49
+ tx: 'deny',
50
+ reason: 'No self-verification was performed (mandate path is OZ-only)',
51
+ },
52
+ evaluatedCases: [],
53
+ backend: 'ts-model',
54
+ simulatorVersion: 'not-run',
55
+ },
56
+ };
57
+ }
58
+ return envelope;
34
59
  }
@@ -1,6 +1,7 @@
1
1
  import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
2
2
  import type { ToolError, ToolResponse } from '../errors.ts';
3
3
  import { type Network, type PredicateNode, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
4
+ import type { SimulationResult } from '../verify/envelope.ts';
4
5
  import { type ComposeUserResponses } from './compose-from-recording.ts';
5
6
  /** Per-policy interpreter adapter config the caller opts into. Absent ->
6
7
  * recording path runs in week-1 mode (OZ adapter only, warnings for the
@@ -41,8 +42,31 @@ export interface SynthesizeFromRecordingOptions {
41
42
  threshold: number;
42
43
  };
43
44
  interpreter?: InterpreterAdapterOptions;
45
+ /** --explain opt-in. When true, the orchestrator attaches the
46
+ * in-memory `PredicateNode` + the corresponding `SimulationResult`
47
+ * to the success envelope so the CLI can render a human-readable
48
+ * review card. Absent or false -> the success envelope is unchanged
49
+ * (byte-identical to today). The flag is ADDITIVE: the existing
50
+ * ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
51
+ * never altered by enabling explain. */
52
+ explain?: true;
44
53
  }
45
- export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy>;
54
+ export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
55
+ /** Present iff `opts.explain === true` and the synthesis succeeded. The
56
+ * `predicateTree` is the exact in-memory `PredicateNode` (canonical
57
+ * JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
58
+ * - reading the predicate tree from the policy document is therefore
59
+ * unnecessary; the orchestrator carries it through. The `simulation`
60
+ * is the verdict produced by the same self-verify pipeline that already
61
+ * runs in the recording path (runHarness + evaluate), so the value is
62
+ * REAL not synthetic. Absent when the synthesis did not engage the
63
+ * interpreter (no interpreter opts supplied) - in that case the CLI
64
+ * builds a minimal honest SimulationResult downstream. */
65
+ explain?: {
66
+ predicateTree: PredicateNode | null;
67
+ simulation: SimulationResult;
68
+ };
69
+ };
46
70
  /** ToolError-shaped error helper used by the body to surface a structured
47
71
  * failure that the envelope converts to `{ok:false, error}`. */
48
72
  declare function throwToolError(code: ToolError['code'], message: string): never;
@@ -195,6 +195,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
195
195
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
196
196
  };
197
197
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
198
+ // --explain hook: capture the in-memory predicate tree + the real
199
+ // self-verify verdict so the CLI can render a faithful review card.
200
+ // The verdict below is built from the SAME runHarness + evaluate that
201
+ // already gates the synthesis (it is not a parallel simulation); the
202
+ // intermediate inputs (harnessCases, evalResult) are otherwise discarded
203
+ // after the gate, so the explain hook reuses them - no extra work.
204
+ let explain = null;
205
+ let explainSim = null;
198
206
  // 5. OZ compile (always runs).
199
207
  const ozAdapter = createOzAdapter(ozConfig);
200
208
  const compileRes = ozAdapter.compile(composed.ir);
@@ -372,6 +380,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
372
380
  },
373
381
  };
374
382
  }
383
+ // --explain capture: the interpreter path already produced the real
384
+ // self-verify verdict (runHarness passed, evalResult.permit is true).
385
+ // Build the SimulationResult from those outputs so the CLI card
386
+ // quotes the SAME verdict that gated the synthesis. We re-evaluate
387
+ // each deny case to surface its concrete reason; the harness only
388
+ // records whether the got-matches-expected boundary held.
389
+ if (opts.explain) {
390
+ explain = finalPredicate;
391
+ const evaluatedCases = [
392
+ {
393
+ dimension: 'permit',
394
+ outcome: evalResult.permit ? 'permit' : 'deny',
395
+ reason: 'matches recorded call',
396
+ },
397
+ ];
398
+ for (const deny of harnessCases.denies) {
399
+ const r = evaluate(finalPredicate, deny.ctx);
400
+ evaluatedCases.push({
401
+ dimension: deny.dimension,
402
+ outcome: r.permit ? 'permit' : 'deny',
403
+ reason: r.permit ? 'no matching deny' : r.reason,
404
+ });
405
+ }
406
+ explainSim = {
407
+ permit: { tx: 'permit' },
408
+ evaluatedCases,
409
+ backend: 'ts-model',
410
+ simulatorVersion: 'ts-model-1.0.0',
411
+ };
412
+ }
375
413
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
376
414
  // canonical bytes back onto the PolicyDocument + PolicyRef. The
377
415
  // `encodePredicate` helper throws ToolError-shaped errors on cap
@@ -469,7 +507,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
469
507
  ],
470
508
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
471
509
  };
472
- return { ok: true, data: proposed };
510
+ // --explain success envelope. The interpreter path populated
511
+ // `explain` + `explainSim` from the real self-verify verdict above;
512
+ // the OZ-only path did not (no predicate tree exists). When opts.explain
513
+ // is set and the OZ-only path ran, construct the minimal honest
514
+ // SimulationResult: the verdict is NOT a passing simulation - the
515
+ // interpreter was never engaged, so permit is deny with a truthful
516
+ // reason and evaluatedCases is empty.
517
+ const envelope = { ok: true, data: proposed };
518
+ if (opts.explain) {
519
+ if (explainSim) {
520
+ envelope.explain = { predicateTree: explain, simulation: explainSim };
521
+ }
522
+ else {
523
+ envelope.explain = {
524
+ predicateTree: null,
525
+ simulation: {
526
+ permit: {
527
+ tx: 'deny',
528
+ reason: 'No self-verification was performed (interpreter adapter was not engaged)',
529
+ },
530
+ evaluatedCases: [],
531
+ backend: 'ts-model',
532
+ simulatorVersion: 'not-run',
533
+ },
534
+ };
535
+ }
536
+ }
537
+ return envelope;
473
538
  }
474
539
  // Re-export the throwToolError helper for callers that need to surface a
475
540
  // ToolError-shaped throw inside the body (e.g. tests).
package/dist/types.d.ts CHANGED
@@ -182,6 +182,14 @@ export type PredicateLeaf = {
182
182
  } | {
183
183
  kind: 'call_arg';
184
184
  index: number;
185
+ } | {
186
+ kind: 'call_arg_len';
187
+ index: number;
188
+ } | {
189
+ kind: 'call_arg_field';
190
+ index: number;
191
+ element: number;
192
+ field: string;
185
193
  } | {
186
194
  kind: 'amount';
187
195
  token: string;
@@ -312,6 +312,10 @@ function lowerSelector(s) {
312
312
  switch (s.kind) {
313
313
  case 'arg':
314
314
  return { kind: 'call_arg', index: s.argIndex };
315
+ case 'arg_len':
316
+ return { kind: 'call_arg_len', index: s.argIndex };
317
+ case 'arg_field':
318
+ return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
315
319
  case 'amount':
316
320
  return { kind: 'amount', token: s.token };
317
321
  case 'window_spent':
@@ -334,13 +338,15 @@ function lowerSelector(s) {
334
338
  * selector kind to the matching `literal_*` kind. The IR compare value is a
335
339
  * raw string (i128-safe); the selector kind fixes the canonical wire type:
336
340
  * - arg -> IR scalarType (set by the recorder/parser)
341
+ * - arg_len -> u32 (vec length is a small non-negative integer)
342
+ * - arg_field -> IR scalarType (the field's recorded type)
337
343
  * - amount -> i128 (canonical Stellar token amount encoding)
338
344
  * - window_spent -> i128 (canonical amount encoding)
339
345
  * - oracle_price -> i128 (canonical price encoding)
340
346
  * - invocation_count -> u32 (counts are small non-negative integers)
341
347
  * - now / valid_until -> u64 (unix timestamps in seconds) */
342
348
  function literalFromIRCompare(c) {
343
- const scalarType = c.selector.kind === 'arg' ? c.selector.scalarType : literalScalarForSelector(c.selector.kind);
349
+ const scalarType = selectorScalarType(c.selector);
344
350
  return literalFromScalar(c.value, scalarType);
345
351
  }
346
352
  function literalScalarForSelector(kind) {
@@ -350,14 +356,16 @@ function literalScalarForSelector(kind) {
350
356
  case 'oracle_price':
351
357
  return 'i128';
352
358
  case 'invocation_count':
359
+ case 'arg_len':
353
360
  return 'u32';
354
361
  case 'now':
355
362
  case 'valid_until':
356
363
  return 'u64';
357
364
  case 'arg':
365
+ case 'arg_field':
358
366
  case 'calldata':
359
367
  case 'value':
360
- // arg -> caller handles scalarType; calldata/value -> unreachable (Path-B).
368
+ // arg / arg_field -> caller handles scalarType; calldata/value -> unreachable (Path-B).
361
369
  return 'i128';
362
370
  }
363
371
  }
@@ -385,10 +393,12 @@ function literalFromScalar(value, scalarType) {
385
393
  /** Scalar type of an IRSelector for the purpose of building literal leaves
386
394
  * (the right-hand side of `eq` / elements of an `in` haystack / elements of
387
395
  * a `literal_vec`). Mirrors `literalScalarForSelector` for OZ extensions and
388
- * uses the selector's own `scalarType` for `arg` selectors. */
396
+ * uses the selector's own `scalarType` for `arg` and `arg_field` selectors. */
389
397
  function selectorScalarType(selector) {
390
398
  if (selector.kind === 'arg')
391
399
  return selector.scalarType;
400
+ if (selector.kind === 'arg_field')
401
+ return selector.scalarType;
392
402
  return literalScalarForSelector(selector.kind);
393
403
  }
394
404
  /** Walk a condition tree and throw if any oracle_price leaf is found anywhere
@@ -462,6 +472,10 @@ function describeCondition(cond) {
462
472
  return `per-call amount comparison on ${s.token}`;
463
473
  case 'arg':
464
474
  return `argument comparison on arg ${s.argIndex}`;
475
+ case 'arg_len':
476
+ return `vec length comparison on arg ${s.argIndex}`;
477
+ case 'arg_field':
478
+ return `map field comparison on arg ${s.argIndex}.${s.field}`;
465
479
  case 'calldata':
466
480
  return 'EVM calldata comparison';
467
481
  case 'value':
@@ -477,6 +491,10 @@ function describeSelector(s) {
477
491
  switch (s.kind) {
478
492
  case 'arg':
479
493
  return `arg ${s.argIndex}`;
494
+ case 'arg_len':
495
+ return `arg_len(${s.argIndex})`;
496
+ case 'arg_field':
497
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`;
480
498
  case 'amount':
481
499
  return `amount(${s.token})`;
482
500
  case 'window_spent':
@@ -234,6 +234,10 @@ function describeCondition(cond) {
234
234
  return `per-call amount comparison on ${s.token} (predicate DSL)`;
235
235
  case 'arg':
236
236
  return `argument comparison on arg ${s.argIndex} (predicate DSL)`;
237
+ case 'arg_len':
238
+ return `vec length comparison on arg ${s.argIndex} (predicate DSL)`;
239
+ case 'arg_field':
240
+ return `map field comparison on arg ${s.argIndex}.${s.field} (predicate DSL)`;
237
241
  case 'calldata':
238
242
  return 'EVM calldata comparison (predicate DSL)';
239
243
  case 'value':
@@ -249,6 +253,10 @@ function describeSelector(s) {
249
253
  switch (s.kind) {
250
254
  case 'arg':
251
255
  return `arg ${s.argIndex}`;
256
+ case 'arg_len':
257
+ return `arg_len(${s.argIndex})`;
258
+ case 'arg_field':
259
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`;
252
260
  case 'amount':
253
261
  return `amount(${s.token})`;
254
262
  case 'window_spent':
@@ -14,6 +14,15 @@ export type IRSelector = {
14
14
  fieldIndex?: number;
15
15
  vecMode?: IRVecMode;
16
16
  scalarType: IRScalarType;
17
+ } | {
18
+ kind: 'arg_len';
19
+ argIndex: number;
20
+ } | {
21
+ kind: 'arg_field';
22
+ argIndex: number;
23
+ element: number;
24
+ field: string;
25
+ scalarType: IRScalarType;
17
26
  } | {
18
27
  kind: 'calldata';
19
28
  offset: number;
@@ -170,6 +170,15 @@ function encodeLeaf(leaf) {
170
170
  return stellar_sdk_1.xdr.ScVal.scvVec([symbol('call_fn')]);
171
171
  case 'call_arg':
172
172
  return stellar_sdk_1.xdr.ScVal.scvVec([symbol('call_arg'), stellar_sdk_1.xdr.ScVal.scvU32(leaf.index)]);
173
+ case 'call_arg_len':
174
+ return stellar_sdk_1.xdr.ScVal.scvVec([symbol('call_arg_len'), stellar_sdk_1.xdr.ScVal.scvU32(leaf.index)]);
175
+ case 'call_arg_field':
176
+ return stellar_sdk_1.xdr.ScVal.scvVec([
177
+ symbol('call_arg_field'),
178
+ stellar_sdk_1.xdr.ScVal.scvU32(leaf.index),
179
+ stellar_sdk_1.xdr.ScVal.scvU32(leaf.element),
180
+ stellar_sdk_1.xdr.ScVal.scvSymbol(leaf.field),
181
+ ]);
173
182
  case 'amount':
174
183
  return stellar_sdk_1.xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)]);
175
184
  case 'window_spent':
@@ -141,6 +141,36 @@ function renderComparison(node) {
141
141
  if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
142
142
  return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
143
143
  }
144
+ // eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
145
+ // Binds the OUTER vec length so a caller cannot append an element to
146
+ // defeat a per-element pin. Rendered as a single readable line so the
147
+ // review card surfaces the implicit "no new elements" guarantee.
148
+ if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
149
+ return `Length of arg[${left.index}] is ${right.value}`;
150
+ }
151
+ // eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
152
+ // The structured-argument bind pins a scalar field inside a vec element.
153
+ // For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
154
+ // is visible; eq reads "= <value>".
155
+ if (left.kind === 'call_arg_field') {
156
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`;
157
+ const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
158
+ if (right.kind === 'literal_address')
159
+ return `${head} ${sep} ${right.value}`;
160
+ if (right.kind === 'literal_symbol')
161
+ return `${head} ${sep} ${right.value}`;
162
+ if (right.kind === 'literal_bytes')
163
+ return `${head} ${sep} ${right.value}`;
164
+ if (right.kind === 'literal_u64')
165
+ return `${head} ${sep} ${right.value}`;
166
+ if (right.kind === 'literal_i128')
167
+ return `${head} ${sep} ${right.value}`;
168
+ if (right.kind === 'literal_u32')
169
+ return `${head} ${sep} ${right.value}`;
170
+ if (right.kind === 'literal_vec') {
171
+ return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
172
+ }
173
+ }
144
174
  // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
145
175
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
146
176
  return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
@@ -187,6 +217,8 @@ function renderVecElement(leaf) {
187
217
  case 'call_contract':
188
218
  case 'call_fn':
189
219
  case 'call_arg':
220
+ case 'call_arg_len':
221
+ case 'call_arg_field':
190
222
  case 'amount':
191
223
  case 'window_spent':
192
224
  case 'now':
@@ -70,6 +70,44 @@ function pushComparison(left, right, op, out) {
70
70
  out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
71
71
  return;
72
72
  }
73
+ if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
74
+ out.push(`Length of arg[${left.index}] is ${right.value}`);
75
+ return;
76
+ }
77
+ // call_arg_field: must mirror the builder's templates byte-for-byte so
78
+ // the cross-check can assert a faithful summary.
79
+ if (left.kind === 'call_arg_field') {
80
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`;
81
+ const sep = op === 'eq' ? '=' : comparisonOpText(op);
82
+ if (right.kind === 'literal_address') {
83
+ out.push(`${head} ${sep} ${right.value}`);
84
+ return;
85
+ }
86
+ if (right.kind === 'literal_symbol') {
87
+ out.push(`${head} ${sep} ${right.value}`);
88
+ return;
89
+ }
90
+ if (right.kind === 'literal_bytes') {
91
+ out.push(`${head} ${sep} ${right.value}`);
92
+ return;
93
+ }
94
+ if (right.kind === 'literal_u64') {
95
+ out.push(`${head} ${sep} ${right.value}`);
96
+ return;
97
+ }
98
+ if (right.kind === 'literal_i128') {
99
+ out.push(`${head} ${sep} ${right.value}`);
100
+ return;
101
+ }
102
+ if (right.kind === 'literal_u32') {
103
+ out.push(`${head} ${sep} ${right.value}`);
104
+ return;
105
+ }
106
+ if (right.kind === 'literal_vec') {
107
+ out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
108
+ return;
109
+ }
110
+ }
73
111
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
74
112
  out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
75
113
  return;
@@ -108,6 +146,8 @@ function renderVecElement(leaf) {
108
146
  case 'call_contract':
109
147
  case 'call_fn':
110
148
  case 'call_arg':
149
+ case 'call_arg_len':
150
+ case 'call_arg_field':
111
151
  case 'amount':
112
152
  case 'window_spent':
113
153
  case 'now':
@@ -1,4 +1,5 @@
1
- import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
1
+ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
+ import type { SimulationResult } from '../verify/envelope.ts';
2
3
  import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
4
  export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
5
  export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
28
29
  * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
29
30
  * XDR decode error in the adapter) would otherwise surface as
30
31
  * "[object Object]" in the MCP transport. */
31
- export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
32
+ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
33
+ explain?: {
34
+ predicateTree: PredicateNode | null;
35
+ simulation: SimulationResult;
36
+ };
37
+ }>;
32
38
  /** Build a canonical ToolError for a thrown exception caught by the tool
33
39
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
34
40
  * default, so we extract a string-friendly message and tag the original
@@ -104,7 +104,7 @@ async function runSynthesizePolicy(raw) {
104
104
  // Zod's optional fields widen to `T | undefined`, which the core's
105
105
  // exact-optional MandateSpec rejects; the schema already validated the
106
106
  // shape, so assert it (same pattern as the recordedTx cast below).
107
- return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig);
107
+ return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
108
108
  }
109
109
  // recording source
110
110
  const recorded = input.recordedTx;
@@ -115,6 +115,7 @@ async function runSynthesizePolicy(raw) {
115
115
  ? { confidenceOverride: input.confidenceOverride }
116
116
  : {}),
117
117
  ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
118
+ ...(input.explain === true ? { explain: true } : {}),
118
119
  }, ozConfig);
119
120
  }
120
121
  catch (e) {
@@ -648,6 +648,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
648
648
  weighted_threshold: string;
649
649
  };
650
650
  }>>;
651
+ explain: z.ZodOptional<z.ZodBoolean>;
651
652
  }, "strip", z.ZodTypeAny, {
652
653
  source: "mandate";
653
654
  mandate: {
@@ -676,6 +677,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
676
677
  weighted_threshold: string;
677
678
  };
678
679
  } | undefined;
680
+ explain?: boolean | undefined;
679
681
  }, {
680
682
  source: "mandate";
681
683
  mandate: {
@@ -704,6 +706,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
704
706
  weighted_threshold: string;
705
707
  };
706
708
  } | undefined;
709
+ explain?: boolean | undefined;
707
710
  }>;
708
711
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
709
712
  * express (per-method scoping, invocation-count windows, oracle bounds, exact
@@ -1085,6 +1088,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1085
1088
  weighted_threshold: string;
1086
1089
  };
1087
1090
  }>>;
1091
+ explain: z.ZodOptional<z.ZodBoolean>;
1088
1092
  }, "strip", z.ZodTypeAny, {
1089
1093
  network: "mainnet" | "testnet";
1090
1094
  source: "recording";
@@ -1149,6 +1153,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1149
1153
  weighted_threshold: string;
1150
1154
  };
1151
1155
  } | undefined;
1156
+ explain?: boolean | undefined;
1152
1157
  }, {
1153
1158
  network: "mainnet" | "testnet";
1154
1159
  source: "recording";
@@ -1213,6 +1218,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1213
1218
  weighted_threshold: string;
1214
1219
  };
1215
1220
  } | undefined;
1221
+ explain?: boolean | undefined;
1216
1222
  }>;
1217
1223
  export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
1218
1224
  source: z.ZodLiteral<"mandate">;
@@ -1334,6 +1340,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1334
1340
  weighted_threshold: string;
1335
1341
  };
1336
1342
  }>>;
1343
+ explain: z.ZodOptional<z.ZodBoolean>;
1337
1344
  }, "strip", z.ZodTypeAny, {
1338
1345
  source: "mandate";
1339
1346
  mandate: {
@@ -1362,6 +1369,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1362
1369
  weighted_threshold: string;
1363
1370
  };
1364
1371
  } | undefined;
1372
+ explain?: boolean | undefined;
1365
1373
  }, {
1366
1374
  source: "mandate";
1367
1375
  mandate: {
@@ -1390,6 +1398,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1390
1398
  weighted_threshold: string;
1391
1399
  };
1392
1400
  } | undefined;
1401
+ explain?: boolean | undefined;
1393
1402
  }>, z.ZodObject<{
1394
1403
  source: z.ZodLiteral<"recording">;
1395
1404
  recordedTx: z.ZodObject<{
@@ -1736,6 +1745,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1736
1745
  weighted_threshold: string;
1737
1746
  };
1738
1747
  }>>;
1748
+ explain: z.ZodOptional<z.ZodBoolean>;
1739
1749
  }, "strip", z.ZodTypeAny, {
1740
1750
  network: "mainnet" | "testnet";
1741
1751
  source: "recording";
@@ -1800,6 +1810,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1800
1810
  weighted_threshold: string;
1801
1811
  };
1802
1812
  } | undefined;
1813
+ explain?: boolean | undefined;
1803
1814
  }, {
1804
1815
  network: "mainnet" | "testnet";
1805
1816
  source: "recording";
@@ -1864,6 +1875,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1864
1875
  weighted_threshold: string;
1865
1876
  };
1866
1877
  } | undefined;
1878
+ explain?: boolean | undefined;
1867
1879
  }>]>;
1868
1880
  export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>;
1869
1881
  export declare const ToolErrorSchema: z.ZodObject<{