@crediolabs/policy-synth 0.1.9 → 0.1.11

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 (96) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +61 -5
  18. package/dist/review-card/cross-check.js +50 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/index.d.ts +8 -2
  22. package/dist/run/index.js +2 -1
  23. package/dist/run/schemas.d.ts +16 -4
  24. package/dist/run/schemas.js +14 -0
  25. package/dist/synth/compose-from-recording.d.ts +4 -0
  26. package/dist/synth/compose-from-recording.js +34 -8
  27. package/dist/synth/deny-cases.d.ts +4 -1
  28. package/dist/synth/deny-cases.js +3 -1
  29. package/dist/synth/evaluate.js +130 -3
  30. package/dist/synth/permit-context.d.ts +15 -0
  31. package/dist/synth/permit-context.js +116 -0
  32. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist/synth/synthesize-from-mandate.js +27 -2
  34. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist/synth/synthesize-from-recording.js +80 -3
  36. package/dist/types.d.ts +9 -0
  37. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  38. package/dist-cjs/errors.d.ts +1 -1
  39. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  40. package/dist-cjs/install/build-add-context-rule.js +308 -0
  41. package/dist-cjs/ir/types.d.ts +4 -0
  42. package/dist-cjs/predicate/decode.d.ts +11 -0
  43. package/dist-cjs/predicate/decode.js +239 -0
  44. package/dist-cjs/predicate/encode.js +79 -14
  45. package/dist-cjs/predicate/from-json.d.ts +4 -0
  46. package/dist-cjs/predicate/from-json.js +116 -0
  47. package/dist-cjs/predicate/index.d.ts +2 -0
  48. package/dist-cjs/predicate/index.js +10 -2
  49. package/dist-cjs/registry/identify.js +20 -1
  50. package/dist-cjs/registry/protocols.d.ts +50 -1
  51. package/dist-cjs/registry/protocols.js +89 -1
  52. package/dist-cjs/review-card/builder.d.ts +13 -0
  53. package/dist-cjs/review-card/builder.js +62 -5
  54. package/dist-cjs/review-card/cross-check.js +50 -3
  55. package/dist-cjs/review-card/index.d.ts +1 -1
  56. package/dist-cjs/review-card/index.js +2 -1
  57. package/dist-cjs/run/index.d.ts +8 -2
  58. package/dist-cjs/run/index.js +2 -1
  59. package/dist-cjs/run/schemas.d.ts +16 -4
  60. package/dist-cjs/run/schemas.js +14 -0
  61. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  62. package/dist-cjs/synth/compose-from-recording.js +34 -8
  63. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  64. package/dist-cjs/synth/deny-cases.js +3 -0
  65. package/dist-cjs/synth/evaluate.js +130 -3
  66. package/dist-cjs/synth/permit-context.d.ts +15 -0
  67. package/dist-cjs/synth/permit-context.js +119 -0
  68. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  69. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  70. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  71. package/dist-cjs/synth/synthesize-from-recording.js +80 -3
  72. package/dist-cjs/types.d.ts +9 -0
  73. package/package.json +5 -2
  74. package/src/adapters/interpreter/adapter.ts +72 -11
  75. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  76. package/src/errors.ts +13 -0
  77. package/src/install/build-add-context-rule.ts +429 -0
  78. package/src/ir/types.ts +4 -0
  79. package/src/predicate/decode.ts +242 -0
  80. package/src/predicate/encode.ts +110 -13
  81. package/src/predicate/from-json.ts +124 -0
  82. package/src/predicate/index.ts +5 -1
  83. package/src/registry/identify.ts +22 -2
  84. package/src/registry/protocols.ts +90 -1
  85. package/src/review-card/builder.ts +57 -5
  86. package/src/review-card/cross-check.ts +50 -3
  87. package/src/review-card/index.ts +1 -0
  88. package/src/run/index.ts +16 -2
  89. package/src/run/schemas.ts +14 -0
  90. package/src/synth/compose-from-recording.ts +46 -7
  91. package/src/synth/deny-cases.ts +3 -1
  92. package/src/synth/evaluate.ts +131 -3
  93. package/src/synth/permit-context.ts +137 -0
  94. package/src/synth/synthesize-from-mandate.ts +40 -4
  95. package/src/synth/synthesize-from-recording.ts +116 -6
  96. package/src/types.ts +12 -0
@@ -29,6 +29,11 @@
29
29
  // Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
30
30
  // modelled by throwing (never a boolean-false that `not`/`or` could mask).
31
31
  import { literalNumericBigInt } from "./predicate-literals.js";
32
+ /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
33
+ * in oracle.rs. A threshold on any other basis must say so. */
34
+ const NORMALISED_DECIMALS = 9;
35
+ /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
36
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
32
37
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
33
38
  * and converted to the matching `ORACLE_*` deny reason. NOT a
34
39
  * boolean-false that `not` / `or` could mask. */
@@ -165,6 +170,13 @@ function evalCompare(op, left, right, ctx) {
165
170
  }
166
171
  // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
167
172
  if (left.kind === 'call_arg') {
173
+ // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
174
+ // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
175
+ // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
176
+ // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
177
+ if (right.kind === 'call_arg_scaled') {
178
+ return evalScaledArgCompare(op, left, right, ctx);
179
+ }
168
180
  const actual = ctx.args[left.index];
169
181
  // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
170
182
  // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
@@ -175,6 +187,11 @@ function evalCompare(op, left, right, ctx) {
175
187
  return evalArgOrderedCompare(op, actual, right);
176
188
  return evalArgEq(op, actual, right, ctx);
177
189
  }
190
+ // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
191
+ // Symmetric form so a policy that phrases the floor either way works.
192
+ if (left.kind === 'call_arg_scaled') {
193
+ return evalScaledArgCompare(op, right, left, ctx);
194
+ }
178
195
  // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
179
196
  // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
180
197
  if (left.kind === 'call_arg_len') {
@@ -293,6 +310,102 @@ function evalArgEq(op, actual, right, ctx) {
293
310
  return { permit: false, reason: 'ARG_MISMATCH' };
294
311
  return { permit: false, reason: 'ARG_MISMATCH' };
295
312
  }
313
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
314
+ * path: the scaled leaf is `args[index] * num / den` (truncating toward
315
+ * zero). On `checked_mul` / `checked_div` failure (overflow or
316
+ * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
317
+ * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
318
+ * rather than the generic `ARG_MISMATCH`.
319
+ *
320
+ * `left` is whichever side of the compare is NOT the scaled leaf. A
321
+ * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
322
+ * leaves has no definable semantics). The other operand must be a
323
+ * numeric shape (`call_arg` carrying a number, or a numeric literal);
324
+ * anything else is `ARG_MISMATCH`. */
325
+ function evalScaledArgCompare(op, left, right, ctx) {
326
+ // Identify which side is scaled, and pull the other side as a number.
327
+ let scaled;
328
+ let other;
329
+ let scaledOnRight;
330
+ if (left.kind === 'call_arg_scaled') {
331
+ scaled = left;
332
+ other = right;
333
+ scaledOnRight = false;
334
+ }
335
+ else if (right.kind === 'call_arg_scaled') {
336
+ scaled = right;
337
+ other = left;
338
+ scaledOnRight = true;
339
+ }
340
+ else {
341
+ // Neither side is scaled - this is a programming error in the
342
+ // dispatcher; the contract returns ARG_MISMATCH to fail closed.
343
+ return { permit: false, reason: 'ARG_MISMATCH' };
344
+ }
345
+ // Source `args[index]` -> BigInt. An out-of-bounds index or a
346
+ // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
347
+ // a violated floor is the wrong code when the operand itself could
348
+ // not be read.
349
+ if (scaled.index >= ctx.args.length) {
350
+ return { permit: false, reason: 'ARG_MISMATCH' };
351
+ }
352
+ const input = argNumericBigInt(ctx.args[scaled.index]);
353
+ if (input === null)
354
+ return { permit: false, reason: 'ARG_MISMATCH' };
355
+ let num;
356
+ let den;
357
+ try {
358
+ num = BigInt(scaled.num);
359
+ den = BigInt(scaled.den);
360
+ }
361
+ catch {
362
+ return { permit: false, reason: 'ARG_MISMATCH' };
363
+ }
364
+ // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
365
+ // check is a defensive belt-and-braces - a future validator
366
+ // regression cannot panic the frame on a divide-by-zero.
367
+ if (den === 0n)
368
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
369
+ const product = input * num;
370
+ // BigInt overflow is silent in JS (it does not throw on multiplication).
371
+ // We can detect over-range by dividing and checking the result against
372
+ // i128 bounds, but the more practical check is whether the result
373
+ // divides cleanly. We rely on BigInt's arbitrary precision and then
374
+ // surface ARITHMETIC_OVERFLOW if the result is outside i128.
375
+ let scaledValue;
376
+ try {
377
+ // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
378
+ scaledValue = product / den;
379
+ }
380
+ catch {
381
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
382
+ }
383
+ // i128 range check: if the scaled value is outside i128, the contract
384
+ // would have wrapped on i128 arithmetic; surface the same deny.
385
+ const I128_MAX = (1n << 127n) - 1n;
386
+ const I128_MIN = -(1n << 127n);
387
+ if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
388
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
389
+ }
390
+ // Resolving the operand when the scaled side is on the right: the
391
+ // operand is `left`; the scaled value is the RHS. The AST order is
392
+ // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
393
+ // When the scaled side is on the left, the order is `scaled <op> other`,
394
+ // i.e. `scaled <op> other_val`.
395
+ let otherVal;
396
+ if (other.kind === 'call_arg') {
397
+ otherVal = argNumericBigInt(ctx.args[other.index]);
398
+ }
399
+ else {
400
+ otherVal = literalNumericBigInt(other);
401
+ }
402
+ if (otherVal === null)
403
+ return { permit: false, reason: 'ARG_MISMATCH' };
404
+ const pass = scaledOnRight
405
+ ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
406
+ : bigintCmp(op, scaledValue.toString(), otherVal.toString());
407
+ return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' };
408
+ }
296
409
  /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
297
410
  * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
298
411
  * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
@@ -389,6 +502,7 @@ function resolveLeaf(leaf, ctx) {
389
502
  case 'invocation_count_in_window':
390
503
  case 'now':
391
504
  case 'valid_until':
505
+ case 'call_arg_scaled':
392
506
  return undefined; // selector leaves with no ScVal projection
393
507
  case 'literal_address':
394
508
  return { type: 'address', value: leaf.value };
@@ -447,10 +561,23 @@ function evalOracleCompare(op, asset, right, ctx) {
447
561
  throw new OracleError('ORACLE_STALE');
448
562
  throw new OracleError(mapped);
449
563
  }
450
- const literal = right.kind === 'literal_i128' ? right.value : null;
451
- if (literal === null)
564
+ // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
565
+ // decimal basis: prices are on the normalised 9-dp basis, and assuming a
566
+ // bare literal shares it is what let a raw 14-dp threshold permit
567
+ // everything. A bare literal is refused rather than assumed.
568
+ if (right.kind !== 'oracle_threshold')
452
569
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
453
- return bigintCmp(op, entry.price, literal)
570
+ if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
571
+ throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
572
+ }
573
+ // Scale BOTH sides up to the wider basis rather than dividing the threshold
574
+ // down: dividing truncates, and a truncated bound moves the permit boundary.
575
+ const decimals = BigInt(right.decimals);
576
+ const normalised = BigInt(NORMALISED_DECIMALS);
577
+ const common = decimals > normalised ? decimals : normalised;
578
+ const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
579
+ const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
580
+ return bigintCmp(op, String(priceScaled), String(literalScaled))
454
581
  ? { permit: true }
455
582
  : { permit: false, reason: 'FN_MISMATCH' };
456
583
  }
@@ -0,0 +1,15 @@
1
+ import type { PredicateNode, RecordedTransaction } from '../types.ts';
2
+ import type { EvalContext } from './evaluate.ts';
3
+ export interface PermitContextResponses {
4
+ windowSeconds: number;
5
+ invocationLimit?: number;
6
+ limitAmount?: string;
7
+ validUntilLedger: number;
8
+ oraclePriceBound?: Array<{
9
+ asset: string;
10
+ operator: string;
11
+ value: string;
12
+ decimals: number;
13
+ }>;
14
+ }
15
+ export declare function buildPermitContext(tx: RecordedTransaction, responses: PermitContextResponses, predicate: PredicateNode): EvalContext;
@@ -0,0 +1,116 @@
1
+ // src/synth/permit-context.ts - build the EvalContext that represents the
2
+ // recorded call being replayed.
3
+ //
4
+ // Extracted from the over-permissiveness harness so the same construction
5
+ // feeds both the in-process battery and the on-chain replay
6
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
7
+ // separately would be two chances to disagree about what "the recorded
8
+ // call" means, which is the one thing both must share.
9
+ import { cloneScVal } from "./deny-cases.js";
10
+ export function buildPermitContext(tx, responses, predicate) {
11
+ const amountByToken = {};
12
+ const totals = new Map();
13
+ for (const m of tx.tokenMovements) {
14
+ const current = totals.get(m.token) ?? 0n;
15
+ totals.set(m.token, current + BigInt(m.amount));
16
+ }
17
+ for (const [token, total] of totals) {
18
+ amountByToken[token] = total.toString();
19
+ }
20
+ const topLevel = tx.invocations[0];
21
+ const scopeContract = topLevel?.contract ?? '';
22
+ const oraclePriceByAsset = {};
23
+ visitOracleLeaves(predicate, (asset, op, bound) => {
24
+ let price;
25
+ switch (op) {
26
+ case 'lt':
27
+ price = bound - 1n;
28
+ break;
29
+ case 'gt':
30
+ price = bound + 1n;
31
+ break;
32
+ default:
33
+ price = bound;
34
+ }
35
+ if (price < 0n)
36
+ price = 0n;
37
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
38
+ });
39
+ const ctx = {
40
+ contract: scopeContract,
41
+ fn: topLevel?.fn ?? '',
42
+ args: (topLevel?.args ?? []).map(cloneScVal),
43
+ atLedger: tx.ledgerSequence,
44
+ nowSeconds: tx.fetchedAt,
45
+ amountByToken,
46
+ windowSpentByToken: {},
47
+ invocationCountByWindow: {},
48
+ oraclePriceByAsset,
49
+ };
50
+ if (responses.validUntilLedger !== undefined) {
51
+ ctx.validUntilLedger = responses.validUntilLedger;
52
+ }
53
+ return ctx;
54
+ }
55
+ function visitOracleLeaves(node, visit) {
56
+ switch (node.op) {
57
+ case 'and':
58
+ case 'or':
59
+ for (const child of node.children)
60
+ visitOracleLeaves(child, visit);
61
+ return;
62
+ case 'not':
63
+ visitOracleLeaves(node.child, visit);
64
+ return;
65
+ case 'eq':
66
+ case 'lt':
67
+ case 'lte':
68
+ case 'gt':
69
+ case 'gte': {
70
+ const leftIsOracle = node.left.kind === 'oracle_price';
71
+ const rightIsOracle = node.right.kind === 'oracle_price';
72
+ let asset;
73
+ let literal;
74
+ if (leftIsOracle) {
75
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
76
+ literal = oracleThresholdNormalised(node.right);
77
+ }
78
+ else if (rightIsOracle) {
79
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
80
+ literal = oracleThresholdNormalised(node.left);
81
+ }
82
+ if (asset !== undefined && literal !== undefined) {
83
+ visit(asset, node.op, literal);
84
+ }
85
+ return;
86
+ }
87
+ case 'in':
88
+ return;
89
+ }
90
+ }
91
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
92
+ * oracle.rs. */
93
+ const NORMALISED_DECIMALS = 9n;
94
+ /** A threshold restated on the normalised basis, so a derived permit price is
95
+ * comparable to it. Thresholds carry their own basis, so the conversion has
96
+ * to happen here - reading the digits raw would build a context off by a
97
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
98
+ function oracleThresholdNormalised(leaf) {
99
+ if (leaf.kind !== 'oracle_threshold')
100
+ return undefined;
101
+ let value;
102
+ try {
103
+ value = BigInt(leaf.value);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ const decimals = BigInt(leaf.decimals);
109
+ if (decimals <= NORMALISED_DECIMALS) {
110
+ return value * 10n ** (NORMALISED_DECIMALS - decimals);
111
+ }
112
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
113
+ // caller only needs a price that lands on the right side of the bound, and
114
+ // it offsets by one from here.
115
+ return value / 10n ** (decimals - NORMALISED_DECIMALS);
116
+ }
@@ -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).
@@ -746,13 +811,25 @@ function visitOracleLeaves(node, visit) {
746
811
  return;
747
812
  }
748
813
  }
814
+ /** An oracle threshold restated on the normalised 9-dp basis prices use.
815
+ * Thresholds carry their own basis, so reading the digits raw would build a
816
+ * permit context off by 10^(decimals-9) and the intended call would not
817
+ * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
749
818
  function oracleLiteralFromLeaf(leaf) {
750
- if (leaf.kind !== 'literal_i128')
819
+ if (leaf.kind !== 'oracle_threshold')
751
820
  return undefined;
821
+ let value;
752
822
  try {
753
- return BigInt(leaf.value);
823
+ value = BigInt(leaf.value);
754
824
  }
755
825
  catch {
756
826
  return undefined;
757
827
  }
828
+ const normalised = 9n;
829
+ const decimals = BigInt(leaf.decimals);
830
+ if (decimals <= normalised)
831
+ return value * 10n ** (normalised - decimals);
832
+ // Floor: a finer-grained threshold has no exact 9-dp representation; the
833
+ // caller offsets by one from here to land on the right side of the bound.
834
+ return value / 10n ** (decimals - normalised);
758
835
  }
package/dist/types.d.ts CHANGED
@@ -190,6 +190,11 @@ export type PredicateLeaf = {
190
190
  index: number;
191
191
  element: number;
192
192
  field: string;
193
+ } | {
194
+ kind: 'call_arg_scaled';
195
+ index: number;
196
+ num: string;
197
+ den: string;
193
198
  } | {
194
199
  kind: 'amount';
195
200
  token: string;
@@ -207,6 +212,10 @@ export type PredicateLeaf = {
207
212
  } | {
208
213
  kind: 'oracle_price';
209
214
  asset: string;
215
+ } | {
216
+ kind: 'oracle_threshold';
217
+ value: string;
218
+ decimals: number;
210
219
  } | {
211
220
  kind: 'literal_address';
212
221
  value: string;
@@ -52,7 +52,10 @@ exports.ORACLE_DEFAULTS = {
52
52
  const CAPABILITIES = {
53
53
  supportsSpendWindow: true,
54
54
  supportsThreshold: false, // thresholds are the OZ adapter's job
55
- supportsTimeExpiry: true, // via valid_until ledger expiry
55
+ // False for the predicate-leaf path: the interpreter refuses a `valid_until`
56
+ // leaf at install. Expiry is still available, but as the context rule's
57
+ // validUntilLedger, which the smart account enforces - not as a predicate.
58
+ supportsTimeExpiry: false,
56
59
  supportsOraclePrice: true,
57
60
  supportsInvocationCount: true,
58
61
  supportsGeneralPredicate: true,
@@ -221,26 +224,61 @@ function lowerRule(rule, config) {
221
224
  function unsupportedConstruct(cond) {
222
225
  switch (cond.op) {
223
226
  case 'in':
224
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
225
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
226
- : null;
227
+ // The oracle position rule is a hard error and must be raised before a
228
+ // construct can be written off as uncovered - otherwise a subtree that
229
+ // is BOTH misplaced-oracle and unsourceable would be silently dropped
230
+ // instead of rejected.
231
+ assertNoOracleDescendants(cond);
232
+ return (unsourceableSelector(cond.selector) ??
233
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
234
+ ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
235
+ : null));
227
236
  case 'eq_seq':
228
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
229
- ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
230
- : null;
237
+ return (unsourceableSelector(cond.selector) ??
238
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
239
+ ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
240
+ : null));
231
241
  case 'compare': {
232
242
  const s = cond.compare.selector;
233
243
  if (s.kind === 'calldata')
234
244
  return 'EVM calldata comparison (predicate DSL)';
235
245
  if (s.kind === 'value')
236
246
  return 'tx.value comparison (predicate DSL)';
237
- return null;
247
+ // The on-chain interpreter sees ONE authorized call - there is no
248
+ // `Context.sub_invocations` in v1 - so it cannot observe the
249
+ // transaction's token movements. `amount` has no value to read, and
250
+ // `window_spent` accumulates BY that amount, so its counter would never
251
+ // move. Deriving either from the call payload would quietly swap "value
252
+ // actually moved" for "value the caller declared" - a weaker guarantee
253
+ // than the review card would be claiming.
254
+ //
255
+ // Rolling spend caps belong to the OZ `spending_limit` primitive, which
256
+ // is already audited and which the OZ adapter emits. A per-call cap is
257
+ // expressible here as `arg_field`; bounding it with `invocation_count`
258
+ // gives an enforceable ceiling per window.
259
+ return unsourceableSelector(s);
238
260
  }
261
+ // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
262
+ // pre-scan, which only sees top-level constraints.
239
263
  case 'and':
264
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
240
265
  case 'or':
266
+ assertNoOracleDescendants(cond);
267
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
241
268
  case 'not':
242
- return null;
269
+ assertNoOracleDescendants(cond);
270
+ return unsupportedConstruct(cond.child);
271
+ }
272
+ }
273
+ /** Selectors whose value the on-chain interpreter has no way to obtain. */
274
+ function unsourceableSelector(s) {
275
+ if (s.kind === 'amount') {
276
+ return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`;
277
+ }
278
+ if (s.kind === 'window_spent') {
279
+ return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`;
243
280
  }
281
+ return null;
244
282
  }
245
283
  /** Lower one IR condition to a PredicateNode. Enforces the oracle position
246
284
  * rule (oracle_price leaves must sit directly under the top-level `and`) and
@@ -316,10 +354,13 @@ function lowerSelector(s) {
316
354
  return { kind: 'call_arg_len', index: s.argIndex };
317
355
  case 'arg_field':
318
356
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
357
+ // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
358
+ // before lowering - the interpreter cannot source either on chain. Reaching
359
+ // here means the pre-scan was bypassed, so fail loudly rather than emit a
360
+ // leaf the contract will refuse.
319
361
  case 'amount':
320
- return { kind: 'amount', token: s.token };
321
362
  case 'window_spent':
322
- return { kind: 'window_spent', token: s.token, windowSeconds: s.windowSeconds };
363
+ throw new Error(`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`);
323
364
  case 'invocation_count':
324
365
  return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds };
325
366
  case 'now':
@@ -346,6 +387,16 @@ function lowerSelector(s) {
346
387
  * - invocation_count -> u32 (counts are small non-negative integers)
347
388
  * - now / valid_until -> u64 (unix timestamps in seconds) */
348
389
  function literalFromIRCompare(c) {
390
+ // An oracle comparand is not a bare literal: the interpreter refuses one,
391
+ // because assuming it shares the normalised 9-dp basis is what made a raw
392
+ // 14-dp threshold permit everything it was written to deny.
393
+ if (c.selector.kind === 'oracle_price') {
394
+ if (c.valueDecimals === undefined) {
395
+ throw toolError('ORACLE_THRESHOLD_BASIS_REQUIRED', 'An oracle price bound must declare the decimal basis of its threshold ' +
396
+ '(oracle prices normalise to 9 decimals).');
397
+ }
398
+ return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals };
399
+ }
349
400
  const scalarType = selectorScalarType(c.selector);
350
401
  return literalFromScalar(c.value, scalarType);
351
402
  }