@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
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ // src/synth/permit-context.ts - build the EvalContext that represents the
3
+ // recorded call being replayed.
4
+ //
5
+ // Extracted from the over-permissiveness harness so the same construction
6
+ // feeds both the in-process battery and the on-chain replay
7
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
8
+ // separately would be two chances to disagree about what "the recorded
9
+ // call" means, which is the one thing both must share.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.buildPermitContext = buildPermitContext;
12
+ const deny_cases_ts_1 = require("./deny-cases.js");
13
+ function buildPermitContext(tx, responses, predicate) {
14
+ const amountByToken = {};
15
+ const totals = new Map();
16
+ for (const m of tx.tokenMovements) {
17
+ const current = totals.get(m.token) ?? 0n;
18
+ totals.set(m.token, current + BigInt(m.amount));
19
+ }
20
+ for (const [token, total] of totals) {
21
+ amountByToken[token] = total.toString();
22
+ }
23
+ const topLevel = tx.invocations[0];
24
+ const scopeContract = topLevel?.contract ?? '';
25
+ const oraclePriceByAsset = {};
26
+ visitOracleLeaves(predicate, (asset, op, bound) => {
27
+ let price;
28
+ switch (op) {
29
+ case 'lt':
30
+ price = bound - 1n;
31
+ break;
32
+ case 'gt':
33
+ price = bound + 1n;
34
+ break;
35
+ default:
36
+ price = bound;
37
+ }
38
+ if (price < 0n)
39
+ price = 0n;
40
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
41
+ });
42
+ const ctx = {
43
+ contract: scopeContract,
44
+ fn: topLevel?.fn ?? '',
45
+ args: (topLevel?.args ?? []).map(deny_cases_ts_1.cloneScVal),
46
+ atLedger: tx.ledgerSequence,
47
+ nowSeconds: tx.fetchedAt,
48
+ amountByToken,
49
+ windowSpentByToken: {},
50
+ invocationCountByWindow: {},
51
+ oraclePriceByAsset,
52
+ };
53
+ if (responses.validUntilLedger !== undefined) {
54
+ ctx.validUntilLedger = responses.validUntilLedger;
55
+ }
56
+ return ctx;
57
+ }
58
+ function visitOracleLeaves(node, visit) {
59
+ switch (node.op) {
60
+ case 'and':
61
+ case 'or':
62
+ for (const child of node.children)
63
+ visitOracleLeaves(child, visit);
64
+ return;
65
+ case 'not':
66
+ visitOracleLeaves(node.child, visit);
67
+ return;
68
+ case 'eq':
69
+ case 'lt':
70
+ case 'lte':
71
+ case 'gt':
72
+ case 'gte': {
73
+ const leftIsOracle = node.left.kind === 'oracle_price';
74
+ const rightIsOracle = node.right.kind === 'oracle_price';
75
+ let asset;
76
+ let literal;
77
+ if (leftIsOracle) {
78
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
79
+ literal = oracleThresholdNormalised(node.right);
80
+ }
81
+ else if (rightIsOracle) {
82
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
83
+ literal = oracleThresholdNormalised(node.left);
84
+ }
85
+ if (asset !== undefined && literal !== undefined) {
86
+ visit(asset, node.op, literal);
87
+ }
88
+ return;
89
+ }
90
+ case 'in':
91
+ return;
92
+ }
93
+ }
94
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
95
+ * oracle.rs. */
96
+ const NORMALISED_DECIMALS = 9n;
97
+ /** A threshold restated on the normalised basis, so a derived permit price is
98
+ * comparable to it. Thresholds carry their own basis, so the conversion has
99
+ * to happen here - reading the digits raw would build a context off by a
100
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
101
+ function oracleThresholdNormalised(leaf) {
102
+ if (leaf.kind !== 'oracle_threshold')
103
+ return undefined;
104
+ let value;
105
+ try {
106
+ value = BigInt(leaf.value);
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ const decimals = BigInt(leaf.decimals);
112
+ if (decimals <= NORMALISED_DECIMALS) {
113
+ return value * 10n ** (NORMALISED_DECIMALS - decimals);
114
+ }
115
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
116
+ // caller only needs a price that lands on the right side of the bound, and
117
+ // it offsets by one from here.
118
+ return value / 10n ** (decimals - NORMALISED_DECIMALS);
119
+ }
@@ -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
@@ -747,13 +812,25 @@ function visitOracleLeaves(node, visit) {
747
812
  return;
748
813
  }
749
814
  }
815
+ /** An oracle threshold restated on the normalised 9-dp basis prices use.
816
+ * Thresholds carry their own basis, so reading the digits raw would build a
817
+ * permit context off by 10^(decimals-9) and the intended call would not
818
+ * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
750
819
  function oracleLiteralFromLeaf(leaf) {
751
- if (leaf.kind !== 'literal_i128')
820
+ if (leaf.kind !== 'oracle_threshold')
752
821
  return undefined;
822
+ let value;
753
823
  try {
754
- return BigInt(leaf.value);
824
+ value = BigInt(leaf.value);
755
825
  }
756
826
  catch {
757
827
  return undefined;
758
828
  }
829
+ const normalised = 9n;
830
+ const decimals = BigInt(leaf.decimals);
831
+ if (decimals <= normalised)
832
+ return value * 10n ** (normalised - decimals);
833
+ // Floor: a finer-grained threshold has no exact 9-dp representation; the
834
+ // caller offsets by one from here to land on the right side of the bound.
835
+ return value / 10n ** (decimals - normalised);
759
836
  }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
@@ -67,11 +67,14 @@
67
67
  "prepack": "bun run build"
68
68
  },
69
69
  "dependencies": {
70
- "@stellar/stellar-sdk": "14.4.0",
71
70
  "zod": "3.25.76"
72
71
  },
72
+ "peerDependencies": {
73
+ "@stellar/stellar-sdk": "^14.4.0"
74
+ },
73
75
  "devDependencies": {
74
76
  "@biomejs/biome": "2.5.5",
77
+ "@stellar/stellar-sdk": "14.4.0",
75
78
  "@types/node": "^26.1.1",
76
79
  "typescript": "5.9.3"
77
80
  }
@@ -90,7 +90,10 @@ export interface InterpreterAdapterConfig {
90
90
  const CAPABILITIES: CustodyCapabilities = {
91
91
  supportsSpendWindow: true,
92
92
  supportsThreshold: false, // thresholds are the OZ adapter's job
93
- supportsTimeExpiry: true, // via valid_until ledger expiry
93
+ // False for the predicate-leaf path: the interpreter refuses a `valid_until`
94
+ // leaf at install. Expiry is still available, but as the context rule's
95
+ // validUntilLedger, which the smart account enforces - not as a predicate.
96
+ supportsTimeExpiry: false,
94
97
  supportsOraclePrice: true,
95
98
  supportsInvocationCount: true,
96
99
  supportsGeneralPredicate: true,
@@ -296,24 +299,64 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
296
299
  function unsupportedConstruct(cond: IRCondition): string | null {
297
300
  switch (cond.op) {
298
301
  case 'in':
299
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
300
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
301
- : null
302
+ // The oracle position rule is a hard error and must be raised before a
303
+ // construct can be written off as uncovered - otherwise a subtree that
304
+ // is BOTH misplaced-oracle and unsourceable would be silently dropped
305
+ // instead of rejected.
306
+ assertNoOracleDescendants(cond)
307
+ return (
308
+ unsourceableSelector(cond.selector) ??
309
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
310
+ ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
311
+ : null)
312
+ )
302
313
  case 'eq_seq':
303
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
304
- ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
305
- : null
314
+ return (
315
+ unsourceableSelector(cond.selector) ??
316
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
317
+ ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
318
+ : null)
319
+ )
306
320
  case 'compare': {
307
321
  const s = cond.compare.selector
308
322
  if (s.kind === 'calldata') return 'EVM calldata comparison (predicate DSL)'
309
323
  if (s.kind === 'value') return 'tx.value comparison (predicate DSL)'
310
- return null
324
+ // The on-chain interpreter sees ONE authorized call - there is no
325
+ // `Context.sub_invocations` in v1 - so it cannot observe the
326
+ // transaction's token movements. `amount` has no value to read, and
327
+ // `window_spent` accumulates BY that amount, so its counter would never
328
+ // move. Deriving either from the call payload would quietly swap "value
329
+ // actually moved" for "value the caller declared" - a weaker guarantee
330
+ // than the review card would be claiming.
331
+ //
332
+ // Rolling spend caps belong to the OZ `spending_limit` primitive, which
333
+ // is already audited and which the OZ adapter emits. A per-call cap is
334
+ // expressible here as `arg_field`; bounding it with `invocation_count`
335
+ // gives an enforceable ceiling per window.
336
+ return unsourceableSelector(s)
311
337
  }
338
+ // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
339
+ // pre-scan, which only sees top-level constraints.
312
340
  case 'and':
341
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
313
342
  case 'or':
343
+ assertNoOracleDescendants(cond)
344
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
314
345
  case 'not':
315
- return null
346
+ assertNoOracleDescendants(cond)
347
+ return unsupportedConstruct(cond.child)
348
+ }
349
+ }
350
+
351
+ /** Selectors whose value the on-chain interpreter has no way to obtain. */
352
+ function unsourceableSelector(s: IRSelector): string | null {
353
+ if (s.kind === 'amount') {
354
+ 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`
355
+ }
356
+ if (s.kind === 'window_spent') {
357
+ 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`
316
358
  }
359
+ return null
317
360
  }
318
361
 
319
362
  /** Lower one IR condition to a PredicateNode. Enforces the oracle position
@@ -393,10 +436,15 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
393
436
  return { kind: 'call_arg_len', index: s.argIndex }
394
437
  case 'arg_field':
395
438
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
439
+ // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
440
+ // before lowering - the interpreter cannot source either on chain. Reaching
441
+ // here means the pre-scan was bypassed, so fail loudly rather than emit a
442
+ // leaf the contract will refuse.
396
443
  case 'amount':
397
- return { kind: 'amount', token: s.token }
398
444
  case 'window_spent':
399
- return { kind: 'window_spent', token: s.token, windowSeconds: s.windowSeconds }
445
+ throw new Error(
446
+ `interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`
447
+ )
400
448
  case 'invocation_count':
401
449
  return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds }
402
450
  case 'now':
@@ -427,6 +475,19 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
427
475
  * - invocation_count -> u32 (counts are small non-negative integers)
428
476
  * - now / valid_until -> u64 (unix timestamps in seconds) */
429
477
  function literalFromIRCompare(c: IRCompare): PredicateLeaf {
478
+ // An oracle comparand is not a bare literal: the interpreter refuses one,
479
+ // because assuming it shares the normalised 9-dp basis is what made a raw
480
+ // 14-dp threshold permit everything it was written to deny.
481
+ if (c.selector.kind === 'oracle_price') {
482
+ if (c.valueDecimals === undefined) {
483
+ throw toolError(
484
+ 'ORACLE_THRESHOLD_BASIS_REQUIRED',
485
+ 'An oracle price bound must declare the decimal basis of its threshold ' +
486
+ '(oracle prices normalise to 9 decimals).'
487
+ )
488
+ }
489
+ return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals }
490
+ }
430
491
  const scalarType: IRScalarType = selectorScalarType(c.selector)
431
492
  return literalFromScalar(c.value, scalarType)
432
493
  }
@@ -113,15 +113,40 @@ pub fn add_context_rule(
113
113
  - `add_context_rule` is called by the **smart account itself** (the contract owning the context rules).
114
114
  - Auth is enforced by Soroban's `require_auth` on the calling context — the smart account's master signer (or a delegated signer with sufficient weight) must authorize the call.
115
115
  - The interpreter's `install` function is called **within the same transaction** as `add_context_rule` and receives the same `smart_account` address.
116
- - **The interpreter does NOT need to read the smart account's master set** — the master signer authorizes `add_context_rule` directly; the interpreter receives the already-authorized `ContextRule` and `smart_account` address.
116
+ - The interpreter **cannot** read the smart account's master set; it receives only the already-authorized `ContextRule` and `smart_account` address.
117
+
118
+ > **CORRECTED 2026-07-27 - do not read the line above as a safety guarantee.**
119
+ > An earlier version of this file said the interpreter "does NOT need to" read the
120
+ > master set because the master authorizes `add_context_rule` directly. The master
121
+ > *can* authorize it. It is **not** true that only the master can.
122
+ >
123
+ > Verified at this pinned commit: `storage.rs` states `add_policy` /
124
+ > `add_context_rule` modify storage "without requiring authorization. Ensure proper
125
+ > access control is implemented at the contract level"; `mod.rs:439` and `mod.rs:246`
126
+ > implement that as `e.current_contract_address().require_auth()`, a self-auth
127
+ > resolved through the account's own context rules; and `mod.rs:80` states that
128
+ > "a `Default` rule matches any context".
129
+ >
130
+ > Therefore any signer on a `Default` rule can attach or replace a policy - including
131
+ > a delegated agent, on its own rule. See `plans/security-finding-install-authorization.md`.
117
132
 
118
133
  ### Safe mechanism for master-only install
119
134
 
120
- The committed safe mechanism is **two-step install** (not out-of-band master const):
135
+ Two-step install is the install **path**. It is NOT relied on as the authorization
136
+ **guarantee**, because the auth it inherits is whatever the account's own rules permit:
137
+
121
138
  1. Owner calls `add_context_rule` with the interpreter address + initial rule.
122
- 2. Interpreter's `install` is called within the same auth context — the `Context` passed to `Policy::enforce` confirms the call is authorized by the smart account's current signer set.
139
+ 2. Interpreter's `install` is called within the same auth context.
123
140
  3. Re-install is structurally possible via `install_nonce` (stored nonce + 1 check).
124
141
 
142
+ The guarantee comes from the interpreter pinning its own authorized installer set at
143
+ first install for `(smart_account, rule_id)`, and denying later installs and
144
+ uninstalls from outside that set with `MASTER_AUTH_REQUIRED`. Rotation goes through
145
+ `rotate_master_signer_set(new_set)`, gated by the old set.
146
+
147
+ `install_nonce` alone does not close this: it blocks replay of an *old* document, but
148
+ a *fresh* looser document with `nonce + 1` would otherwise be accepted.
149
+
125
150
  ---
126
151
 
127
152
  ## Framework Limits (OZ Hard Limits)
package/src/errors.ts CHANGED
@@ -39,9 +39,20 @@ export type ErrorCode =
39
39
  | 'AMOUNT_OVERFLOW'
40
40
  | 'RULE_SIGNERS_CHANGED'
41
41
  | 'SCOPE_SELF_CALL'
42
+ | 'ARG_MISMATCH'
43
+ | 'CONTRACT_SCOPE'
44
+ | 'UNSUPPORTED_NODE'
45
+ | 'STATEFUL_BOUND'
46
+ | 'NOT_IN_ALLOWLIST'
47
+ | 'FREQUENCY'
48
+ // A `call_arg >= call_arg_scaled(..)` slippage floor was not met. Distinct
49
+ // from the generic ARG_MISMATCH/STATEFUL_BOUND so a violated swap floor
50
+ // reads as itself on the review card.
51
+ | 'SLIPPAGE_FLOOR'
42
52
  // --- oracle denies (fatal evaluator errors) ---
43
53
  | 'ORACLE_STALE'
44
54
  | 'ORACLE_MISSING'
55
+ | 'ORACLE_NO_CONFIRMATION'
45
56
  | 'ORACLE_DEVIATION_EXCEEDED'
46
57
  | 'ORACLE_MALFORMED_HISTORY'
47
58
  | 'ORACLE_FINGERPRINT_DRIFT'
@@ -49,6 +60,8 @@ export type ErrorCode =
49
60
  | 'ORACLE_LEAF_INVALID_POSITION'
50
61
  | 'ORACLE_PARAMS_OUT_OF_RANGE'
51
62
  | 'ORACLE_DECIMALS_MISMATCH'
63
+ | 'ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE'
64
+ | 'ORACLE_THRESHOLD_BASIS_REQUIRED'
52
65
  // --- escape-hatch compile gate ---
53
66
  | 'COMPILE_OK'
54
67
  | 'COMPILE_GATE_FAILED'