@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
@@ -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,
@@ -279,6 +279,95 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
279
279
  }
280
280
  }
281
281
  }
282
+ // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
283
+ // reserve_token_ids - no map fields to bind). The `requests` vec
284
+ // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
285
+ // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
286
+ // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
287
+ // liquidation/auction fills. Pinning only one element is unsafe: a caller
288
+ // can append a second element with a different action (WithdrawCollateral
289
+ // -> Borrow on a different asset, any amount, then auction fills). Length
290
+ // + per-element pinning is total; a quantifier over elements is not. If we
291
+ // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
292
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
293
+ const requestsArg = topLevel.args[3];
294
+ if (requestsArg && requestsArg.type === 'vec') {
295
+ const elements = requestsArg.value;
296
+ interpreterConstraints.push({
297
+ op: 'compare',
298
+ compare: {
299
+ selector: { kind: 'arg_len', argIndex: 3 },
300
+ operator: 'eq',
301
+ value: String(elements.length),
302
+ },
303
+ });
304
+ for (let i = 0; i < elements.length; i++) {
305
+ const element = elements[i];
306
+ if (!element || element.type !== 'map')
307
+ continue;
308
+ const fields = element.value;
309
+ const fieldValue = (name, scalarType) => {
310
+ const entry = fields.find((e) => e.key === name);
311
+ if (!entry)
312
+ return null;
313
+ if (entry.val.type === 'address' && scalarType === 'address')
314
+ return entry.val.value;
315
+ if (entry.val.type === 'i128' && scalarType === 'i128')
316
+ return entry.val.value;
317
+ if (entry.val.type === 'u32' && scalarType === 'u32')
318
+ return entry.val.value;
319
+ return null;
320
+ };
321
+ const address = fieldValue('address', 'address');
322
+ const amount = fieldValue('amount', 'i128');
323
+ const requestType = fieldValue('request_type', 'u32');
324
+ if (address === null || amount === null || requestType === null)
325
+ continue;
326
+ interpreterConstraints.push({
327
+ op: 'compare',
328
+ compare: {
329
+ selector: {
330
+ kind: 'arg_field',
331
+ argIndex: 3,
332
+ element: i,
333
+ field: 'request_type',
334
+ scalarType: 'u32',
335
+ },
336
+ operator: 'eq',
337
+ value: requestType,
338
+ },
339
+ });
340
+ interpreterConstraints.push({
341
+ op: 'compare',
342
+ compare: {
343
+ selector: {
344
+ kind: 'arg_field',
345
+ argIndex: 3,
346
+ element: i,
347
+ field: 'address',
348
+ scalarType: 'address',
349
+ },
350
+ operator: 'eq',
351
+ value: address,
352
+ },
353
+ });
354
+ interpreterConstraints.push({
355
+ op: 'compare',
356
+ compare: {
357
+ selector: {
358
+ kind: 'arg_field',
359
+ argIndex: 3,
360
+ element: i,
361
+ field: 'amount',
362
+ scalarType: 'i128',
363
+ },
364
+ operator: 'lte',
365
+ value: amount,
366
+ },
367
+ });
368
+ }
369
+ }
370
+ }
282
371
  // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
283
372
  // The interpreter adapter is the ONLY way to express an exact ordered
284
373
  // sequence (OZ built-ins cannot). Element order is preserved verbatim.
@@ -21,6 +21,15 @@ const ADJACENT_ASSETS = [
21
21
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
22
22
  exports.OVERPERMISSIVE_DIMENSIONS = OVERPERMISSIVE_DIMENSIONS;
23
23
  // The 15 dimensions the synth pipeline uses for self-verify and minimise.
24
+ // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
25
+ // alongside the existing dimensions so the per-element binds emitted for
26
+ // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
27
+ // minimise. Without these, no deny case exercises the new leaves and the
28
+ // minimise pass drops them as "redundant" - reopening the element-append
29
+ // hole they were added to close. Both dimensions are no-ops for any fixture
30
+ // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
31
+ // generator short-circuits on leaf-kind), so they cannot make production
32
+ // synthesis refuse for fixtures that do not exercise the new grammar.
24
33
  const ORIGINAL_DIMENSIONS = [
25
34
  'amount',
26
35
  'asset',
@@ -37,6 +46,8 @@ const ORIGINAL_DIMENSIONS = [
37
46
  'oracle_deviation_exceeded',
38
47
  'oracle_paused',
39
48
  'soroswap_allowed_path',
49
+ 'vec_append',
50
+ 'map_field_flip',
40
51
  ];
41
52
  exports.ORIGINAL_DIMENSIONS = ORIGINAL_DIMENSIONS;
42
53
  /** Build deterministic model-evaluated alternatives without mutating the intended call.
@@ -196,7 +207,9 @@ function generateCases(predicate, permitCtx, dimensions) {
196
207
  hasConstrainedArg &&
197
208
  permitCtx.args.length >= 2 &&
198
209
  permitCtx.args[0]?.type === 'address' &&
199
- permitCtx.args[1]?.type === 'address') {
210
+ permitCtx.args[1]?.type === 'address' &&
211
+ permitCtx.args[0].value !==
212
+ permitCtx.args[1].value) {
200
213
  const ctx = cloneContext(permitCtx);
201
214
  // Guard above guarantees args[0] and args[1] exist and are address-typed.
202
215
  const a0 = ctx.args[0];
@@ -205,6 +218,59 @@ function generateCases(predicate, permitCtx, dimensions) {
205
218
  ctx.args[1] = a0;
206
219
  denies.push({ dimension: 'argument_reorder', ctx });
207
220
  }
221
+ // --- map_field_flip: flip a bound map field to a different valid value of
222
+ // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
223
+ // Targets each `call_arg_field` leaf and produces a ctx whose vec element
224
+ // has a different value of the recorded field (different request_type,
225
+ // different amount, different address). The blend-submit case uses this
226
+ // to detect a missing request_type pin. ---
227
+ if (!dimensions || dimensions.includes('map_field_flip')) {
228
+ for (const comparison of facts.comparisons) {
229
+ const sel = comparison.left;
230
+ if (sel.kind !== 'call_arg_field')
231
+ continue;
232
+ const arg = permitCtx.args[sel.index];
233
+ if (!arg || arg.type !== 'vec')
234
+ continue;
235
+ const element = arg.value[sel.element];
236
+ if (!element || element.type !== 'map' || !Array.isArray(element.value))
237
+ continue;
238
+ const entry = element.value.find((e) => e.key === sel.field);
239
+ if (!entry)
240
+ continue;
241
+ const flipped = flipFieldValue(entry.val);
242
+ if (flipped === null)
243
+ continue;
244
+ const ctx = cloneContext(permitCtx);
245
+ const clonedVec = arg.value.map(cloneScVal);
246
+ const clonedElement = clonedVec[sel.element];
247
+ if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value))
248
+ continue;
249
+ clonedElement.value = clonedElement.value.map((e) => e.key === sel.field ? { key: e.key, val: flipped } : e);
250
+ clonedVec[sel.element] = clonedElement;
251
+ ctx.args[sel.index] = { type: 'vec', value: clonedVec };
252
+ denies.push({ dimension: 'map_field_flip', ctx });
253
+ }
254
+ }
255
+ // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
256
+ // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
257
+ // length pin a caller can append an extra element to defeat per-element binds. ---
258
+ if (!dimensions || dimensions.includes('vec_append')) {
259
+ for (const comparison of facts.comparisons) {
260
+ const sel = comparison.left;
261
+ if (sel.kind !== 'call_arg_len')
262
+ continue;
263
+ const arg = permitCtx.args[sel.index];
264
+ if (!arg || arg.type !== 'vec')
265
+ continue;
266
+ const ctx = cloneContext(permitCtx);
267
+ ctx.args[sel.index] = {
268
+ type: 'vec',
269
+ value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
270
+ };
271
+ denies.push({ dimension: 'vec_append', ctx });
272
+ }
273
+ }
208
274
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
209
275
  return { permit: cloneContext(permitCtx), denies };
210
276
  }
@@ -436,3 +502,25 @@ function cloneDepthError(value) {
436
502
  err.depthContext = value.type;
437
503
  throw err;
438
504
  }
505
+ /** Build a value of the SAME ScVal type that differs from the recorded one,
506
+ * used by the `map_field_flip` mutation to defeat a per-element pin without
507
+ * changing the wire type (a type/arity change is rejected by host dispatch
508
+ * before Policy::enforce, so it is not the predicate's job). Returns null
509
+ * when the ScVal type has no obvious different value (e.g. opaque/other). */
510
+ function flipFieldValue(val) {
511
+ switch (val.type) {
512
+ case 'address': {
513
+ const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE';
514
+ const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5';
515
+ return { type: 'address', value: val.value === a ? b : a };
516
+ }
517
+ case 'i128':
518
+ case 'u64':
519
+ case 'u32':
520
+ return { type: val.type, value: String(BigInt(val.value) + 1n) };
521
+ case 'symbol':
522
+ return { type: 'symbol', value: `${val.value}x` };
523
+ default:
524
+ return null;
525
+ }
526
+ }
@@ -178,6 +178,38 @@ function evalCompare(op, left, right, ctx) {
178
178
  return evalArgOrderedCompare(op, actual, right);
179
179
  return evalArgEq(op, actual, right, ctx);
180
180
  }
181
+ // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
182
+ // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
183
+ if (left.kind === 'call_arg_len') {
184
+ const actual = ctx.args[left.index];
185
+ if (!actual || actual.type !== 'vec')
186
+ return { permit: false, reason: 'ARG_MISMATCH' };
187
+ if (right.kind !== 'literal_u32')
188
+ return { permit: false, reason: 'ARG_MISMATCH' };
189
+ return actual.value.length === right.value
190
+ ? { permit: true }
191
+ : { permit: false, reason: 'ARG_MISMATCH' };
192
+ }
193
+ // --- step 4d: call_arg_field: the value of a field in the map at element i
194
+ // of the vec at argument index. Fails closed on a non-vec arg, an
195
+ // out-of-range element, a missing field, a non-map element, or a type
196
+ // mismatch between the field ScVal and the literal leaf.
197
+ if (left.kind === 'call_arg_field') {
198
+ const actual = ctx.args[left.index];
199
+ if (!actual || actual.type !== 'vec')
200
+ return { permit: false, reason: 'ARG_MISMATCH' };
201
+ const element = actual.value[left.element];
202
+ if (!element || element.type !== 'map')
203
+ return { permit: false, reason: 'ARG_MISMATCH' };
204
+ if (!Array.isArray(element.value))
205
+ return { permit: false, reason: 'ARG_MISMATCH' };
206
+ const entry = element.value.find((e) => e.key === left.field);
207
+ if (!entry)
208
+ return { permit: false, reason: 'ARG_MISMATCH' };
209
+ if (op === 'eq')
210
+ return evalArgEq(op, entry.val, right, ctx);
211
+ return evalArgOrderedCompare(op, entry.val, right);
212
+ }
181
213
  // --- step 6: AMOUNT_BOUND ---
182
214
  if (left.kind === 'amount' && op !== 'eq') {
183
215
  return evalAmountCompare(op, left.token, right, ctx);
@@ -337,6 +369,23 @@ function resolveLeaf(leaf, ctx) {
337
369
  return { type: 'symbol', value: ctx.fn };
338
370
  case 'call_arg':
339
371
  return ctx.args[leaf.index];
372
+ case 'call_arg_len':
373
+ // No direct ScVal projection: the length is an integer the comparator
374
+ // resolves against the right-hand literal. Returning undefined keeps
375
+ // the `in` membership path structurally informed (no haystack match).
376
+ return undefined;
377
+ case 'call_arg_field': {
378
+ const actual = ctx.args[leaf.index];
379
+ if (!actual || actual.type !== 'vec')
380
+ return undefined;
381
+ const element = actual.value[leaf.element];
382
+ if (!element || element.type !== 'map')
383
+ return undefined;
384
+ if (!Array.isArray(element.value))
385
+ return undefined;
386
+ const entry = element.value.find((e) => e.key === leaf.field);
387
+ return entry ? entry.val : undefined;
388
+ }
340
389
  case 'amount':
341
390
  case 'window_spent':
342
391
  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
+ };
@@ -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
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.8",
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",
@@ -389,6 +389,10 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
389
389
  switch (s.kind) {
390
390
  case 'arg':
391
391
  return { kind: 'call_arg', index: s.argIndex }
392
+ case 'arg_len':
393
+ return { kind: 'call_arg_len', index: s.argIndex }
394
+ case 'arg_field':
395
+ return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
392
396
  case 'amount':
393
397
  return { kind: 'amount', token: s.token }
394
398
  case 'window_spent':
@@ -415,14 +419,15 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
415
419
  * selector kind to the matching `literal_*` kind. The IR compare value is a
416
420
  * raw string (i128-safe); the selector kind fixes the canonical wire type:
417
421
  * - arg -> IR scalarType (set by the recorder/parser)
422
+ * - arg_len -> u32 (vec length is a small non-negative integer)
423
+ * - arg_field -> IR scalarType (the field's recorded type)
418
424
  * - amount -> i128 (canonical Stellar token amount encoding)
419
425
  * - window_spent -> i128 (canonical amount encoding)
420
426
  * - oracle_price -> i128 (canonical price encoding)
421
427
  * - invocation_count -> u32 (counts are small non-negative integers)
422
428
  * - now / valid_until -> u64 (unix timestamps in seconds) */
423
429
  function literalFromIRCompare(c: IRCompare): PredicateLeaf {
424
- const scalarType: IRScalarType =
425
- c.selector.kind === 'arg' ? c.selector.scalarType : literalScalarForSelector(c.selector.kind)
430
+ const scalarType: IRScalarType = selectorScalarType(c.selector)
426
431
  return literalFromScalar(c.value, scalarType)
427
432
  }
428
433
 
@@ -433,14 +438,16 @@ function literalScalarForSelector(kind: IRSelector['kind']): IRScalarType {
433
438
  case 'oracle_price':
434
439
  return 'i128'
435
440
  case 'invocation_count':
441
+ case 'arg_len':
436
442
  return 'u32'
437
443
  case 'now':
438
444
  case 'valid_until':
439
445
  return 'u64'
440
446
  case 'arg':
447
+ case 'arg_field':
441
448
  case 'calldata':
442
449
  case 'value':
443
- // arg -> caller handles scalarType; calldata/value -> unreachable (Path-B).
450
+ // arg / arg_field -> caller handles scalarType; calldata/value -> unreachable (Path-B).
444
451
  return 'i128'
445
452
  }
446
453
  }
@@ -473,9 +480,10 @@ function literalFromScalar(value: string, scalarType: IRScalarType): PredicateLe
473
480
  /** Scalar type of an IRSelector for the purpose of building literal leaves
474
481
  * (the right-hand side of `eq` / elements of an `in` haystack / elements of
475
482
  * a `literal_vec`). Mirrors `literalScalarForSelector` for OZ extensions and
476
- * uses the selector's own `scalarType` for `arg` selectors. */
483
+ * uses the selector's own `scalarType` for `arg` and `arg_field` selectors. */
477
484
  function selectorScalarType(selector: IRSelector): IRScalarType {
478
485
  if (selector.kind === 'arg') return selector.scalarType
486
+ if (selector.kind === 'arg_field') return selector.scalarType
479
487
  return literalScalarForSelector(selector.kind)
480
488
  }
481
489
 
@@ -569,6 +577,10 @@ function describeCondition(cond: IRCondition): string {
569
577
  return `per-call amount comparison on ${s.token}`
570
578
  case 'arg':
571
579
  return `argument comparison on arg ${s.argIndex}`
580
+ case 'arg_len':
581
+ return `vec length comparison on arg ${s.argIndex}`
582
+ case 'arg_field':
583
+ return `map field comparison on arg ${s.argIndex}.${s.field}`
572
584
  case 'calldata':
573
585
  return 'EVM calldata comparison'
574
586
  case 'value':
@@ -585,6 +597,10 @@ function describeSelector(s: IRSelector): string {
585
597
  switch (s.kind) {
586
598
  case 'arg':
587
599
  return `arg ${s.argIndex}`
600
+ case 'arg_len':
601
+ return `arg_len(${s.argIndex})`
602
+ case 'arg_field':
603
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`
588
604
  case 'amount':
589
605
  return `amount(${s.token})`
590
606
  case 'window_spent':
@@ -307,6 +307,10 @@ function describeCondition(cond: IRCondition): string {
307
307
  return `per-call amount comparison on ${s.token} (predicate DSL)`
308
308
  case 'arg':
309
309
  return `argument comparison on arg ${s.argIndex} (predicate DSL)`
310
+ case 'arg_len':
311
+ return `vec length comparison on arg ${s.argIndex} (predicate DSL)`
312
+ case 'arg_field':
313
+ return `map field comparison on arg ${s.argIndex}.${s.field} (predicate DSL)`
310
314
  case 'calldata':
311
315
  return 'EVM calldata comparison (predicate DSL)'
312
316
  case 'value':
@@ -323,6 +327,10 @@ function describeSelector(s: IRSelector): string {
323
327
  switch (s.kind) {
324
328
  case 'arg':
325
329
  return `arg ${s.argIndex}`
330
+ case 'arg_len':
331
+ return `arg_len(${s.argIndex})`
332
+ case 'arg_field':
333
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`
326
334
  case 'amount':
327
335
  return `amount(${s.token})`
328
336
  case 'window_spent':
package/src/ir/types.ts CHANGED
@@ -41,6 +41,14 @@ export type IRSelector =
41
41
  vecMode?: IRVecMode
42
42
  scalarType: IRScalarType
43
43
  } // Stellar StellarComp
44
+ | { kind: 'arg_len'; argIndex: number } // length of a vec-typed argument as u32
45
+ | {
46
+ kind: 'arg_field'
47
+ argIndex: number
48
+ element: number
49
+ field: string
50
+ scalarType: IRScalarType
51
+ } // field of a map element within a vec-typed argument
44
52
  | { kind: 'calldata'; offset: number; length: number } // EVM ByteComp
45
53
  | { kind: 'value' } // EVM tx.value
46
54
  // --- OZ extensions (not in NEAR-V2; lowered only by adapters that support them) ---
@@ -207,6 +207,15 @@ function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
207
207
  return xdr.ScVal.scvVec([symbol('call_fn')])
208
208
  case 'call_arg':
209
209
  return xdr.ScVal.scvVec([symbol('call_arg'), xdr.ScVal.scvU32(leaf.index)])
210
+ case 'call_arg_len':
211
+ return xdr.ScVal.scvVec([symbol('call_arg_len'), xdr.ScVal.scvU32(leaf.index)])
212
+ case 'call_arg_field':
213
+ return xdr.ScVal.scvVec([
214
+ symbol('call_arg_field'),
215
+ xdr.ScVal.scvU32(leaf.index),
216
+ xdr.ScVal.scvU32(leaf.element),
217
+ xdr.ScVal.scvSymbol(leaf.field),
218
+ ])
210
219
  case 'amount':
211
220
  return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)])
212
221
  case 'window_spent':