@crediolabs/policy-synth 0.1.17 → 0.2.0

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 (113) hide show
  1. package/README.md +44 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/authority-overlap.d.ts +134 -0
  8. package/dist/install/authority-overlap.js +0 -0
  9. package/dist/install/build-add-context-rule.d.ts +8 -0
  10. package/dist/install/build-add-context-rule.js +17 -46
  11. package/dist/install/build-install-policy.d.ts +12 -5
  12. package/dist/install/build-install-policy.js +32 -23
  13. package/dist/install/build-merge-policy.d.ts +70 -0
  14. package/dist/install/build-merge-policy.js +130 -0
  15. package/dist/install/get-interpreter-info.js +2 -2
  16. package/dist/install/index.d.ts +3 -1
  17. package/dist/install/index.js +8 -1
  18. package/dist/install/oz-auth.js +5 -3
  19. package/dist/install/plan-merge-policy.d.ts +49 -0
  20. package/dist/install/plan-merge-policy.js +86 -0
  21. package/dist/install/read-account-rules.d.ts +100 -0
  22. package/dist/install/read-account-rules.js +283 -0
  23. package/dist/predicate/decode.js +1 -1
  24. package/dist/predicate/encode.js +171 -10
  25. package/dist/record/decode.js +5 -6
  26. package/dist/registry/protocols.d.ts +1 -1
  27. package/dist/registry/protocols.js +1 -1
  28. package/dist/review-card/builder.d.ts +6 -0
  29. package/dist/review-card/builder.js +9 -1
  30. package/dist/run/index.d.ts +104 -18
  31. package/dist/run/index.js +359 -75
  32. package/dist/run/schemas.d.ts +479 -18
  33. package/dist/run/schemas.js +132 -24
  34. package/dist/synth/compose-from-recording.d.ts +10 -15
  35. package/dist/synth/compose-from-recording.js +79 -124
  36. package/dist/synth/deny-cases.d.ts +5 -0
  37. package/dist/synth/deny-cases.js +52 -13
  38. package/dist/synth/evaluate.js +69 -119
  39. package/dist/synth/harness.d.ts +13 -1
  40. package/dist/synth/harness.js +22 -1
  41. package/dist/synth/index.d.ts +1 -1
  42. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  43. package/dist/synth/synthesize-from-recording.js +129 -155
  44. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  45. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  46. package/dist-cjs/adapters/oz/adapter.js +12 -13
  47. package/dist-cjs/codegen/compile-gate.js +5 -0
  48. package/dist-cjs/codegen/template.js +17 -0
  49. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  50. package/dist-cjs/install/authority-overlap.js +0 -0
  51. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  52. package/dist-cjs/install/build-add-context-rule.js +17 -45
  53. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  54. package/dist-cjs/install/build-install-policy.js +32 -23
  55. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  56. package/dist-cjs/install/build-merge-policy.js +134 -0
  57. package/dist-cjs/install/get-interpreter-info.js +2 -2
  58. package/dist-cjs/install/index.d.ts +3 -1
  59. package/dist-cjs/install/index.js +24 -3
  60. package/dist-cjs/install/oz-auth.js +5 -3
  61. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  62. package/dist-cjs/install/plan-merge-policy.js +90 -0
  63. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  64. package/dist-cjs/install/read-account-rules.js +296 -0
  65. package/dist-cjs/predicate/decode.js +1 -1
  66. package/dist-cjs/predicate/encode.js +171 -10
  67. package/dist-cjs/record/decode.js +5 -6
  68. package/dist-cjs/registry/protocols.d.ts +1 -1
  69. package/dist-cjs/registry/protocols.js +1 -1
  70. package/dist-cjs/review-card/builder.d.ts +6 -0
  71. package/dist-cjs/review-card/builder.js +9 -1
  72. package/dist-cjs/run/index.d.ts +104 -18
  73. package/dist-cjs/run/index.js +360 -74
  74. package/dist-cjs/run/schemas.d.ts +479 -18
  75. package/dist-cjs/run/schemas.js +133 -25
  76. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  77. package/dist-cjs/synth/compose-from-recording.js +79 -124
  78. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  79. package/dist-cjs/synth/deny-cases.js +52 -13
  80. package/dist-cjs/synth/evaluate.js +69 -119
  81. package/dist-cjs/synth/harness.d.ts +13 -1
  82. package/dist-cjs/synth/harness.js +22 -1
  83. package/dist-cjs/synth/index.d.ts +1 -1
  84. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  85. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  86. package/package.json +1 -1
  87. package/src/adapters/interpreter/adapter.ts +28 -48
  88. package/src/adapters/oz/adapter.ts +12 -13
  89. package/src/codegen/compile-gate.ts +5 -0
  90. package/src/codegen/template.ts +17 -0
  91. package/src/install/authority-overlap.ts +0 -0
  92. package/src/install/build-add-context-rule.ts +29 -56
  93. package/src/install/build-install-policy.ts +41 -23
  94. package/src/install/build-merge-policy.ts +219 -0
  95. package/src/install/get-interpreter-info.ts +2 -2
  96. package/src/install/index.ts +36 -2
  97. package/src/install/oz-auth.ts +5 -3
  98. package/src/install/plan-merge-policy.ts +133 -0
  99. package/src/install/read-account-rules.ts +376 -0
  100. package/src/predicate/decode.ts +1 -1
  101. package/src/predicate/encode.ts +176 -10
  102. package/src/record/decode.ts +5 -6
  103. package/src/registry/protocols.ts +1 -1
  104. package/src/review-card/builder.ts +17 -1
  105. package/src/run/index.ts +500 -102
  106. package/src/run/schemas.ts +145 -24
  107. package/src/synth/compose-from-recording.ts +87 -132
  108. package/src/synth/deny-cases.ts +59 -13
  109. package/src/synth/evaluate.ts +70 -118
  110. package/src/synth/harness.ts +29 -1
  111. package/src/synth/index.ts +1 -0
  112. package/src/synth/synthesize-from-recording.ts +209 -192
  113. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -20,21 +20,18 @@ export interface InterpreterAdapterOptions {
20
20
  maxStalenessSeconds?: number;
21
21
  maxDeviationBps?: number;
22
22
  };
23
- /** @internal Test-only seam. When set, the orchestrator uses this
24
- * PredicateNode as the starting point for self-verify + minimise instead
25
- * of calling the interpreter adapter's compile(). Production callers MUST
26
- * NOT set this; the field exists so the self-verify pipeline can be
27
- * exercised on hand-crafted predicates (redundant conjuncts for
28
- * minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
29
- * `smartAccountAddress` + `installNonce` + `oracleParams` are still
30
- * carried through so the emitted PolicyRef + PolicyDocument match the
31
- * recorded call shape. */
32
- __testPredicateNode?: PredicateNode;
33
23
  }
34
- /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
35
- * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
36
- * limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
37
- * `interpreter` opts the recording path into the interpreter adapter. */
24
+ /** PRIVATE test-only extension of `InterpreterAdapterOptions` for the
25
+ * `__testPredicateNode` seam. Exported under a `__` prefix so production
26
+ * callers can grep for it and see it is a test hook. The seam is read in
27
+ * `synthesizeFromRecordingInner` only when `process.env.NODE_ENV === 'test'`
28
+ * - a hard RUNTIME guard throws otherwise so a misconfigured production
29
+ * caller cannot bypass the recording -> interpreter adapter compile path. */
30
+ export type __TestInterpreterAdapterOptions = InterpreterAdapterOptions & {
31
+ /** @internal Test-only seam. Throws at runtime when NODE_ENV !== 'test'. */
32
+ __testPredicateNode?: PredicateNode;
33
+ };
34
+ /** Top-level orchestrator inputs. */
38
35
  export interface SynthesizeFromRecordingOptions {
39
36
  network: Network;
40
37
  userResponses?: ComposeUserResponses;
@@ -42,13 +39,8 @@ export interface SynthesizeFromRecordingOptions {
42
39
  threshold: number;
43
40
  };
44
41
  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. */
42
+ /** --explain opt-in. The flag is ADDITIVE: existing ProposedPolicy fields are
43
+ * never altered by enabling explain. Absent -> success envelope is byte-identical. */
52
44
  explain?: true;
53
45
  }
54
46
  export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
@@ -71,3 +63,21 @@ export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: S
71
63
  * failure that the envelope converts to `{ok:false, error}`. */
72
64
  declare function throwToolError(code: ToolError['code'], message: string): never;
73
65
  export { throwToolError };
66
+ /** Diagnostic reported by `visitOracleLeaves` when an oracle comparison
67
+ * cannot be normalised to oracle-on-left. Currently only "oracle-on-right
68
+ * with a non-literal LHS" - the other branches already produce a parseable
69
+ * threshold and are returned to the caller. */
70
+ export interface OracleNormalisationWarning {
71
+ /** The dimension name (currently always "oracle_normalisation_dropped"). */
72
+ dimension: 'oracle_normalisation_dropped';
73
+ /** The operator as written. */
74
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte';
75
+ /** The asset the oracle leaf is bound to. */
76
+ asset: string;
77
+ /** The non-literal RHS (oracle-on-left) or LHS (oracle-on-right) leaf
78
+ * whose shape stopped the normalisation. Surfaced for diagnostics;
79
+ * intentionally a partial view - the full leaf is the caller's job. */
80
+ otherKind: string;
81
+ /** Human-readable message (matches the warn-on-drop text). */
82
+ message: string;
83
+ }
@@ -1,35 +1,10 @@
1
1
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
2
2
  //
3
- // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
4
- // bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
5
- // pair used by the deterministic Mandate path. The flow:
6
- //
7
- // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
8
- // the caller may relax via `confidenceOverride.threshold`).
9
- // 2. `lower(tx)` -> IntentFacts.
10
- // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
11
- // 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
12
- // interpreterIr (predicate-shape), ambiguities, warnings }.
13
- // 5. `ozAdapter.compile(ir)` -> CompileResult.
14
- // 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
15
- // opted in to the interpreter adapter:
16
- // a. Run `interpreterAdapter.compile(interpreterIr)`.
17
- // b. Throw -> SYNTHESIS_ERROR carrying the gate code
18
- // (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
19
- // ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
20
- // policy - a warning can be ignored and yields an over-broad rule.
21
- // c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
22
- // descriptor (the user asked for the interpreter to enforce these
23
- // constraints; refusing them silently is the audit failure mode).
24
- // d. `covered === true` -> merge its PolicyDocument + PolicyRef into
25
- // the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
26
- // is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
27
- // `OZ_LIMITS.maxPoliciesPerRule` (= 5).
28
- // 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
29
- // (the OZ `uncovered` markers, prefixed), and ambiguities.
30
- //
31
- // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
32
- // No randomness, no clock, no globals.
3
+ // `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
4
+ // via the same `PolicyIR` + adapter pair used by the deterministic Mandate path.
5
+ // Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
6
+ // -> OZ compile -> (opt-in) interpreter compile + self-verify + minimise -> merge.
7
+ // Same (tx, opts, ozConfig) -> byte-identical ProposedPolicy (no randomness, clock, globals).
33
8
  import { createInterpreterAdapter, lowerRuleToPredicate, PLACEHOLDER_INTERPRETER_ADDRESS, } from "../adapters/interpreter/adapter.js";
34
9
  import { createOzAdapter } from "../adapters/oz/adapter.js";
35
10
  import { encodePredicate } from "../predicate/encode.js";
@@ -43,13 +18,11 @@ import { minimize } from "./minimize.js";
43
18
  import { decideScope } from "./scope.js";
44
19
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
45
20
  export function synthesizeFromRecording(tx, opts, ozConfig) {
46
- // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
47
- // with a string `.code`) inside the body is converted to a structured
48
- // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
49
- // instead of being silently swallowed. The envelope must wrap the entire
50
- // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
51
- // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
52
- // a structured ToolError rather than a thrown RangeError.
21
+ // Convert any ToolError-shaped throw (object with a string `.code`) to a
22
+ // structured `{ok:false, error}`; anything else is rethrown so genuine bugs
23
+ // crash instead of being silently swallowed. Wraps the entire body so
24
+ // cap errors (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) and cloneScVal depth
25
+ // throws surface as structured ToolErrors rather than RangeErrors.
53
26
  try {
54
27
  return synthesizeFromRecordingInner(tx, opts, ozConfig);
55
28
  }
@@ -68,10 +41,9 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
68
41
  throw e;
69
42
  }
70
43
  }
71
- /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
72
- * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
73
- * depth-cap throws). Detected by a string `code` field — the only contract
74
- * the body's internal helpers agree on. */
44
+ /** True when `e` is a ToolError-shaped throw (object with a string `code`).
45
+ * Detected by string `code` field the only contract the body's internal
46
+ * helpers agree on. */
75
47
  function isToolErrorShape(e) {
76
48
  if (e === null || typeof e !== 'object')
77
49
  return false;
@@ -88,17 +60,14 @@ function throwToolError(code, message) {
88
60
  err.retryable = false;
89
61
  throw err;
90
62
  }
91
- /** Synthesize a ProposedPolicy from a recorded transaction. */
92
63
  function synthesizeFromRecordingInner(tx, opts, ozConfig) {
93
64
  // 0. validate inputs (fail closed - never synthesize from garbage).
94
65
  const invalid = validateOptions(opts);
95
66
  if (invalid)
96
67
  return { ok: false, error: invalid };
97
- // 0a. per-movement amount validation (item 2). Synthesizing from a recording
98
- // whose `tokenMovements[].amount` does not match the canonical positive
99
- // decimal integer format is a malformed-input failure, not a runtime
100
- // one - reject it here so the downstream `BigInt(m.amount)` in
101
- // `buildPermitContext` cannot throw a SyntaxError past the envelope.
68
+ // 0a. per-movement amount validation. Reject malformed recordings here so
69
+ // the downstream `BigInt(m.amount)` in `buildPermitContext` cannot throw
70
+ // a SyntaxError past the envelope.
102
71
  for (const [i, m] of tx.tokenMovements.entries()) {
103
72
  if (!/^[0-9]+$/.test(m.amount)) {
104
73
  return {
@@ -126,15 +95,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
126
95
  },
127
96
  };
128
97
  }
129
- // 1a. Zero-invocation refusal (item 1). A recording with zero contract
130
- // invocations cleared the parseConfidence gate legitimately (the
131
- // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
132
- // policy must scope to an authorized contract call. Refuse before any
133
- // lower/scope work so the failure is specific and actionable. The
134
- // recorder's silence is also made visible via `parseConfidence.noInvocations`
135
- // so consumers can pattern-match without inferring from `invocations: []`
136
- // next to `overall: 1.0`. The message does NOT ask for an ABI - the
137
- // failure mode is the recording shape, not decoding coverage.
98
+ // 1a. Zero-invocation refusal. A recording with zero invocations clears
99
+ // parseConfidence legitimately (denom === 0 short-circuits overall to 1.0),
100
+ // but a policy must scope to an authorized contract call. Refuse early.
138
101
  const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
139
102
  if (hasNoInvocations) {
140
103
  return {
@@ -164,9 +127,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
164
127
  },
165
128
  };
166
129
  }
167
- // 2. lower.
168
130
  const facts = lower(tx);
169
- // 3. decideScope.
170
131
  const scopeRes = decideScope(facts, {
171
132
  network: opts.network,
172
133
  ...(opts.userResponses?.validUntilLedger !== undefined
@@ -196,11 +157,8 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
196
157
  };
197
158
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
198
159
  // --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.
160
+ // self-verify verdict (built from the SAME runHarness + evaluate that gated
161
+ // the synthesis, not a parallel simulation).
204
162
  let explain = null;
205
163
  let explainSim = null;
206
164
  // 5. OZ compile (always runs).
@@ -223,6 +181,10 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
223
181
  const interpreterOpts = opts.interpreter;
224
182
  let interpreterPolicyDocument = null;
225
183
  let interpreterPolicyRef = null;
184
+ // Cross-layer L3: declared OUTSIDE the `if (interpreterOpts)` block so the
185
+ // warnings folded into `proposed.warnings[]` (which lives after that block)
186
+ // can read it. The block assigns it; the default is empty.
187
+ let permitCtxWarnings = [];
226
188
  if (interpreterOpts) {
227
189
  const interpreterConfig = {
228
190
  network: opts.network,
@@ -231,8 +193,17 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
231
193
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
232
194
  };
233
195
  let startingPredicate = null;
196
+ // `__testPredicateNode` is a test-only seam. It is NOT in the public
197
+ // `InterpreterAdapterOptions` type, so a production caller cannot set it
198
+ // without bypassing the type system. We read it via a private cast and
199
+ // enforce a runtime NODE_ENV check so a misconfigured production caller
200
+ // that smuggles it in (any-cast, JSON-driven opt, etc.) is caught here
201
+ // rather than silently overriding the compiled predicate.
234
202
  const testSeam = interpreterOpts.__testPredicateNode;
235
203
  if (testSeam !== undefined) {
204
+ if (process.env.NODE_ENV !== 'test') {
205
+ throw new Error('synthesizeFromRecording: __testPredicateNode is a test-only seam and is refused outside NODE_ENV=test');
206
+ }
236
207
  startingPredicate = testSeam;
237
208
  }
238
209
  else {
@@ -349,7 +320,12 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
349
320
  },
350
321
  };
351
322
  }
352
- const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
323
+ // Cross-layer L3: warnings collected from `buildPermitContext` (currently
324
+ // only the oracle-on-right normaliser) and folded into the proposed
325
+ // policy's `warnings[]` so the caller sees them on the success envelope.
326
+ const permitCtxResult = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
327
+ const permitCtx = permitCtxResult.ctx;
328
+ permitCtxWarnings = permitCtxResult.warnings;
353
329
  const finalPredicate = startingPredicate.op === 'and'
354
330
  ? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
355
331
  : startingPredicate;
@@ -380,12 +356,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
380
356
  },
381
357
  };
382
358
  }
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.
359
+ // --explain capture: quote the SAME verdict that gated the synthesis.
360
+ // Re-evaluate each deny case to surface its concrete reason (the harness
361
+ // only records whether the got-matches-expected boundary held).
389
362
  if (opts.explain) {
390
363
  explain = finalPredicate;
391
364
  const evaluatedCases = [
@@ -411,10 +384,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
411
384
  };
412
385
  }
413
386
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
414
- // canonical bytes back onto the PolicyDocument + PolicyRef. The
415
- // `encodePredicate` helper throws ToolError-shaped errors on cap
416
- // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
417
- // envelope converts them to a structured `{ok:false, error}`.
387
+ // canonical bytes back onto the PolicyDocument + PolicyRef. Cap breaches
388
+ // (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) throw ToolError-shaped errors;
389
+ // the outer envelope converts them to structured `{ok:false, error}`.
418
390
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
419
391
  if (testSeam !== undefined) {
420
392
  interpreterPolicyDocument = {
@@ -487,9 +459,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
487
459
  // When nothing installable was synthesised (no interpreter doc AND no OZ
488
460
  // policy refs), an empty `policies` array reads as "no restrictions" rather
489
461
  // than "I synthesised nothing". Surface that explicitly so the empty result
490
- // is never mistaken for a permissive policy - the context rule still exists,
491
- // but it constrains nothing. (Kept as `{ok:true}` so the documented
492
- // Path-A/Path-B demo behaviour is preserved - see F3.)
462
+ // is never mistaken for a permissive policy.
493
463
  const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
494
464
  ? [
495
465
  'No policy constraints were synthesised: the call to this contract is UNCONSTRAINED by this policy. Enable the interpreter (supply a smart account) to enforce the surfaced constraints.',
@@ -504,16 +474,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
504
474
  ...zeroPolicyWarning,
505
475
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
506
476
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
477
+ ...permitCtxWarnings,
507
478
  ],
508
479
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
509
480
  };
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.
481
+ // --explain success envelope. When opts.explain is set and the OZ-only
482
+ // path ran, construct the minimal honest SimulationResult: the verdict
483
+ // is NOT a passing simulation - the interpreter was never engaged, so
484
+ // permit is deny with a truthful reason and evaluatedCases is empty.
517
485
  const envelope = { ok: true, data: proposed };
518
486
  if (opts.explain) {
519
487
  if (explainSim) {
@@ -536,24 +504,21 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
536
504
  }
537
505
  return envelope;
538
506
  }
539
- // Re-export the throwToolError helper for callers that need to surface a
540
- // ToolError-shaped throw inside the body (e.g. tests).
541
507
  export { throwToolError };
542
- /** OZ-side `uncovered` warning patterns that the interpreter adapter
543
- * actually lowers when wired in. When the interpreter adapter succeeds, we
544
- * drop matching entries from the OZ uncovered list so the user-facing
545
- * warnings reflect what is still UN-enforced rather than what OZ alone
546
- * could not do. Match the exact descriptor strings the OZ adapter emits
547
- * (see `src/adapters/oz/adapter.ts#describeCondition` /
548
- * `describeSelector`). */
508
+ /** OZ-side `uncovered` warning patterns the interpreter adapter actually lowers
509
+ * when wired in. When the interpreter adapter succeeds, matching entries are
510
+ * dropped from the OZ uncovered list so user-facing warnings reflect what is
511
+ * still UN-enforced rather than what OZ alone could not do. Matches the
512
+ * exact descriptor strings the OZ adapter emits (see `src/adapters/oz/adapter.ts`
513
+ * `describeCondition` / `describeSelector`). */
549
514
  const INTERPRETER_COVERED_OZ_PATTERN = /^per-method scoping to|^value allowlist on arg|^exact ordered sequence on arg|^oracle price condition on|^invocation-count window|^spending_limit on token .+ needs a CallContract context scoped to that token/;
550
515
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
551
516
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
552
- * positive i128 decimal string; network mainnet|testnet. When the caller
553
- * opts into the interpreter adapter, `smartAccountAddress` must be a C...
554
- * contract address (the on-chain policy-bound account, NOT the G... source
555
- * account from the recording), `installNonce` must be a positive integer
556
- * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
517
+ * positive i128 decimal string; network mainnet|testnet. When the interpreter
518
+ * adapter is opted in, `smartAccountAddress` must be a C... contract address
519
+ * (the on-chain policy-bound account, NOT the G... source account),
520
+ * `installNonce` must fit u32 (default 1), and `oracleParams` must
521
+ * tighten-only vs the wasm defaults. */
557
522
  function validateOptions(opts) {
558
523
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
559
524
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
@@ -585,13 +550,9 @@ function validateOptions(opts) {
585
550
  if (typeof sa !== 'string' || sa.length === 0) {
586
551
  return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
587
552
  }
588
- // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
589
- // C.../56-char shape check so a fixture/LLM-seam marker is reported
590
- // with the specific placeholder error (not a generic "must be a
591
- // C... contract" message). A real install routes a C... contract
592
- // address derivable from the on-chain account; any
593
- // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
594
- // fixture marker that must never reach the install payload.
553
+ // Block placeholder/stub prefixes BEFORE the C.../56-char shape check so
554
+ // a fixture/LLM-seam marker is reported with the specific placeholder
555
+ // error. 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' must never reach install.
595
556
  if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
596
557
  return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
597
558
  }
@@ -599,9 +560,7 @@ function validateOptions(opts) {
599
560
  return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
600
561
  }
601
562
  const nonce = opts.interpreter.installNonce;
602
- // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
603
- // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
604
- // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
563
+ // installNonce must fit u32 (the on-chain per-rule nonce is a u32).
605
564
  if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
606
565
  return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
607
566
  }
@@ -621,7 +580,7 @@ function validateOptions(opts) {
621
580
  }
622
581
  return null;
623
582
  }
624
- /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
583
+ /** Placeholder/stub smart-account prefixes. Mirrors the
625
584
  * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
626
585
  * the interpreter-contract strkey; a real install must point at a C...
627
586
  * contract address derivable from the on-chain account, never a
@@ -629,16 +588,15 @@ function validateOptions(opts) {
629
588
  const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
630
589
  /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
631
590
  * cannot be represented on-chain, so reject it at the synthesis boundary
632
- * (fail-closed) instead of passing it through as an over-broad spending_limit.
633
- * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
591
+ * (fail-closed) instead of passing it through as an over-broad spending_limit. */
634
592
  const I128_MAX = 2n ** 127n - 1n;
635
593
  function isPositiveInt(n) {
636
594
  return Number.isInteger(n) && n > 0;
637
595
  }
638
- /** True when `s` is a canonical positive decimal integer inside the signed-i128
639
- * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
640
- * it cannot be installed on-chain, and accepting it would emit a spending_limit
641
- * with an effectively unbounded cap. */
596
+ /** True when `s` is a canonical positive decimal integer inside [1, 2^127-1].
597
+ * Values above the i128 ceiling are rejected fail-closed: they cannot be
598
+ * installed on-chain, and accepting them would emit a spending_limit with an
599
+ * effectively unbounded cap. */
642
600
  function isPositiveI128(s) {
643
601
  if (!/^[0-9]+$/.test(s))
644
602
  return false;
@@ -672,20 +630,17 @@ function mergeAmbiguities(...lists) {
672
630
  }
673
631
  return out;
674
632
  }
675
- /** Build the permit `EvalContext` the self-verify harness drives. The shape
676
- * mirrors the intended recorded call (the only call the user actually
677
- * performed) so:
678
- * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
679
- * permits the recorded call it was derived from); a failure surfaces
680
- * as DENY_CASE_FAILURE.
633
+ /** Build the permit `EvalContext` the self-verify harness drives. Shape
634
+ * mirrors the intended recorded call so:
635
+ * - `evaluate(predicate, ctx).permit === true` must hold (a failure
636
+ * surfaces as DENY_CASE_FAILURE).
681
637
  * - `generateCases(predicate, ctx)` produces a deny battery that reflects
682
- * the actual recorded move (real amount, real args, real window start).
683
- * Amounts are summed per-token over all movements of the recorded tx
684
- * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
685
- * contains a price+timestamp satisfying each `oracle_price` leaf in the
686
- * predicate so the intended call permits under every bound; the harness
687
- * then mutates those entries (stale / missing / deviation / paused) to
688
- * exercise the ORACLE_* deny paths. */
638
+ * the actual recorded move (real amount, args, window start).
639
+ * Amounts are summed per-token over all movements (BigInt, never lossy).
640
+ * `oraclePriceByAsset` contains a price+timestamp satisfying each
641
+ * `oracle_price` leaf so the intended call permits under every bound;
642
+ * the harness mutates those entries (stale / missing / deviation / paused)
643
+ * to exercise the ORACLE_* deny paths. */
689
644
  function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
690
645
  const amountByToken = {};
691
646
  const totals = new Map();
@@ -696,7 +651,8 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
696
651
  for (const [token, total] of totals) {
697
652
  amountByToken[token] = total.toString();
698
653
  }
699
- const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt);
654
+ const warnings = [];
655
+ const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt, warnings);
700
656
  const ctx = {
701
657
  contract: scope.contract,
702
658
  fn: topLevel.fn,
@@ -711,16 +667,14 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
711
667
  if (userResponses?.validUntilLedger !== undefined) {
712
668
  ctx.validUntilLedger = userResponses.validUntilLedger;
713
669
  }
714
- return ctx;
670
+ return { ctx, warnings };
715
671
  }
716
672
  function cloneScVal(value, depth = 0) {
717
- // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
718
- // (decoded by the recorder). We clone top-level shells so the harness can
719
- // mutate deny cases without aliasing the recorded call. Recursion is
720
- // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
721
- // cannot RangeError the JS stack; the over-depth branch throws a
722
- // ToolError-shaped error that the `synthesizeFromRecording` envelope
723
- // (item 3) converts to `{ok:false, error}`.
673
+ // Clone top-level shells so the harness can mutate deny cases without
674
+ // aliasing the recorded call. Recursion bounded by MAX_SCVAL_CLONE_DEPTH so
675
+ // a hand-crafted nested-vec cannot RangeError the JS stack; the over-depth
676
+ // branch throws a ToolError-shaped error the envelope converts to
677
+ // `{ok:false, error}`.
724
678
  if (value.type === 'vec') {
725
679
  if (depth >= MAX_SCVAL_CLONE_DEPTH) {
726
680
  throw cloneDepthError(value);
@@ -732,25 +686,19 @@ function cloneScVal(value, depth = 0) {
732
686
  }
733
687
  return { ...value };
734
688
  }
735
- /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
736
- * The shape (object with string `code`) is what the `synthesizeFromRecording`
737
- * envelope detects and converts to a structured `{ok:false, error}`. */
738
689
  function cloneDepthError(value) {
739
690
  const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
740
691
  err.code = 'SYNTHESIS_ERROR';
741
692
  err.severity = 'error';
742
693
  err.retryable = false;
743
- // Tag the offending value so failures are diagnosable.
744
694
  err.depthContext = value.type;
745
695
  throw err;
746
696
  }
747
- /** Walk every `oracle_price` leaf in the predicate and return a price map
748
- * whose entries satisfy the bound so the intended call permits. The
749
- * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
750
- * fresh-oracle deny case in `generateCases` is the only path that flips
751
- * this map. Negatives are clamped at 0 - oracle prices are non-negative on
752
- * Stellar. */
753
- function oracleSatisfyingPrices(predicate, nowSeconds) {
697
+ /** Walk every `oracle_price` leaf and return a price map whose entries satisfy
698
+ * the bound. Timestamp pinned to `nowSeconds` (the recorded `fetchedAt`) so
699
+ * the fresh-oracle deny case in `generateCases` is the only path that flips
700
+ * this map. Negatives clamped at 0 - oracle prices are non-negative on Stellar. */
701
+ function oracleSatisfyingPrices(predicate, nowSeconds, warnings) {
754
702
  const out = {};
755
703
  visitOracleLeaves(predicate, (asset, op, bound) => {
756
704
  let price;
@@ -768,18 +716,25 @@ function oracleSatisfyingPrices(predicate, nowSeconds) {
768
716
  if (price < 0n)
769
717
  price = 0n;
770
718
  out[asset] = { price: price.toString(), timestampSeconds: nowSeconds };
719
+ }, (warning) => {
720
+ // Cross-layer L3: oracle-on-right that cannot be normalised is
721
+ // surfaced as a warning, not silently dropped. The Rust interpreter
722
+ // would surface this as `UnsupportedNode`; the warning lets the
723
+ // caller decide whether to fix the predicate shape or accept the
724
+ // over-permissive hole.
725
+ warnings.push(warning.message);
771
726
  });
772
727
  return out;
773
728
  }
774
- function visitOracleLeaves(node, visit) {
729
+ function visitOracleLeaves(node, visit, onWarning) {
775
730
  switch (node.op) {
776
731
  case 'and':
777
732
  case 'or':
778
733
  for (const child of node.children)
779
- visitOracleLeaves(child, visit);
734
+ visitOracleLeaves(child, visit, onWarning);
780
735
  return;
781
736
  case 'not':
782
- visitOracleLeaves(node.child, visit);
737
+ visitOracleLeaves(node.child, visit, onWarning);
783
738
  return;
784
739
  case 'eq':
785
740
  case 'lt':
@@ -800,8 +755,27 @@ function visitOracleLeaves(node, visit) {
800
755
  oracleAsset = rightLeaf.asset;
801
756
  literal = oracleLiteralFromLeaf(leftLeaf);
802
757
  }
803
- if (oracleAsset === undefined || literal === undefined)
758
+ if (oracleAsset === undefined || literal === undefined) {
759
+ // Two reasons the case cannot be normalised:
760
+ // - neither side is an oracle leaf (visitor has nothing to do)
761
+ // - oracle-on-right with a non-literal LHS (Rust dispatch would
762
+ // hit UnsupportedNode; the TS model cannot build a permit ctx
763
+ // for it)
764
+ // Only the second is a meaningful warning; the first is a no-op
765
+ // (the visitor was called for a non-oracle comparison). Emit the
766
+ // warning when an oracle IS present on one side but the other
767
+ // side is not a parseable threshold literal.
768
+ if (rightIsOracle) {
769
+ onWarning?.({
770
+ dimension: 'oracle_normalisation_dropped',
771
+ op: node.op,
772
+ asset: rightLeaf.kind === 'oracle_price' ? rightLeaf.asset : '',
773
+ otherKind: leftLeaf.kind,
774
+ message: `oracle-on-right cannot be normalised: the LHS (kind=${leftLeaf.kind}) is not an oracle_threshold literal; the Rust interpreter would surface UnsupportedNode here`,
775
+ });
776
+ }
804
777
  return;
778
+ }
805
779
  visit(oracleAsset, node.op, literal);
806
780
  return;
807
781
  }
@@ -811,9 +785,9 @@ function visitOracleLeaves(node, visit) {
811
785
  return;
812
786
  }
813
787
  }
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
788
+ /** Restate an oracle threshold on the normalised 9-dp basis prices use.
789
+ * Thresholds carry their own basis, so reading the digits raw would build
790
+ * a permit context off by 10^(decimals-9) and the intended call would not
817
791
  * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
818
792
  function oracleLiteralFromLeaf(leaf) {
819
793
  if (leaf.kind !== 'oracle_threshold')
@@ -4,7 +4,7 @@ import type { Network, PredicateNode } from '../../types.ts';
4
4
  /** [VERIFY] NOT a real deployed address. The interpreter is a per-network
5
5
  * deploy artifact we do not have yet; install is a later phase. */
6
6
  export declare const PLACEHOLDER_INTERPRETER_ADDRESS = "VERIFY-interpreter-address";
7
- /** Wasm-level oracle defaults (mirrors INTERPRETER_INSTALL_PARAMS.md OracleParams).
7
+ /** Wasm-level oracle defaults (mirrors the interpreter's `OracleParams`).
8
8
  * Per-policy overrides may TIGHTEN only - they may never exceed these. */
9
9
  export declare const ORACLE_DEFAULTS: {
10
10
  readonly maxStalenessSeconds: 600;
@@ -29,10 +29,9 @@ export interface InterpreterAdapterConfig {
29
29
  export declare function createInterpreterAdapter(config: InterpreterAdapterConfig): CustodyAdapter;
30
30
  /** Lower a single IR rule to the canonical pre-encoding `PredicateNode`. The
31
31
  * orchestrator uses this to wire the self-verify + minimise pipeline: after
32
- * `compile(ir)` succeeds (`covered === true`, `proposed` set), the
33
- * orchestrator re-derives the PredicateNode via this helper to drive
34
- * `minimize` and `runHarness` on the SAME shape the encoder saw. Pure and
35
- * deterministic: same `rule + config` -> byte-identical PredicateNode. The
36
- * `uncovered` list the adapter surfaces during compile is NOT re-derived
37
- * here; callers that need it should use the result of `compile(ir)`. */
32
+ * `compile(ir)` succeeds it re-derives the PredicateNode via this helper to
33
+ * drive `minimize` and `runHarness` on the SAME shape the encoder saw. Pure
34
+ * and deterministic: same `rule + config` -> byte-identical PredicateNode.
35
+ * The `uncovered` list is NOT re-derived - callers needing it must use
36
+ * `compile(ir)`. */
38
37
  export declare function lowerRuleToPredicate(rule: IRPolicyRule, config: InterpreterAdapterConfig): PredicateNode;