@crediolabs/policy-synth 0.1.9 → 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.
@@ -138,6 +138,36 @@ function renderComparison(node) {
138
138
  if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
139
139
  return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
140
140
  }
141
+ // eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
142
+ // Binds the OUTER vec length so a caller cannot append an element to
143
+ // defeat a per-element pin. Rendered as a single readable line so the
144
+ // review card surfaces the implicit "no new elements" guarantee.
145
+ if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
146
+ return `Length of arg[${left.index}] is ${right.value}`;
147
+ }
148
+ // eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
149
+ // The structured-argument bind pins a scalar field inside a vec element.
150
+ // For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
151
+ // is visible; eq reads "= <value>".
152
+ if (left.kind === 'call_arg_field') {
153
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`;
154
+ const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
155
+ if (right.kind === 'literal_address')
156
+ return `${head} ${sep} ${right.value}`;
157
+ if (right.kind === 'literal_symbol')
158
+ return `${head} ${sep} ${right.value}`;
159
+ if (right.kind === 'literal_bytes')
160
+ return `${head} ${sep} ${right.value}`;
161
+ if (right.kind === 'literal_u64')
162
+ return `${head} ${sep} ${right.value}`;
163
+ if (right.kind === 'literal_i128')
164
+ return `${head} ${sep} ${right.value}`;
165
+ if (right.kind === 'literal_u32')
166
+ return `${head} ${sep} ${right.value}`;
167
+ if (right.kind === 'literal_vec') {
168
+ return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
169
+ }
170
+ }
141
171
  // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
142
172
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
143
173
  return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
@@ -67,6 +67,44 @@ function pushComparison(left, right, op, out) {
67
67
  out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
68
68
  return;
69
69
  }
70
+ if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
71
+ out.push(`Length of arg[${left.index}] is ${right.value}`);
72
+ return;
73
+ }
74
+ // call_arg_field: must mirror the builder's templates byte-for-byte so
75
+ // the cross-check can assert a faithful summary.
76
+ if (left.kind === 'call_arg_field') {
77
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`;
78
+ const sep = op === 'eq' ? '=' : comparisonOpText(op);
79
+ if (right.kind === 'literal_address') {
80
+ out.push(`${head} ${sep} ${right.value}`);
81
+ return;
82
+ }
83
+ if (right.kind === 'literal_symbol') {
84
+ out.push(`${head} ${sep} ${right.value}`);
85
+ return;
86
+ }
87
+ if (right.kind === 'literal_bytes') {
88
+ out.push(`${head} ${sep} ${right.value}`);
89
+ return;
90
+ }
91
+ if (right.kind === 'literal_u64') {
92
+ out.push(`${head} ${sep} ${right.value}`);
93
+ return;
94
+ }
95
+ if (right.kind === 'literal_i128') {
96
+ out.push(`${head} ${sep} ${right.value}`);
97
+ return;
98
+ }
99
+ if (right.kind === 'literal_u32') {
100
+ out.push(`${head} ${sep} ${right.value}`);
101
+ return;
102
+ }
103
+ if (right.kind === 'literal_vec') {
104
+ out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
105
+ return;
106
+ }
107
+ }
70
108
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
71
109
  out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
72
110
  return;
@@ -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
package/dist/run/index.js CHANGED
@@ -89,7 +89,7 @@ export async function runSynthesizePolicy(raw) {
89
89
  // Zod's optional fields widen to `T | undefined`, which the core's
90
90
  // exact-optional MandateSpec rejects; the schema already validated the
91
91
  // shape, so assert it (same pattern as the recordedTx cast below).
92
- return await synthesizeFromMandate(input.mandate, ozConfig);
92
+ return await synthesizeFromMandate(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
93
93
  }
94
94
  // recording source
95
95
  const recorded = input.recordedTx;
@@ -100,6 +100,7 @@ export async function runSynthesizePolicy(raw) {
100
100
  ? { confidenceOverride: input.confidenceOverride }
101
101
  : {}),
102
102
  ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
103
+ ...(input.explain === true ? { explain: true } : {}),
103
104
  }, ozConfig);
104
105
  }
105
106
  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<{
@@ -175,6 +175,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
175
175
  source: z.literal('mandate'),
176
176
  mandate: MandateSpecSchema,
177
177
  ozConfig: OzAdapterConfigSchema.optional(),
178
+ // --explain opt-in. When true, the orchestrator attaches the
179
+ // in-memory predicate tree (null for the mandate path) + a minimal
180
+ // honest SimulationResult to the success envelope. Absent or false
181
+ // -> the success envelope is unchanged (byte-identical to today).
182
+ explain: z.boolean().optional(),
178
183
  });
179
184
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
180
185
  * express (per-method scoping, invocation-count windows, oracle bounds, exact
@@ -200,6 +205,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
200
205
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
201
206
  interpreter: InterpreterOptionsSchema.optional(),
202
207
  ozConfig: OzAdapterConfigSchema.optional(),
208
+ // --explain opt-in. When true, the orchestrator attaches the
209
+ // in-memory PredicateNode + the corresponding SimulationResult
210
+ // (real one from the self-verify pipeline when the interpreter is
211
+ // engaged, minimal honest value otherwise) to the success envelope.
212
+ // Absent or false -> the success envelope is unchanged (byte-identical
213
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
214
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
215
+ // explain.
216
+ explain: z.boolean().optional(),
203
217
  });
204
218
  export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
205
219
  SynthesizePolicyMandateInputSchema,
@@ -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).
@@ -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`;
@@ -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;
@@ -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<{
@@ -178,6 +178,11 @@ exports.SynthesizePolicyMandateInputSchema = zod_1.z.object({
178
178
  source: zod_1.z.literal('mandate'),
179
179
  mandate: exports.MandateSpecSchema,
180
180
  ozConfig: exports.OzAdapterConfigSchema.optional(),
181
+ // --explain opt-in. When true, the orchestrator attaches the
182
+ // in-memory predicate tree (null for the mandate path) + a minimal
183
+ // honest SimulationResult to the success envelope. Absent or false
184
+ // -> the success envelope is unchanged (byte-identical to today).
185
+ explain: zod_1.z.boolean().optional(),
181
186
  });
182
187
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
183
188
  * express (per-method scoping, invocation-count windows, oracle bounds, exact
@@ -203,6 +208,15 @@ exports.SynthesizePolicyRecordingInputSchema = zod_1.z.object({
203
208
  confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
204
209
  interpreter: exports.InterpreterOptionsSchema.optional(),
205
210
  ozConfig: exports.OzAdapterConfigSchema.optional(),
211
+ // --explain opt-in. When true, the orchestrator attaches the
212
+ // in-memory PredicateNode + the corresponding SimulationResult
213
+ // (real one from the self-verify pipeline when the interpreter is
214
+ // engaged, minimal honest value otherwise) to the success envelope.
215
+ // Absent or false -> the success envelope is unchanged (byte-identical
216
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
217
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
218
+ // explain.
219
+ explain: zod_1.z.boolean().optional(),
206
220
  });
207
221
  exports.SynthesizePolicyInputSchema = zod_1.z.discriminatedUnion('source', [
208
222
  exports.SynthesizePolicyMandateInputSchema,
@@ -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
+ };
@@ -13,7 +13,14 @@ exports.synthesizeFromMandate = synthesizeFromMandate;
13
13
  const adapter_ts_1 = require("../adapters/oz/adapter.js");
14
14
  const to_ir_ts_1 = require("../mandate/to-ir.js");
15
15
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
16
- function synthesizeFromMandate(spec, ozConfig) {
16
+ function synthesizeFromMandate(spec, ozConfig,
17
+ /** --explain opt-in. When true, the success envelope carries the
18
+ * in-memory predicate tree (always null for the mandate path - the
19
+ * declarative MandateSpec lowers to OZ built-ins, not to an
20
+ * interpreter predicate) + a minimal honest SimulationResult. The
21
+ * flag is ADDITIVE: the existing ProposedPolicy fields are never
22
+ * altered. */
23
+ opts) {
17
24
  const ir = (0, to_ir_ts_1.mandateToPolicyIR)(spec);
18
25
  const adapter = (0, adapter_ts_1.createOzAdapter)(ozConfig);
19
26
  const result = adapter.compile(ir);
@@ -33,5 +40,23 @@ function synthesizeFromMandate(spec, ozConfig) {
33
40
  ...result.proposed,
34
41
  warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
35
42
  };
36
- return { ok: true, data: proposed };
43
+ // Same --explain envelope pattern as the recording path. The mandate path
44
+ // never produces an interpreter predicate, so predicateTree is null and
45
+ // the simulation is a minimal honest deny (no self-verify was performed).
46
+ const envelope = { ok: true, data: proposed };
47
+ if (opts?.explain) {
48
+ envelope.explain = {
49
+ predicateTree: null,
50
+ simulation: {
51
+ permit: {
52
+ tx: 'deny',
53
+ reason: 'No self-verification was performed (mandate path is OZ-only)',
54
+ },
55
+ evaluatedCases: [],
56
+ backend: 'ts-model',
57
+ simulatorVersion: 'not-run',
58
+ },
59
+ };
60
+ }
61
+ return envelope;
37
62
  }
@@ -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;
@@ -199,6 +199,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
199
199
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
200
200
  };
201
201
  const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
202
+ // --explain hook: capture the in-memory predicate tree + the real
203
+ // self-verify verdict so the CLI can render a faithful review card.
204
+ // The verdict below is built from the SAME runHarness + evaluate that
205
+ // already gates the synthesis (it is not a parallel simulation); the
206
+ // intermediate inputs (harnessCases, evalResult) are otherwise discarded
207
+ // after the gate, so the explain hook reuses them - no extra work.
208
+ let explain = null;
209
+ let explainSim = null;
202
210
  // 5. OZ compile (always runs).
203
211
  const ozAdapter = (0, adapter_ts_2.createOzAdapter)(ozConfig);
204
212
  const compileRes = ozAdapter.compile(composed.ir);
@@ -376,6 +384,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
376
384
  },
377
385
  };
378
386
  }
387
+ // --explain capture: the interpreter path already produced the real
388
+ // self-verify verdict (runHarness passed, evalResult.permit is true).
389
+ // Build the SimulationResult from those outputs so the CLI card
390
+ // quotes the SAME verdict that gated the synthesis. We re-evaluate
391
+ // each deny case to surface its concrete reason; the harness only
392
+ // records whether the got-matches-expected boundary held.
393
+ if (opts.explain) {
394
+ explain = finalPredicate;
395
+ const evaluatedCases = [
396
+ {
397
+ dimension: 'permit',
398
+ outcome: evalResult.permit ? 'permit' : 'deny',
399
+ reason: 'matches recorded call',
400
+ },
401
+ ];
402
+ for (const deny of harnessCases.denies) {
403
+ const r = (0, evaluate_ts_1.evaluate)(finalPredicate, deny.ctx);
404
+ evaluatedCases.push({
405
+ dimension: deny.dimension,
406
+ outcome: r.permit ? 'permit' : 'deny',
407
+ reason: r.permit ? 'no matching deny' : r.reason,
408
+ });
409
+ }
410
+ explainSim = {
411
+ permit: { tx: 'permit' },
412
+ evaluatedCases,
413
+ backend: 'ts-model',
414
+ simulatorVersion: 'ts-model-1.0.0',
415
+ };
416
+ }
379
417
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
380
418
  // canonical bytes back onto the PolicyDocument + PolicyRef. The
381
419
  // `encodePredicate` helper throws ToolError-shaped errors on cap
@@ -473,7 +511,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
473
511
  ],
474
512
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
475
513
  };
476
- return { ok: true, data: proposed };
514
+ // --explain success envelope. The interpreter path populated
515
+ // `explain` + `explainSim` from the real self-verify verdict above;
516
+ // the OZ-only path did not (no predicate tree exists). When opts.explain
517
+ // is set and the OZ-only path ran, construct the minimal honest
518
+ // SimulationResult: the verdict is NOT a passing simulation - the
519
+ // interpreter was never engaged, so permit is deny with a truthful
520
+ // reason and evaluatedCases is empty.
521
+ const envelope = { ok: true, data: proposed };
522
+ if (opts.explain) {
523
+ if (explainSim) {
524
+ envelope.explain = { predicateTree: explain, simulation: explainSim };
525
+ }
526
+ else {
527
+ envelope.explain = {
528
+ predicateTree: null,
529
+ simulation: {
530
+ permit: {
531
+ tx: 'deny',
532
+ reason: 'No self-verification was performed (interpreter adapter was not engaged)',
533
+ },
534
+ evaluatedCases: [],
535
+ backend: 'ts-model',
536
+ simulatorVersion: 'not-run',
537
+ },
538
+ };
539
+ }
540
+ }
541
+ return envelope;
477
542
  }
478
543
  /** OZ-side `uncovered` warning patterns that the interpreter adapter
479
544
  * actually lowers when wired in. When the interpreter adapter succeeds, we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -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`
@@ -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
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', [
@@ -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