@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
@@ -307,6 +307,10 @@ function lowerSelector(s) {
307
307
  switch (s.kind) {
308
308
  case 'arg':
309
309
  return { kind: 'call_arg', index: s.argIndex };
310
+ case 'arg_len':
311
+ return { kind: 'call_arg_len', index: s.argIndex };
312
+ case 'arg_field':
313
+ return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
310
314
  case 'amount':
311
315
  return { kind: 'amount', token: s.token };
312
316
  case 'window_spent':
@@ -329,13 +333,15 @@ function lowerSelector(s) {
329
333
  * selector kind to the matching `literal_*` kind. The IR compare value is a
330
334
  * raw string (i128-safe); the selector kind fixes the canonical wire type:
331
335
  * - arg -> IR scalarType (set by the recorder/parser)
336
+ * - arg_len -> u32 (vec length is a small non-negative integer)
337
+ * - arg_field -> IR scalarType (the field's recorded type)
332
338
  * - amount -> i128 (canonical Stellar token amount encoding)
333
339
  * - window_spent -> i128 (canonical amount encoding)
334
340
  * - oracle_price -> i128 (canonical price encoding)
335
341
  * - invocation_count -> u32 (counts are small non-negative integers)
336
342
  * - now / valid_until -> u64 (unix timestamps in seconds) */
337
343
  function literalFromIRCompare(c) {
338
- const scalarType = c.selector.kind === 'arg' ? c.selector.scalarType : literalScalarForSelector(c.selector.kind);
344
+ const scalarType = selectorScalarType(c.selector);
339
345
  return literalFromScalar(c.value, scalarType);
340
346
  }
341
347
  function literalScalarForSelector(kind) {
@@ -345,14 +351,16 @@ function literalScalarForSelector(kind) {
345
351
  case 'oracle_price':
346
352
  return 'i128';
347
353
  case 'invocation_count':
354
+ case 'arg_len':
348
355
  return 'u32';
349
356
  case 'now':
350
357
  case 'valid_until':
351
358
  return 'u64';
352
359
  case 'arg':
360
+ case 'arg_field':
353
361
  case 'calldata':
354
362
  case 'value':
355
- // arg -> caller handles scalarType; calldata/value -> unreachable (Path-B).
363
+ // arg / arg_field -> caller handles scalarType; calldata/value -> unreachable (Path-B).
356
364
  return 'i128';
357
365
  }
358
366
  }
@@ -380,10 +388,12 @@ function literalFromScalar(value, scalarType) {
380
388
  /** Scalar type of an IRSelector for the purpose of building literal leaves
381
389
  * (the right-hand side of `eq` / elements of an `in` haystack / elements of
382
390
  * a `literal_vec`). Mirrors `literalScalarForSelector` for OZ extensions and
383
- * uses the selector's own `scalarType` for `arg` selectors. */
391
+ * uses the selector's own `scalarType` for `arg` and `arg_field` selectors. */
384
392
  function selectorScalarType(selector) {
385
393
  if (selector.kind === 'arg')
386
394
  return selector.scalarType;
395
+ if (selector.kind === 'arg_field')
396
+ return selector.scalarType;
387
397
  return literalScalarForSelector(selector.kind);
388
398
  }
389
399
  /** Walk a condition tree and throw if any oracle_price leaf is found anywhere
@@ -457,6 +467,10 @@ function describeCondition(cond) {
457
467
  return `per-call amount comparison on ${s.token}`;
458
468
  case 'arg':
459
469
  return `argument comparison on arg ${s.argIndex}`;
470
+ case 'arg_len':
471
+ return `vec length comparison on arg ${s.argIndex}`;
472
+ case 'arg_field':
473
+ return `map field comparison on arg ${s.argIndex}.${s.field}`;
460
474
  case 'calldata':
461
475
  return 'EVM calldata comparison';
462
476
  case 'value':
@@ -472,6 +486,10 @@ function describeSelector(s) {
472
486
  switch (s.kind) {
473
487
  case 'arg':
474
488
  return `arg ${s.argIndex}`;
489
+ case 'arg_len':
490
+ return `arg_len(${s.argIndex})`;
491
+ case 'arg_field':
492
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`;
475
493
  case 'amount':
476
494
  return `amount(${s.token})`;
477
495
  case 'window_spent':
@@ -229,6 +229,10 @@ function describeCondition(cond) {
229
229
  return `per-call amount comparison on ${s.token} (predicate DSL)`;
230
230
  case 'arg':
231
231
  return `argument comparison on arg ${s.argIndex} (predicate DSL)`;
232
+ case 'arg_len':
233
+ return `vec length comparison on arg ${s.argIndex} (predicate DSL)`;
234
+ case 'arg_field':
235
+ return `map field comparison on arg ${s.argIndex}.${s.field} (predicate DSL)`;
232
236
  case 'calldata':
233
237
  return 'EVM calldata comparison (predicate DSL)';
234
238
  case 'value':
@@ -244,6 +248,10 @@ function describeSelector(s) {
244
248
  switch (s.kind) {
245
249
  case 'arg':
246
250
  return `arg ${s.argIndex}`;
251
+ case 'arg_len':
252
+ return `arg_len(${s.argIndex})`;
253
+ case 'arg_field':
254
+ return `arg_field(${s.argIndex}, ${s.element}, ${s.field})`;
247
255
  case 'amount':
248
256
  return `amount(${s.token})`;
249
257
  case 'window_spent':
@@ -14,6 +14,15 @@ export type IRSelector = {
14
14
  fieldIndex?: number;
15
15
  vecMode?: IRVecMode;
16
16
  scalarType: IRScalarType;
17
+ } | {
18
+ kind: 'arg_len';
19
+ argIndex: number;
20
+ } | {
21
+ kind: 'arg_field';
22
+ argIndex: number;
23
+ element: number;
24
+ field: string;
25
+ scalarType: IRScalarType;
17
26
  } | {
18
27
  kind: 'calldata';
19
28
  offset: number;
@@ -167,6 +167,15 @@ function encodeLeaf(leaf) {
167
167
  return xdr.ScVal.scvVec([symbol('call_fn')]);
168
168
  case 'call_arg':
169
169
  return xdr.ScVal.scvVec([symbol('call_arg'), xdr.ScVal.scvU32(leaf.index)]);
170
+ case 'call_arg_len':
171
+ return xdr.ScVal.scvVec([symbol('call_arg_len'), xdr.ScVal.scvU32(leaf.index)]);
172
+ case 'call_arg_field':
173
+ return xdr.ScVal.scvVec([
174
+ symbol('call_arg_field'),
175
+ xdr.ScVal.scvU32(leaf.index),
176
+ xdr.ScVal.scvU32(leaf.element),
177
+ xdr.ScVal.scvSymbol(leaf.field),
178
+ ]);
170
179
  case 'amount':
171
180
  return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)]);
172
181
  case 'window_spent':
@@ -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`;
@@ -184,6 +214,8 @@ function renderVecElement(leaf) {
184
214
  case 'call_contract':
185
215
  case 'call_fn':
186
216
  case 'call_arg':
217
+ case 'call_arg_len':
218
+ case 'call_arg_field':
187
219
  case 'amount':
188
220
  case 'window_spent':
189
221
  case 'now':
@@ -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;
@@ -105,6 +143,8 @@ function renderVecElement(leaf) {
105
143
  case 'call_contract':
106
144
  case 'call_fn':
107
145
  case 'call_arg':
146
+ case 'call_arg_len':
147
+ case 'call_arg_field':
108
148
  case 'amount':
109
149
  case 'window_spent':
110
150
  case 'now':
@@ -1,4 +1,5 @@
1
- import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
1
+ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
+ import type { SimulationResult } from '../verify/envelope.ts';
2
3
  import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
4
  export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
5
  export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
28
29
  * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
29
30
  * XDR decode error in the adapter) would otherwise surface as
30
31
  * "[object Object]" in the MCP transport. */
31
- export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
32
+ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
33
+ explain?: {
34
+ predicateTree: PredicateNode | null;
35
+ simulation: SimulationResult;
36
+ };
37
+ }>;
32
38
  /** Build a canonical ToolError for a thrown exception caught by the tool
33
39
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
34
40
  * default, so we extract a string-friendly message and tag the original
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,
@@ -276,6 +276,95 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
276
276
  }
277
277
  }
278
278
  }
279
+ // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
280
+ // reserve_token_ids - no map fields to bind). The `requests` vec
281
+ // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
282
+ // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
283
+ // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
284
+ // liquidation/auction fills. Pinning only one element is unsafe: a caller
285
+ // can append a second element with a different action (WithdrawCollateral
286
+ // -> Borrow on a different asset, any amount, then auction fills). Length
287
+ // + per-element pinning is total; a quantifier over elements is not. If we
288
+ // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
289
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
290
+ const requestsArg = topLevel.args[3];
291
+ if (requestsArg && requestsArg.type === 'vec') {
292
+ const elements = requestsArg.value;
293
+ interpreterConstraints.push({
294
+ op: 'compare',
295
+ compare: {
296
+ selector: { kind: 'arg_len', argIndex: 3 },
297
+ operator: 'eq',
298
+ value: String(elements.length),
299
+ },
300
+ });
301
+ for (let i = 0; i < elements.length; i++) {
302
+ const element = elements[i];
303
+ if (!element || element.type !== 'map')
304
+ continue;
305
+ const fields = element.value;
306
+ const fieldValue = (name, scalarType) => {
307
+ const entry = fields.find((e) => e.key === name);
308
+ if (!entry)
309
+ return null;
310
+ if (entry.val.type === 'address' && scalarType === 'address')
311
+ return entry.val.value;
312
+ if (entry.val.type === 'i128' && scalarType === 'i128')
313
+ return entry.val.value;
314
+ if (entry.val.type === 'u32' && scalarType === 'u32')
315
+ return entry.val.value;
316
+ return null;
317
+ };
318
+ const address = fieldValue('address', 'address');
319
+ const amount = fieldValue('amount', 'i128');
320
+ const requestType = fieldValue('request_type', 'u32');
321
+ if (address === null || amount === null || requestType === null)
322
+ continue;
323
+ interpreterConstraints.push({
324
+ op: 'compare',
325
+ compare: {
326
+ selector: {
327
+ kind: 'arg_field',
328
+ argIndex: 3,
329
+ element: i,
330
+ field: 'request_type',
331
+ scalarType: 'u32',
332
+ },
333
+ operator: 'eq',
334
+ value: requestType,
335
+ },
336
+ });
337
+ interpreterConstraints.push({
338
+ op: 'compare',
339
+ compare: {
340
+ selector: {
341
+ kind: 'arg_field',
342
+ argIndex: 3,
343
+ element: i,
344
+ field: 'address',
345
+ scalarType: 'address',
346
+ },
347
+ operator: 'eq',
348
+ value: address,
349
+ },
350
+ });
351
+ interpreterConstraints.push({
352
+ op: 'compare',
353
+ compare: {
354
+ selector: {
355
+ kind: 'arg_field',
356
+ argIndex: 3,
357
+ element: i,
358
+ field: 'amount',
359
+ scalarType: 'i128',
360
+ },
361
+ operator: 'lte',
362
+ value: amount,
363
+ },
364
+ });
365
+ }
366
+ }
367
+ }
279
368
  // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
280
369
  // The interpreter adapter is the ONLY way to express an exact ordered
281
370
  // sequence (OZ built-ins cannot). Element order is preserved verbatim.
@@ -16,6 +16,15 @@ const ADJACENT_ASSETS = [
16
16
  // them as FINDINGS against the already-emitted policy.
17
17
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
18
18
  // The 15 dimensions the synth pipeline uses for self-verify and minimise.
19
+ // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
20
+ // alongside the existing dimensions so the per-element binds emitted for
21
+ // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
22
+ // minimise. Without these, no deny case exercises the new leaves and the
23
+ // minimise pass drops them as "redundant" - reopening the element-append
24
+ // hole they were added to close. Both dimensions are no-ops for any fixture
25
+ // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
26
+ // generator short-circuits on leaf-kind), so they cannot make production
27
+ // synthesis refuse for fixtures that do not exercise the new grammar.
19
28
  const ORIGINAL_DIMENSIONS = [
20
29
  'amount',
21
30
  'asset',
@@ -32,6 +41,8 @@ const ORIGINAL_DIMENSIONS = [
32
41
  'oracle_deviation_exceeded',
33
42
  'oracle_paused',
34
43
  'soroswap_allowed_path',
44
+ 'vec_append',
45
+ 'map_field_flip',
35
46
  ];
36
47
  export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
37
48
  /** Build deterministic model-evaluated alternatives without mutating the intended call.
@@ -191,7 +202,9 @@ export function generateCases(predicate, permitCtx, dimensions) {
191
202
  hasConstrainedArg &&
192
203
  permitCtx.args.length >= 2 &&
193
204
  permitCtx.args[0]?.type === 'address' &&
194
- permitCtx.args[1]?.type === 'address') {
205
+ permitCtx.args[1]?.type === 'address' &&
206
+ permitCtx.args[0].value !==
207
+ permitCtx.args[1].value) {
195
208
  const ctx = cloneContext(permitCtx);
196
209
  // Guard above guarantees args[0] and args[1] exist and are address-typed.
197
210
  const a0 = ctx.args[0];
@@ -200,6 +213,59 @@ export function generateCases(predicate, permitCtx, dimensions) {
200
213
  ctx.args[1] = a0;
201
214
  denies.push({ dimension: 'argument_reorder', ctx });
202
215
  }
216
+ // --- map_field_flip: flip a bound map field to a different valid value of
217
+ // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
218
+ // Targets each `call_arg_field` leaf and produces a ctx whose vec element
219
+ // has a different value of the recorded field (different request_type,
220
+ // different amount, different address). The blend-submit case uses this
221
+ // to detect a missing request_type pin. ---
222
+ if (!dimensions || dimensions.includes('map_field_flip')) {
223
+ for (const comparison of facts.comparisons) {
224
+ const sel = comparison.left;
225
+ if (sel.kind !== 'call_arg_field')
226
+ continue;
227
+ const arg = permitCtx.args[sel.index];
228
+ if (!arg || arg.type !== 'vec')
229
+ continue;
230
+ const element = arg.value[sel.element];
231
+ if (!element || element.type !== 'map' || !Array.isArray(element.value))
232
+ continue;
233
+ const entry = element.value.find((e) => e.key === sel.field);
234
+ if (!entry)
235
+ continue;
236
+ const flipped = flipFieldValue(entry.val);
237
+ if (flipped === null)
238
+ continue;
239
+ const ctx = cloneContext(permitCtx);
240
+ const clonedVec = arg.value.map(cloneScVal);
241
+ const clonedElement = clonedVec[sel.element];
242
+ if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value))
243
+ continue;
244
+ clonedElement.value = clonedElement.value.map((e) => e.key === sel.field ? { key: e.key, val: flipped } : e);
245
+ clonedVec[sel.element] = clonedElement;
246
+ ctx.args[sel.index] = { type: 'vec', value: clonedVec };
247
+ denies.push({ dimension: 'map_field_flip', ctx });
248
+ }
249
+ }
250
+ // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
251
+ // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
252
+ // length pin a caller can append an extra element to defeat per-element binds. ---
253
+ if (!dimensions || dimensions.includes('vec_append')) {
254
+ for (const comparison of facts.comparisons) {
255
+ const sel = comparison.left;
256
+ if (sel.kind !== 'call_arg_len')
257
+ continue;
258
+ const arg = permitCtx.args[sel.index];
259
+ if (!arg || arg.type !== 'vec')
260
+ continue;
261
+ const ctx = cloneContext(permitCtx);
262
+ ctx.args[sel.index] = {
263
+ type: 'vec',
264
+ value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
265
+ };
266
+ denies.push({ dimension: 'vec_append', ctx });
267
+ }
268
+ }
203
269
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
204
270
  return { permit: cloneContext(permitCtx), denies };
205
271
  }
@@ -431,3 +497,25 @@ function cloneDepthError(value) {
431
497
  err.depthContext = value.type;
432
498
  throw err;
433
499
  }
500
+ /** Build a value of the SAME ScVal type that differs from the recorded one,
501
+ * used by the `map_field_flip` mutation to defeat a per-element pin without
502
+ * changing the wire type (a type/arity change is rejected by host dispatch
503
+ * before Policy::enforce, so it is not the predicate's job). Returns null
504
+ * when the ScVal type has no obvious different value (e.g. opaque/other). */
505
+ function flipFieldValue(val) {
506
+ switch (val.type) {
507
+ case 'address': {
508
+ const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE';
509
+ const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5';
510
+ return { type: 'address', value: val.value === a ? b : a };
511
+ }
512
+ case 'i128':
513
+ case 'u64':
514
+ case 'u32':
515
+ return { type: val.type, value: String(BigInt(val.value) + 1n) };
516
+ case 'symbol':
517
+ return { type: 'symbol', value: `${val.value}x` };
518
+ default:
519
+ return null;
520
+ }
521
+ }