@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,36 +1,11 @@
1
1
  "use strict";
2
2
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
3
3
  //
4
- // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
5
- // bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
6
- // pair used by the deterministic Mandate path. The flow:
7
- //
8
- // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
9
- // the caller may relax via `confidenceOverride.threshold`).
10
- // 2. `lower(tx)` -> IntentFacts.
11
- // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
12
- // 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
13
- // interpreterIr (predicate-shape), ambiguities, warnings }.
14
- // 5. `ozAdapter.compile(ir)` -> CompileResult.
15
- // 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
16
- // opted in to the interpreter adapter:
17
- // a. Run `interpreterAdapter.compile(interpreterIr)`.
18
- // b. Throw -> SYNTHESIS_ERROR carrying the gate code
19
- // (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
20
- // ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
21
- // policy - a warning can be ignored and yields an over-broad rule.
22
- // c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
23
- // descriptor (the user asked for the interpreter to enforce these
24
- // constraints; refusing them silently is the audit failure mode).
25
- // d. `covered === true` -> merge its PolicyDocument + PolicyRef into
26
- // the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
27
- // is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
28
- // `OZ_LIMITS.maxPoliciesPerRule` (= 5).
29
- // 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
30
- // (the OZ `uncovered` markers, prefixed), and ambiguities.
31
- //
32
- // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
33
- // No randomness, no clock, no globals.
4
+ // `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
5
+ // via the same `PolicyIR` + adapter pair used by the deterministic Mandate path.
6
+ // Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
7
+ // -> OZ compile -> (opt-in) interpreter compile + self-verify + minimise -> merge.
8
+ // Same (tx, opts, ozConfig) -> byte-identical ProposedPolicy (no randomness, clock, globals).
34
9
  Object.defineProperty(exports, "__esModule", { value: true });
35
10
  exports.synthesizeFromRecording = synthesizeFromRecording;
36
11
  exports.throwToolError = throwToolError;
@@ -47,13 +22,11 @@ const minimize_ts_1 = require("./minimize.js");
47
22
  const scope_ts_1 = require("./scope.js");
48
23
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
49
24
  function synthesizeFromRecording(tx, opts, ozConfig) {
50
- // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
51
- // with a string `.code`) inside the body is converted to a structured
52
- // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
53
- // instead of being silently swallowed. The envelope must wrap the entire
54
- // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
55
- // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
56
- // a structured ToolError rather than a thrown RangeError.
25
+ // Convert any ToolError-shaped throw (object with a string `.code`) to a
26
+ // structured `{ok:false, error}`; anything else is rethrown so genuine bugs
27
+ // crash instead of being silently swallowed. Wraps the entire body so
28
+ // cap errors (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) and cloneScVal depth
29
+ // throws surface as structured ToolErrors rather than RangeErrors.
57
30
  try {
58
31
  return synthesizeFromRecordingInner(tx, opts, ozConfig);
59
32
  }
@@ -72,10 +45,9 @@ function synthesizeFromRecording(tx, opts, ozConfig) {
72
45
  throw e;
73
46
  }
74
47
  }
75
- /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
76
- * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
77
- * depth-cap throws). Detected by a string `code` field — the only contract
78
- * the body's internal helpers agree on. */
48
+ /** True when `e` is a ToolError-shaped throw (object with a string `code`).
49
+ * Detected by string `code` field the only contract the body's internal
50
+ * helpers agree on. */
79
51
  function isToolErrorShape(e) {
80
52
  if (e === null || typeof e !== 'object')
81
53
  return false;
@@ -92,17 +64,14 @@ function throwToolError(code, message) {
92
64
  err.retryable = false;
93
65
  throw err;
94
66
  }
95
- /** Synthesize a ProposedPolicy from a recorded transaction. */
96
67
  function synthesizeFromRecordingInner(tx, opts, ozConfig) {
97
68
  // 0. validate inputs (fail closed - never synthesize from garbage).
98
69
  const invalid = validateOptions(opts);
99
70
  if (invalid)
100
71
  return { ok: false, error: invalid };
101
- // 0a. per-movement amount validation (item 2). Synthesizing from a recording
102
- // whose `tokenMovements[].amount` does not match the canonical positive
103
- // decimal integer format is a malformed-input failure, not a runtime
104
- // one - reject it here so the downstream `BigInt(m.amount)` in
105
- // `buildPermitContext` cannot throw a SyntaxError past the envelope.
72
+ // 0a. per-movement amount validation. Reject malformed recordings here so
73
+ // the downstream `BigInt(m.amount)` in `buildPermitContext` cannot throw
74
+ // a SyntaxError past the envelope.
106
75
  for (const [i, m] of tx.tokenMovements.entries()) {
107
76
  if (!/^[0-9]+$/.test(m.amount)) {
108
77
  return {
@@ -130,15 +99,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
130
99
  },
131
100
  };
132
101
  }
133
- // 1a. Zero-invocation refusal (item 1). A recording with zero contract
134
- // invocations cleared the parseConfidence gate legitimately (the
135
- // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
136
- // policy must scope to an authorized contract call. Refuse before any
137
- // lower/scope work so the failure is specific and actionable. The
138
- // recorder's silence is also made visible via `parseConfidence.noInvocations`
139
- // so consumers can pattern-match without inferring from `invocations: []`
140
- // next to `overall: 1.0`. The message does NOT ask for an ABI - the
141
- // failure mode is the recording shape, not decoding coverage.
102
+ // 1a. Zero-invocation refusal. A recording with zero invocations clears
103
+ // parseConfidence legitimately (denom === 0 short-circuits overall to 1.0),
104
+ // but a policy must scope to an authorized contract call. Refuse early.
142
105
  const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
143
106
  if (hasNoInvocations) {
144
107
  return {
@@ -168,9 +131,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
168
131
  },
169
132
  };
170
133
  }
171
- // 2. lower.
172
134
  const facts = (0, lower_ts_1.lower)(tx);
173
- // 3. decideScope.
174
135
  const scopeRes = (0, scope_ts_1.decideScope)(facts, {
175
136
  network: opts.network,
176
137
  ...(opts.userResponses?.validUntilLedger !== undefined
@@ -200,11 +161,8 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
200
161
  };
201
162
  const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
202
163
  // --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.
164
+ // self-verify verdict (built from the SAME runHarness + evaluate that gated
165
+ // the synthesis, not a parallel simulation).
208
166
  let explain = null;
209
167
  let explainSim = null;
210
168
  // 5. OZ compile (always runs).
@@ -227,6 +185,10 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
227
185
  const interpreterOpts = opts.interpreter;
228
186
  let interpreterPolicyDocument = null;
229
187
  let interpreterPolicyRef = null;
188
+ // Cross-layer L3: declared OUTSIDE the `if (interpreterOpts)` block so the
189
+ // warnings folded into `proposed.warnings[]` (which lives after that block)
190
+ // can read it. The block assigns it; the default is empty.
191
+ let permitCtxWarnings = [];
230
192
  if (interpreterOpts) {
231
193
  const interpreterConfig = {
232
194
  network: opts.network,
@@ -235,8 +197,17 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
235
197
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
236
198
  };
237
199
  let startingPredicate = null;
200
+ // `__testPredicateNode` is a test-only seam. It is NOT in the public
201
+ // `InterpreterAdapterOptions` type, so a production caller cannot set it
202
+ // without bypassing the type system. We read it via a private cast and
203
+ // enforce a runtime NODE_ENV check so a misconfigured production caller
204
+ // that smuggles it in (any-cast, JSON-driven opt, etc.) is caught here
205
+ // rather than silently overriding the compiled predicate.
238
206
  const testSeam = interpreterOpts.__testPredicateNode;
239
207
  if (testSeam !== undefined) {
208
+ if (process.env.NODE_ENV !== 'test') {
209
+ throw new Error('synthesizeFromRecording: __testPredicateNode is a test-only seam and is refused outside NODE_ENV=test');
210
+ }
240
211
  startingPredicate = testSeam;
241
212
  }
242
213
  else {
@@ -353,7 +324,12 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
353
324
  },
354
325
  };
355
326
  }
356
- const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
327
+ // Cross-layer L3: warnings collected from `buildPermitContext` (currently
328
+ // only the oracle-on-right normaliser) and folded into the proposed
329
+ // policy's `warnings[]` so the caller sees them on the success envelope.
330
+ const permitCtxResult = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
331
+ const permitCtx = permitCtxResult.ctx;
332
+ permitCtxWarnings = permitCtxResult.warnings;
357
333
  const finalPredicate = startingPredicate.op === 'and'
358
334
  ? (0, minimize_ts_1.minimize)(startingPredicate, permitCtx, deny_cases_ts_1.ORIGINAL_DIMENSIONS)
359
335
  : startingPredicate;
@@ -384,12 +360,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
384
360
  },
385
361
  };
386
362
  }
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.
363
+ // --explain capture: quote the SAME verdict that gated the synthesis.
364
+ // Re-evaluate each deny case to surface its concrete reason (the harness
365
+ // only records whether the got-matches-expected boundary held).
393
366
  if (opts.explain) {
394
367
  explain = finalPredicate;
395
368
  const evaluatedCases = [
@@ -415,10 +388,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
415
388
  };
416
389
  }
417
390
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
418
- // canonical bytes back onto the PolicyDocument + PolicyRef. The
419
- // `encodePredicate` helper throws ToolError-shaped errors on cap
420
- // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
421
- // envelope converts them to a structured `{ok:false, error}`.
391
+ // canonical bytes back onto the PolicyDocument + PolicyRef. Cap breaches
392
+ // (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) throw ToolError-shaped errors;
393
+ // the outer envelope converts them to structured `{ok:false, error}`.
422
394
  const { encodedPredicate, predicateHash } = (0, encode_ts_1.encodePredicate)(finalPredicate);
423
395
  if (testSeam !== undefined) {
424
396
  interpreterPolicyDocument = {
@@ -491,9 +463,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
491
463
  // When nothing installable was synthesised (no interpreter doc AND no OZ
492
464
  // policy refs), an empty `policies` array reads as "no restrictions" rather
493
465
  // than "I synthesised nothing". Surface that explicitly so the empty result
494
- // is never mistaken for a permissive policy - the context rule still exists,
495
- // but it constrains nothing. (Kept as `{ok:true}` so the documented
496
- // Path-A/Path-B demo behaviour is preserved - see F3.)
466
+ // is never mistaken for a permissive policy.
497
467
  const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
498
468
  ? [
499
469
  '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.',
@@ -508,16 +478,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
508
478
  ...zeroPolicyWarning,
509
479
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
510
480
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
481
+ ...permitCtxWarnings,
511
482
  ],
512
483
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
513
484
  };
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.
485
+ // --explain success envelope. When opts.explain is set and the OZ-only
486
+ // path ran, construct the minimal honest SimulationResult: the verdict
487
+ // is NOT a passing simulation - the interpreter was never engaged, so
488
+ // permit is deny with a truthful reason and evaluatedCases is empty.
521
489
  const envelope = { ok: true, data: proposed };
522
490
  if (opts.explain) {
523
491
  if (explainSim) {
@@ -540,21 +508,20 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
540
508
  }
541
509
  return envelope;
542
510
  }
543
- /** OZ-side `uncovered` warning patterns that the interpreter adapter
544
- * actually lowers when wired in. When the interpreter adapter succeeds, we
545
- * drop matching entries from the OZ uncovered list so the user-facing
546
- * warnings reflect what is still UN-enforced rather than what OZ alone
547
- * could not do. Match the exact descriptor strings the OZ adapter emits
548
- * (see `src/adapters/oz/adapter.ts#describeCondition` /
549
- * `describeSelector`). */
511
+ /** OZ-side `uncovered` warning patterns the interpreter adapter actually lowers
512
+ * when wired in. When the interpreter adapter succeeds, matching entries are
513
+ * dropped from the OZ uncovered list so user-facing warnings reflect what is
514
+ * still UN-enforced rather than what OZ alone could not do. Matches the
515
+ * exact descriptor strings the OZ adapter emits (see `src/adapters/oz/adapter.ts`
516
+ * `describeCondition` / `describeSelector`). */
550
517
  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/;
551
518
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
552
519
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
553
- * positive i128 decimal string; network mainnet|testnet. When the caller
554
- * opts into the interpreter adapter, `smartAccountAddress` must be a C...
555
- * contract address (the on-chain policy-bound account, NOT the G... source
556
- * account from the recording), `installNonce` must be a positive integer
557
- * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
520
+ * positive i128 decimal string; network mainnet|testnet. When the interpreter
521
+ * adapter is opted in, `smartAccountAddress` must be a C... contract address
522
+ * (the on-chain policy-bound account, NOT the G... source account),
523
+ * `installNonce` must fit u32 (default 1), and `oracleParams` must
524
+ * tighten-only vs the wasm defaults. */
558
525
  function validateOptions(opts) {
559
526
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
560
527
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
@@ -586,13 +553,9 @@ function validateOptions(opts) {
586
553
  if (typeof sa !== 'string' || sa.length === 0) {
587
554
  return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
588
555
  }
589
- // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
590
- // C.../56-char shape check so a fixture/LLM-seam marker is reported
591
- // with the specific placeholder error (not a generic "must be a
592
- // C... contract" message). A real install routes a C... contract
593
- // address derivable from the on-chain account; any
594
- // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
595
- // fixture marker that must never reach the install payload.
556
+ // Block placeholder/stub prefixes BEFORE the C.../56-char shape check so
557
+ // a fixture/LLM-seam marker is reported with the specific placeholder
558
+ // error. 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' must never reach install.
596
559
  if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
597
560
  return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
598
561
  }
@@ -600,9 +563,7 @@ function validateOptions(opts) {
600
563
  return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
601
564
  }
602
565
  const nonce = opts.interpreter.installNonce;
603
- // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
604
- // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
605
- // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
566
+ // installNonce must fit u32 (the on-chain per-rule nonce is a u32).
606
567
  if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > types_ts_1.SOROBAN_LIMITS.u32Max)) {
607
568
  return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${types_ts_1.SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
608
569
  }
@@ -622,7 +583,7 @@ function validateOptions(opts) {
622
583
  }
623
584
  return null;
624
585
  }
625
- /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
586
+ /** Placeholder/stub smart-account prefixes. Mirrors the
626
587
  * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
627
588
  * the interpreter-contract strkey; a real install must point at a C...
628
589
  * contract address derivable from the on-chain account, never a
@@ -630,16 +591,15 @@ function validateOptions(opts) {
630
591
  const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
631
592
  /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
632
593
  * cannot be represented on-chain, so reject it at the synthesis boundary
633
- * (fail-closed) instead of passing it through as an over-broad spending_limit.
634
- * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
594
+ * (fail-closed) instead of passing it through as an over-broad spending_limit. */
635
595
  const I128_MAX = 2n ** 127n - 1n;
636
596
  function isPositiveInt(n) {
637
597
  return Number.isInteger(n) && n > 0;
638
598
  }
639
- /** True when `s` is a canonical positive decimal integer inside the signed-i128
640
- * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
641
- * it cannot be installed on-chain, and accepting it would emit a spending_limit
642
- * with an effectively unbounded cap. */
599
+ /** True when `s` is a canonical positive decimal integer inside [1, 2^127-1].
600
+ * Values above the i128 ceiling are rejected fail-closed: they cannot be
601
+ * installed on-chain, and accepting them would emit a spending_limit with an
602
+ * effectively unbounded cap. */
643
603
  function isPositiveI128(s) {
644
604
  if (!/^[0-9]+$/.test(s))
645
605
  return false;
@@ -673,20 +633,17 @@ function mergeAmbiguities(...lists) {
673
633
  }
674
634
  return out;
675
635
  }
676
- /** Build the permit `EvalContext` the self-verify harness drives. The shape
677
- * mirrors the intended recorded call (the only call the user actually
678
- * performed) so:
679
- * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
680
- * permits the recorded call it was derived from); a failure surfaces
681
- * as DENY_CASE_FAILURE.
636
+ /** Build the permit `EvalContext` the self-verify harness drives. Shape
637
+ * mirrors the intended recorded call so:
638
+ * - `evaluate(predicate, ctx).permit === true` must hold (a failure
639
+ * surfaces as DENY_CASE_FAILURE).
682
640
  * - `generateCases(predicate, ctx)` produces a deny battery that reflects
683
- * the actual recorded move (real amount, real args, real window start).
684
- * Amounts are summed per-token over all movements of the recorded tx
685
- * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
686
- * contains a price+timestamp satisfying each `oracle_price` leaf in the
687
- * predicate so the intended call permits under every bound; the harness
688
- * then mutates those entries (stale / missing / deviation / paused) to
689
- * exercise the ORACLE_* deny paths. */
641
+ * the actual recorded move (real amount, args, window start).
642
+ * Amounts are summed per-token over all movements (BigInt, never lossy).
643
+ * `oraclePriceByAsset` contains a price+timestamp satisfying each
644
+ * `oracle_price` leaf so the intended call permits under every bound;
645
+ * the harness mutates those entries (stale / missing / deviation / paused)
646
+ * to exercise the ORACLE_* deny paths. */
690
647
  function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
691
648
  const amountByToken = {};
692
649
  const totals = new Map();
@@ -697,7 +654,8 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
697
654
  for (const [token, total] of totals) {
698
655
  amountByToken[token] = total.toString();
699
656
  }
700
- const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt);
657
+ const warnings = [];
658
+ const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt, warnings);
701
659
  const ctx = {
702
660
  contract: scope.contract,
703
661
  fn: topLevel.fn,
@@ -712,16 +670,14 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
712
670
  if (userResponses?.validUntilLedger !== undefined) {
713
671
  ctx.validUntilLedger = userResponses.validUntilLedger;
714
672
  }
715
- return ctx;
673
+ return { ctx, warnings };
716
674
  }
717
675
  function cloneScVal(value, depth = 0) {
718
- // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
719
- // (decoded by the recorder). We clone top-level shells so the harness can
720
- // mutate deny cases without aliasing the recorded call. Recursion is
721
- // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
722
- // cannot RangeError the JS stack; the over-depth branch throws a
723
- // ToolError-shaped error that the `synthesizeFromRecording` envelope
724
- // (item 3) converts to `{ok:false, error}`.
676
+ // Clone top-level shells so the harness can mutate deny cases without
677
+ // aliasing the recorded call. Recursion bounded by MAX_SCVAL_CLONE_DEPTH so
678
+ // a hand-crafted nested-vec cannot RangeError the JS stack; the over-depth
679
+ // branch throws a ToolError-shaped error the envelope converts to
680
+ // `{ok:false, error}`.
725
681
  if (value.type === 'vec') {
726
682
  if (depth >= types_ts_1.MAX_SCVAL_CLONE_DEPTH) {
727
683
  throw cloneDepthError(value);
@@ -733,25 +689,19 @@ function cloneScVal(value, depth = 0) {
733
689
  }
734
690
  return { ...value };
735
691
  }
736
- /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
737
- * The shape (object with string `code`) is what the `synthesizeFromRecording`
738
- * envelope detects and converts to a structured `{ok:false, error}`. */
739
692
  function cloneDepthError(value) {
740
693
  const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${types_ts_1.MAX_SCVAL_CLONE_DEPTH})`);
741
694
  err.code = 'SYNTHESIS_ERROR';
742
695
  err.severity = 'error';
743
696
  err.retryable = false;
744
- // Tag the offending value so failures are diagnosable.
745
697
  err.depthContext = value.type;
746
698
  throw err;
747
699
  }
748
- /** Walk every `oracle_price` leaf in the predicate and return a price map
749
- * whose entries satisfy the bound so the intended call permits. The
750
- * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
751
- * fresh-oracle deny case in `generateCases` is the only path that flips
752
- * this map. Negatives are clamped at 0 - oracle prices are non-negative on
753
- * Stellar. */
754
- function oracleSatisfyingPrices(predicate, nowSeconds) {
700
+ /** Walk every `oracle_price` leaf and return a price map whose entries satisfy
701
+ * the bound. Timestamp pinned to `nowSeconds` (the recorded `fetchedAt`) so
702
+ * the fresh-oracle deny case in `generateCases` is the only path that flips
703
+ * this map. Negatives clamped at 0 - oracle prices are non-negative on Stellar. */
704
+ function oracleSatisfyingPrices(predicate, nowSeconds, warnings) {
755
705
  const out = {};
756
706
  visitOracleLeaves(predicate, (asset, op, bound) => {
757
707
  let price;
@@ -769,18 +719,25 @@ function oracleSatisfyingPrices(predicate, nowSeconds) {
769
719
  if (price < 0n)
770
720
  price = 0n;
771
721
  out[asset] = { price: price.toString(), timestampSeconds: nowSeconds };
722
+ }, (warning) => {
723
+ // Cross-layer L3: oracle-on-right that cannot be normalised is
724
+ // surfaced as a warning, not silently dropped. The Rust interpreter
725
+ // would surface this as `UnsupportedNode`; the warning lets the
726
+ // caller decide whether to fix the predicate shape or accept the
727
+ // over-permissive hole.
728
+ warnings.push(warning.message);
772
729
  });
773
730
  return out;
774
731
  }
775
- function visitOracleLeaves(node, visit) {
732
+ function visitOracleLeaves(node, visit, onWarning) {
776
733
  switch (node.op) {
777
734
  case 'and':
778
735
  case 'or':
779
736
  for (const child of node.children)
780
- visitOracleLeaves(child, visit);
737
+ visitOracleLeaves(child, visit, onWarning);
781
738
  return;
782
739
  case 'not':
783
- visitOracleLeaves(node.child, visit);
740
+ visitOracleLeaves(node.child, visit, onWarning);
784
741
  return;
785
742
  case 'eq':
786
743
  case 'lt':
@@ -801,8 +758,27 @@ function visitOracleLeaves(node, visit) {
801
758
  oracleAsset = rightLeaf.asset;
802
759
  literal = oracleLiteralFromLeaf(leftLeaf);
803
760
  }
804
- if (oracleAsset === undefined || literal === undefined)
761
+ if (oracleAsset === undefined || literal === undefined) {
762
+ // Two reasons the case cannot be normalised:
763
+ // - neither side is an oracle leaf (visitor has nothing to do)
764
+ // - oracle-on-right with a non-literal LHS (Rust dispatch would
765
+ // hit UnsupportedNode; the TS model cannot build a permit ctx
766
+ // for it)
767
+ // Only the second is a meaningful warning; the first is a no-op
768
+ // (the visitor was called for a non-oracle comparison). Emit the
769
+ // warning when an oracle IS present on one side but the other
770
+ // side is not a parseable threshold literal.
771
+ if (rightIsOracle) {
772
+ onWarning?.({
773
+ dimension: 'oracle_normalisation_dropped',
774
+ op: node.op,
775
+ asset: rightLeaf.kind === 'oracle_price' ? rightLeaf.asset : '',
776
+ otherKind: leftLeaf.kind,
777
+ 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`,
778
+ });
779
+ }
805
780
  return;
781
+ }
806
782
  visit(oracleAsset, node.op, literal);
807
783
  return;
808
784
  }
@@ -812,9 +788,9 @@ function visitOracleLeaves(node, visit) {
812
788
  return;
813
789
  }
814
790
  }
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
791
+ /** Restate an oracle threshold on the normalised 9-dp basis prices use.
792
+ * Thresholds carry their own basis, so reading the digits raw would build
793
+ * a permit context off by 10^(decimals-9) and the intended call would not
818
794
  * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
819
795
  function oracleLiteralFromLeaf(leaf) {
820
796
  if (leaf.kind !== 'oracle_threshold')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
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",