@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
@@ -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
 
34
9
  import type { InterpreterAdapterConfig } from '../adapters/interpreter/adapter.ts'
35
10
  import {
@@ -81,34 +56,27 @@ export interface InterpreterAdapterOptions {
81
56
  * (maxStalenessSeconds <= 600, maxDeviationBps <= 200); widening is
82
57
  * rejected at the options boundary. */
83
58
  oracleParams?: { maxStalenessSeconds?: number; maxDeviationBps?: number }
84
- /** @internal Test-only seam. When set, the orchestrator uses this
85
- * PredicateNode as the starting point for self-verify + minimise instead
86
- * of calling the interpreter adapter's compile(). Production callers MUST
87
- * NOT set this; the field exists so the self-verify pipeline can be
88
- * exercised on hand-crafted predicates (redundant conjuncts for
89
- * minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
90
- * `smartAccountAddress` + `installNonce` + `oracleParams` are still
91
- * carried through so the emitted PolicyRef + PolicyDocument match the
92
- * recorded call shape. */
59
+ }
60
+
61
+ /** PRIVATE test-only extension of `InterpreterAdapterOptions` for the
62
+ * `__testPredicateNode` seam. Exported under a `__` prefix so production
63
+ * callers can grep for it and see it is a test hook. The seam is read in
64
+ * `synthesizeFromRecordingInner` only when `process.env.NODE_ENV === 'test'`
65
+ * - a hard RUNTIME guard throws otherwise so a misconfigured production
66
+ * caller cannot bypass the recording -> interpreter adapter compile path. */
67
+ export type __TestInterpreterAdapterOptions = InterpreterAdapterOptions & {
68
+ /** @internal Test-only seam. Throws at runtime when NODE_ENV !== 'test'. */
93
69
  __testPredicateNode?: PredicateNode
94
70
  }
95
71
 
96
- /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
97
- * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
98
- * limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
99
- * `interpreter` opts the recording path into the interpreter adapter. */
72
+ /** Top-level orchestrator inputs. */
100
73
  export interface SynthesizeFromRecordingOptions {
101
74
  network: Network
102
75
  userResponses?: ComposeUserResponses
103
76
  confidenceOverride?: { threshold: number }
104
77
  interpreter?: InterpreterAdapterOptions
105
- /** --explain opt-in. When true, the orchestrator attaches the
106
- * in-memory `PredicateNode` + the corresponding `SimulationResult`
107
- * to the success envelope so the CLI can render a human-readable
108
- * review card. Absent or false -> the success envelope is unchanged
109
- * (byte-identical to today). The flag is ADDITIVE: the existing
110
- * ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
111
- * never altered by enabling explain. */
78
+ /** --explain opt-in. The flag is ADDITIVE: existing ProposedPolicy fields are
79
+ * never altered by enabling explain. Absent -> success envelope is byte-identical. */
112
80
  explain?: true
113
81
  }
114
82
 
@@ -132,13 +100,11 @@ export function synthesizeFromRecording(
132
100
  simulation: SimulationResult
133
101
  }
134
102
  } {
135
- // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
136
- // with a string `.code`) inside the body is converted to a structured
137
- // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
138
- // instead of being silently swallowed. The envelope must wrap the entire
139
- // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
140
- // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
141
- // a structured ToolError rather than a thrown RangeError.
103
+ // Convert any ToolError-shaped throw (object with a string `.code`) to a
104
+ // structured `{ok:false, error}`; anything else is rethrown so genuine bugs
105
+ // crash instead of being silently swallowed. Wraps the entire body so
106
+ // cap errors (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) and cloneScVal depth
107
+ // throws surface as structured ToolErrors rather than RangeErrors.
142
108
  try {
143
109
  return synthesizeFromRecordingInner(tx, opts, ozConfig)
144
110
  } catch (e) {
@@ -157,10 +123,9 @@ export function synthesizeFromRecording(
157
123
  }
158
124
  }
159
125
 
160
- /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
161
- * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
162
- * depth-cap throws). Detected by a string `code` field — the only contract
163
- * the body's internal helpers agree on. */
126
+ /** True when `e` is a ToolError-shaped throw (object with a string `code`).
127
+ * Detected by string `code` field the only contract the body's internal
128
+ * helpers agree on. */
164
129
  function isToolErrorShape(e: unknown): e is { code: string; message: string } {
165
130
  if (e === null || typeof e !== 'object') return false
166
131
  const code = (e as { code?: unknown }).code
@@ -178,7 +143,6 @@ function throwToolError(code: ToolError['code'], message: string): never {
178
143
  throw err
179
144
  }
180
145
 
181
- /** Synthesize a ProposedPolicy from a recorded transaction. */
182
146
  function synthesizeFromRecordingInner(
183
147
  tx: RecordedTransaction,
184
148
  opts: SynthesizeFromRecordingOptions,
@@ -193,11 +157,9 @@ function synthesizeFromRecordingInner(
193
157
  const invalid = validateOptions(opts)
194
158
  if (invalid) return { ok: false, error: invalid }
195
159
 
196
- // 0a. per-movement amount validation (item 2). Synthesizing from a recording
197
- // whose `tokenMovements[].amount` does not match the canonical positive
198
- // decimal integer format is a malformed-input failure, not a runtime
199
- // one - reject it here so the downstream `BigInt(m.amount)` in
200
- // `buildPermitContext` cannot throw a SyntaxError past the envelope.
160
+ // 0a. per-movement amount validation. Reject malformed recordings here so
161
+ // the downstream `BigInt(m.amount)` in `buildPermitContext` cannot throw
162
+ // a SyntaxError past the envelope.
201
163
  for (const [i, m] of tx.tokenMovements.entries()) {
202
164
  if (!/^[0-9]+$/.test(m.amount)) {
203
165
  return {
@@ -227,15 +189,9 @@ function synthesizeFromRecordingInner(
227
189
  }
228
190
  }
229
191
 
230
- // 1a. Zero-invocation refusal (item 1). A recording with zero contract
231
- // invocations cleared the parseConfidence gate legitimately (the
232
- // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
233
- // policy must scope to an authorized contract call. Refuse before any
234
- // lower/scope work so the failure is specific and actionable. The
235
- // recorder's silence is also made visible via `parseConfidence.noInvocations`
236
- // so consumers can pattern-match without inferring from `invocations: []`
237
- // next to `overall: 1.0`. The message does NOT ask for an ABI - the
238
- // failure mode is the recording shape, not decoding coverage.
192
+ // 1a. Zero-invocation refusal. A recording with zero invocations clears
193
+ // parseConfidence legitimately (denom === 0 short-circuits overall to 1.0),
194
+ // but a policy must scope to an authorized contract call. Refuse early.
239
195
  const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0
240
196
  if (hasNoInvocations) {
241
197
  return {
@@ -268,10 +224,8 @@ function synthesizeFromRecordingInner(
268
224
  }
269
225
  }
270
226
 
271
- // 2. lower.
272
227
  const facts = lower(tx)
273
228
 
274
- // 3. decideScope.
275
229
  const scopeRes = decideScope(facts, {
276
230
  network: opts.network,
277
231
  ...(opts.userResponses?.validUntilLedger !== undefined
@@ -302,11 +256,8 @@ function synthesizeFromRecordingInner(
302
256
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
303
257
 
304
258
  // --explain hook: capture the in-memory predicate tree + the real
305
- // self-verify verdict so the CLI can render a faithful review card.
306
- // The verdict below is built from the SAME runHarness + evaluate that
307
- // already gates the synthesis (it is not a parallel simulation); the
308
- // intermediate inputs (harnessCases, evalResult) are otherwise discarded
309
- // after the gate, so the explain hook reuses them - no extra work.
259
+ // self-verify verdict (built from the SAME runHarness + evaluate that gated
260
+ // the synthesis, not a parallel simulation).
310
261
  let explain: PredicateNode | null = null
311
262
  let explainSim: SimulationResult | null = null
312
263
 
@@ -333,6 +284,10 @@ function synthesizeFromRecordingInner(
333
284
 
334
285
  let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
335
286
  let interpreterPolicyRef: ProposedPolicy['policyRefs'][number] | null = null
287
+ // Cross-layer L3: declared OUTSIDE the `if (interpreterOpts)` block so the
288
+ // warnings folded into `proposed.warnings[]` (which lives after that block)
289
+ // can read it. The block assigns it; the default is empty.
290
+ let permitCtxWarnings: string[] = []
336
291
 
337
292
  if (interpreterOpts) {
338
293
  const interpreterConfig: InterpreterAdapterConfig = {
@@ -343,8 +298,19 @@ function synthesizeFromRecordingInner(
343
298
  }
344
299
 
345
300
  let startingPredicate: PredicateNode | null = null
346
- const testSeam = interpreterOpts.__testPredicateNode
301
+ // `__testPredicateNode` is a test-only seam. It is NOT in the public
302
+ // `InterpreterAdapterOptions` type, so a production caller cannot set it
303
+ // without bypassing the type system. We read it via a private cast and
304
+ // enforce a runtime NODE_ENV check so a misconfigured production caller
305
+ // that smuggles it in (any-cast, JSON-driven opt, etc.) is caught here
306
+ // rather than silently overriding the compiled predicate.
307
+ const testSeam = (interpreterOpts as __TestInterpreterAdapterOptions).__testPredicateNode
347
308
  if (testSeam !== undefined) {
309
+ if (process.env.NODE_ENV !== 'test') {
310
+ throw new Error(
311
+ 'synthesizeFromRecording: __testPredicateNode is a test-only seam and is refused outside NODE_ENV=test'
312
+ )
313
+ }
348
314
  startingPredicate = testSeam
349
315
  } else {
350
316
  const interpreterAdapter = createInterpreterAdapter(interpreterConfig)
@@ -466,7 +432,18 @@ function synthesizeFromRecordingInner(
466
432
  },
467
433
  }
468
434
  }
469
- const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
435
+ // Cross-layer L3: warnings collected from `buildPermitContext` (currently
436
+ // only the oracle-on-right normaliser) and folded into the proposed
437
+ // policy's `warnings[]` so the caller sees them on the success envelope.
438
+ const permitCtxResult = buildPermitContext(
439
+ tx,
440
+ scope,
441
+ topLevel,
442
+ opts.userResponses,
443
+ startingPredicate
444
+ )
445
+ const permitCtx = permitCtxResult.ctx
446
+ permitCtxWarnings = permitCtxResult.warnings
470
447
 
471
448
  const finalPredicate: PredicateNode =
472
449
  startingPredicate.op === 'and'
@@ -501,12 +478,9 @@ function synthesizeFromRecordingInner(
501
478
  }
502
479
  }
503
480
 
504
- // --explain capture: the interpreter path already produced the real
505
- // self-verify verdict (runHarness passed, evalResult.permit is true).
506
- // Build the SimulationResult from those outputs so the CLI card
507
- // quotes the SAME verdict that gated the synthesis. We re-evaluate
508
- // each deny case to surface its concrete reason; the harness only
509
- // records whether the got-matches-expected boundary held.
481
+ // --explain capture: quote the SAME verdict that gated the synthesis.
482
+ // Re-evaluate each deny case to surface its concrete reason (the harness
483
+ // only records whether the got-matches-expected boundary held).
510
484
  if (opts.explain) {
511
485
  explain = finalPredicate
512
486
  const evaluatedCases: SimulationResult['evaluatedCases'] = [
@@ -533,10 +507,9 @@ function synthesizeFromRecordingInner(
533
507
  }
534
508
 
535
509
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
536
- // canonical bytes back onto the PolicyDocument + PolicyRef. The
537
- // `encodePredicate` helper throws ToolError-shaped errors on cap
538
- // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
539
- // envelope converts them to a structured `{ok:false, error}`.
510
+ // canonical bytes back onto the PolicyDocument + PolicyRef. Cap breaches
511
+ // (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) throw ToolError-shaped errors;
512
+ // the outer envelope converts them to structured `{ok:false, error}`.
540
513
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
541
514
 
542
515
  if (testSeam !== undefined) {
@@ -612,9 +585,7 @@ function synthesizeFromRecordingInner(
612
585
  // When nothing installable was synthesised (no interpreter doc AND no OZ
613
586
  // policy refs), an empty `policies` array reads as "no restrictions" rather
614
587
  // than "I synthesised nothing". Surface that explicitly so the empty result
615
- // is never mistaken for a permissive policy - the context rule still exists,
616
- // but it constrains nothing. (Kept as `{ok:true}` so the documented
617
- // Path-A/Path-B demo behaviour is preserved - see F3.)
588
+ // is never mistaken for a permissive policy.
618
589
  const zeroPolicyWarning =
619
590
  mergedRefs.length === 0 && !interpreterPolicyDocument
620
591
  ? [
@@ -631,16 +602,14 @@ function synthesizeFromRecordingInner(
631
602
  ...zeroPolicyWarning,
632
603
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
633
604
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
605
+ ...permitCtxWarnings,
634
606
  ],
635
607
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
636
608
  }
637
- // --explain success envelope. The interpreter path populated
638
- // `explain` + `explainSim` from the real self-verify verdict above;
639
- // the OZ-only path did not (no predicate tree exists). When opts.explain
640
- // is set and the OZ-only path ran, construct the minimal honest
641
- // SimulationResult: the verdict is NOT a passing simulation - the
642
- // interpreter was never engaged, so permit is deny with a truthful
643
- // reason and evaluatedCases is empty.
609
+ // --explain success envelope. When opts.explain is set and the OZ-only
610
+ // path ran, construct the minimal honest SimulationResult: the verdict
611
+ // is NOT a passing simulation - the interpreter was never engaged, so
612
+ // permit is deny with a truthful reason and evaluatedCases is empty.
644
613
  const envelope: ToolResponse<ProposedPolicy> & {
645
614
  explain?: {
646
615
  predicateTree: PredicateNode | null
@@ -668,27 +637,24 @@ function synthesizeFromRecordingInner(
668
637
  return envelope
669
638
  }
670
639
 
671
- // Re-export the throwToolError helper for callers that need to surface a
672
- // ToolError-shaped throw inside the body (e.g. tests).
673
640
  export { throwToolError }
674
641
 
675
- /** OZ-side `uncovered` warning patterns that the interpreter adapter
676
- * actually lowers when wired in. When the interpreter adapter succeeds, we
677
- * drop matching entries from the OZ uncovered list so the user-facing
678
- * warnings reflect what is still UN-enforced rather than what OZ alone
679
- * could not do. Match the exact descriptor strings the OZ adapter emits
680
- * (see `src/adapters/oz/adapter.ts#describeCondition` /
681
- * `describeSelector`). */
642
+ /** OZ-side `uncovered` warning patterns the interpreter adapter actually lowers
643
+ * when wired in. When the interpreter adapter succeeds, matching entries are
644
+ * dropped from the OZ uncovered list so user-facing warnings reflect what is
645
+ * still UN-enforced rather than what OZ alone could not do. Matches the
646
+ * exact descriptor strings the OZ adapter emits (see `src/adapters/oz/adapter.ts`
647
+ * `describeCondition` / `describeSelector`). */
682
648
  const INTERPRETER_COVERED_OZ_PATTERN =
683
649
  /^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/
684
650
 
685
651
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
686
652
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
687
- * positive i128 decimal string; network mainnet|testnet. When the caller
688
- * opts into the interpreter adapter, `smartAccountAddress` must be a C...
689
- * contract address (the on-chain policy-bound account, NOT the G... source
690
- * account from the recording), `installNonce` must be a positive integer
691
- * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
653
+ * positive i128 decimal string; network mainnet|testnet. When the interpreter
654
+ * adapter is opted in, `smartAccountAddress` must be a C... contract address
655
+ * (the on-chain policy-bound account, NOT the G... source account),
656
+ * `installNonce` must fit u32 (default 1), and `oracleParams` must
657
+ * tighten-only vs the wasm defaults. */
692
658
  function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
693
659
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
694
660
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
@@ -732,13 +698,9 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
732
698
  `interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
733
699
  )
734
700
  }
735
- // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
736
- // C.../56-char shape check so a fixture/LLM-seam marker is reported
737
- // with the specific placeholder error (not a generic "must be a
738
- // C... contract" message). A real install routes a C... contract
739
- // address derivable from the on-chain account; any
740
- // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
741
- // fixture marker that must never reach the install payload.
701
+ // Block placeholder/stub prefixes BEFORE the C.../56-char shape check so
702
+ // a fixture/LLM-seam marker is reported with the specific placeholder
703
+ // error. 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' must never reach install.
742
704
  if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
743
705
  return synthesisError(
744
706
  `interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
@@ -750,9 +712,7 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
750
712
  )
751
713
  }
752
714
  const nonce = opts.interpreter.installNonce
753
- // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
754
- // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
755
- // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
715
+ // installNonce must fit u32 (the on-chain per-rule nonce is a u32).
756
716
  if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
757
717
  return synthesisError(
758
718
  `interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
@@ -783,7 +743,7 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
783
743
  return null
784
744
  }
785
745
 
786
- /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
746
+ /** Placeholder/stub smart-account prefixes. Mirrors the
787
747
  * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
788
748
  * the interpreter-contract strkey; a real install must point at a C...
789
749
  * contract address derivable from the on-chain account, never a
@@ -792,18 +752,17 @@ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
792
752
 
793
753
  /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
794
754
  * cannot be represented on-chain, so reject it at the synthesis boundary
795
- * (fail-closed) instead of passing it through as an over-broad spending_limit.
796
- * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
755
+ * (fail-closed) instead of passing it through as an over-broad spending_limit. */
797
756
  const I128_MAX = 2n ** 127n - 1n
798
757
 
799
758
  function isPositiveInt(n: number): boolean {
800
759
  return Number.isInteger(n) && n > 0
801
760
  }
802
761
 
803
- /** True when `s` is a canonical positive decimal integer inside the signed-i128
804
- * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
805
- * it cannot be installed on-chain, and accepting it would emit a spending_limit
806
- * with an effectively unbounded cap. */
762
+ /** True when `s` is a canonical positive decimal integer inside [1, 2^127-1].
763
+ * Values above the i128 ceiling are rejected fail-closed: they cannot be
764
+ * installed on-chain, and accepting them would emit a spending_limit with an
765
+ * effectively unbounded cap. */
807
766
  function isPositiveI128(s: string): boolean {
808
767
  if (!/^[0-9]+$/.test(s)) return false
809
768
  try {
@@ -840,27 +799,24 @@ function mergeAmbiguities(
840
799
  return out
841
800
  }
842
801
 
843
- /** Build the permit `EvalContext` the self-verify harness drives. The shape
844
- * mirrors the intended recorded call (the only call the user actually
845
- * performed) so:
846
- * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
847
- * permits the recorded call it was derived from); a failure surfaces
848
- * as DENY_CASE_FAILURE.
802
+ /** Build the permit `EvalContext` the self-verify harness drives. Shape
803
+ * mirrors the intended recorded call so:
804
+ * - `evaluate(predicate, ctx).permit === true` must hold (a failure
805
+ * surfaces as DENY_CASE_FAILURE).
849
806
  * - `generateCases(predicate, ctx)` produces a deny battery that reflects
850
- * the actual recorded move (real amount, real args, real window start).
851
- * Amounts are summed per-token over all movements of the recorded tx
852
- * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
853
- * contains a price+timestamp satisfying each `oracle_price` leaf in the
854
- * predicate so the intended call permits under every bound; the harness
855
- * then mutates those entries (stale / missing / deviation / paused) to
856
- * exercise the ORACLE_* deny paths. */
807
+ * the actual recorded move (real amount, args, window start).
808
+ * Amounts are summed per-token over all movements (BigInt, never lossy).
809
+ * `oraclePriceByAsset` contains a price+timestamp satisfying each
810
+ * `oracle_price` leaf so the intended call permits under every bound;
811
+ * the harness mutates those entries (stale / missing / deviation / paused)
812
+ * to exercise the ORACLE_* deny paths. */
857
813
  function buildPermitContext(
858
814
  tx: RecordedTransaction,
859
815
  scope: Extract<ScopeDecision, { kind: 'call_contract' }>,
860
816
  topLevel: ContractInvocation,
861
817
  userResponses: ComposeUserResponses | undefined,
862
818
  predicate: PredicateNode
863
- ): EvalContext {
819
+ ): { ctx: EvalContext; warnings: string[] } {
864
820
  const amountByToken: Record<string, string> = {}
865
821
  const totals = new Map<string, bigint>()
866
822
  for (const m of tx.tokenMovements) {
@@ -871,9 +827,11 @@ function buildPermitContext(
871
827
  amountByToken[token] = total.toString()
872
828
  }
873
829
 
830
+ const warnings: string[] = []
874
831
  const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = oracleSatisfyingPrices(
875
832
  predicate,
876
- tx.fetchedAt
833
+ tx.fetchedAt,
834
+ warnings
877
835
  )
878
836
 
879
837
  const ctx: EvalContext = {
@@ -890,20 +848,18 @@ function buildPermitContext(
890
848
  if (userResponses?.validUntilLedger !== undefined) {
891
849
  ctx.validUntilLedger = userResponses.validUntilLedger
892
850
  }
893
- return ctx
851
+ return { ctx, warnings }
894
852
  }
895
853
 
896
854
  function cloneScVal(
897
855
  value: { type: string; value: unknown },
898
856
  depth = 0
899
857
  ): ContractInvocation['args'][number] {
900
- // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
901
- // (decoded by the recorder). We clone top-level shells so the harness can
902
- // mutate deny cases without aliasing the recorded call. Recursion is
903
- // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
904
- // cannot RangeError the JS stack; the over-depth branch throws a
905
- // ToolError-shaped error that the `synthesizeFromRecording` envelope
906
- // (item 3) converts to `{ok:false, error}`.
858
+ // Clone top-level shells so the harness can mutate deny cases without
859
+ // aliasing the recorded call. Recursion bounded by MAX_SCVAL_CLONE_DEPTH so
860
+ // a hand-crafted nested-vec cannot RangeError the JS stack; the over-depth
861
+ // branch throws a ToolError-shaped error the envelope converts to
862
+ // `{ok:false, error}`.
907
863
  if (value.type === 'vec') {
908
864
  if (depth >= MAX_SCVAL_CLONE_DEPTH) {
909
865
  throw cloneDepthError(value)
@@ -918,9 +874,6 @@ function cloneScVal(
918
874
  return { ...value } as ContractInvocation['args'][number]
919
875
  }
920
876
 
921
- /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
922
- * The shape (object with string `code`) is what the `synthesizeFromRecording`
923
- * envelope detects and converts to a structured `{ok:false, error}`. */
924
877
  function cloneDepthError(value: { type: string; value: unknown }): never {
925
878
  const err = new Error(
926
879
  `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
@@ -928,52 +881,96 @@ function cloneDepthError(value: { type: string; value: unknown }): never {
928
881
  err.code = 'SYNTHESIS_ERROR'
929
882
  err.severity = 'error'
930
883
  err.retryable = false
931
- // Tag the offending value so failures are diagnosable.
932
884
  err.depthContext = value.type
933
885
  throw err
934
886
  }
935
887
 
936
- /** Walk every `oracle_price` leaf in the predicate and return a price map
937
- * whose entries satisfy the bound so the intended call permits. The
938
- * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
939
- * fresh-oracle deny case in `generateCases` is the only path that flips
940
- * this map. Negatives are clamped at 0 - oracle prices are non-negative on
941
- * Stellar. */
888
+ /** Walk every `oracle_price` leaf and return a price map whose entries satisfy
889
+ * the bound. Timestamp pinned to `nowSeconds` (the recorded `fetchedAt`) so
890
+ * the fresh-oracle deny case in `generateCases` is the only path that flips
891
+ * this map. Negatives clamped at 0 - oracle prices are non-negative on Stellar. */
942
892
  function oracleSatisfyingPrices(
943
893
  predicate: PredicateNode,
944
- nowSeconds: number
894
+ nowSeconds: number,
895
+ warnings: string[]
945
896
  ): EvalContext['oraclePriceByAsset'] {
946
897
  const out: EvalContext['oraclePriceByAsset'] = {}
947
- visitOracleLeaves(predicate, (asset, op, bound) => {
948
- let price: bigint
949
- switch (op) {
950
- case 'lt':
951
- case 'gt':
952
- price = op === 'lt' ? bound - 1n : bound + 1n
953
- break
954
- case 'lte':
955
- case 'gte':
956
- case 'eq':
957
- price = bound
958
- break
898
+ visitOracleLeaves(
899
+ predicate,
900
+ (asset, op, bound) => {
901
+ let price: bigint
902
+ switch (op) {
903
+ case 'lt':
904
+ case 'gt':
905
+ price = op === 'lt' ? bound - 1n : bound + 1n
906
+ break
907
+ case 'lte':
908
+ case 'gte':
909
+ case 'eq':
910
+ price = bound
911
+ break
912
+ }
913
+ if (price < 0n) price = 0n
914
+ out[asset] = { price: price.toString(), timestampSeconds: nowSeconds }
915
+ },
916
+ (warning) => {
917
+ // Cross-layer L3: oracle-on-right that cannot be normalised is
918
+ // surfaced as a warning, not silently dropped. The Rust interpreter
919
+ // would surface this as `UnsupportedNode`; the warning lets the
920
+ // caller decide whether to fix the predicate shape or accept the
921
+ // over-permissive hole.
922
+ warnings.push(warning.message)
959
923
  }
960
- if (price < 0n) price = 0n
961
- out[asset] = { price: price.toString(), timestampSeconds: nowSeconds }
962
- })
924
+ )
963
925
  return out
964
926
  }
965
927
 
928
+ // Cross-layer L3: surface oracle-on-right as a WARNING rather than silently
929
+ // dropping it. The Rust interpreter only fires `eval_oracle_compare` when the
930
+ // oracle leaf is on the LEFT (`oracle_price op threshold`); a predicate that
931
+ // inverts the shape (`threshold op oracle_price`) falls through to
932
+ // `UnsupportedNode` instead. The TS model and the lowering path both attempt
933
+ // to normalise oracle-on-right by flipping the operator so the oracle ends
934
+ // up on the left, but the normalisation is only possible when the other
935
+ // side is a literal we can read as a bigint (an `oracle_threshold`, an
936
+ // `i128`/`u32`/`u64` literal). When it isn't (e.g. `oracle_price < call_arg[i]`),
937
+ // the case cannot be normalised - previously we silently dropped it, which
938
+ // means the harness would never exercise the bound and `minimize` could
939
+ // prune it. The warning is purely additive: the existing normalisation
940
+ // path is preserved, the case is just also reported so callers can decide
941
+ // to either fix the predicate shape or accept the over-permissive hole.
942
+
943
+ /** Diagnostic reported by `visitOracleLeaves` when an oracle comparison
944
+ * cannot be normalised to oracle-on-left. Currently only "oracle-on-right
945
+ * with a non-literal LHS" - the other branches already produce a parseable
946
+ * threshold and are returned to the caller. */
947
+ export interface OracleNormalisationWarning {
948
+ /** The dimension name (currently always "oracle_normalisation_dropped"). */
949
+ dimension: 'oracle_normalisation_dropped'
950
+ /** The operator as written. */
951
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
952
+ /** The asset the oracle leaf is bound to. */
953
+ asset: string
954
+ /** The non-literal RHS (oracle-on-left) or LHS (oracle-on-right) leaf
955
+ * whose shape stopped the normalisation. Surfaced for diagnostics;
956
+ * intentionally a partial view - the full leaf is the caller's job. */
957
+ otherKind: string
958
+ /** Human-readable message (matches the warn-on-drop text). */
959
+ message: string
960
+ }
961
+
966
962
  function visitOracleLeaves(
967
963
  node: PredicateNode,
968
- visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
964
+ visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void,
965
+ onWarning?: (warning: OracleNormalisationWarning) => void
969
966
  ): void {
970
967
  switch (node.op) {
971
968
  case 'and':
972
969
  case 'or':
973
- for (const child of node.children) visitOracleLeaves(child, visit)
970
+ for (const child of node.children) visitOracleLeaves(child, visit, onWarning)
974
971
  return
975
972
  case 'not':
976
- visitOracleLeaves(node.child, visit)
973
+ visitOracleLeaves(node.child, visit, onWarning)
977
974
  return
978
975
  case 'eq':
979
976
  case 'lt':
@@ -993,7 +990,27 @@ function visitOracleLeaves(
993
990
  oracleAsset = rightLeaf.asset
994
991
  literal = oracleLiteralFromLeaf(leftLeaf)
995
992
  }
996
- if (oracleAsset === undefined || literal === undefined) return
993
+ if (oracleAsset === undefined || literal === undefined) {
994
+ // Two reasons the case cannot be normalised:
995
+ // - neither side is an oracle leaf (visitor has nothing to do)
996
+ // - oracle-on-right with a non-literal LHS (Rust dispatch would
997
+ // hit UnsupportedNode; the TS model cannot build a permit ctx
998
+ // for it)
999
+ // Only the second is a meaningful warning; the first is a no-op
1000
+ // (the visitor was called for a non-oracle comparison). Emit the
1001
+ // warning when an oracle IS present on one side but the other
1002
+ // side is not a parseable threshold literal.
1003
+ if (rightIsOracle) {
1004
+ onWarning?.({
1005
+ dimension: 'oracle_normalisation_dropped',
1006
+ op: node.op,
1007
+ asset: rightLeaf.kind === 'oracle_price' ? rightLeaf.asset : '',
1008
+ otherKind: leftLeaf.kind,
1009
+ 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`,
1010
+ })
1011
+ }
1012
+ return
1013
+ }
997
1014
  visit(oracleAsset, node.op, literal)
998
1015
  return
999
1016
  }
@@ -1004,9 +1021,9 @@ function visitOracleLeaves(
1004
1021
  }
1005
1022
  }
1006
1023
 
1007
- /** An oracle threshold restated on the normalised 9-dp basis prices use.
1008
- * Thresholds carry their own basis, so reading the digits raw would build a
1009
- * permit context off by 10^(decimals-9) and the intended call would not
1024
+ /** Restate an oracle threshold on the normalised 9-dp basis prices use.
1025
+ * Thresholds carry their own basis, so reading the digits raw would build
1026
+ * a permit context off by 10^(decimals-9) and the intended call would not
1010
1027
  * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
1011
1028
  function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
1012
1029
  if (leaf.kind !== 'oracle_threshold') return undefined