@crediolabs/policy-synth 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +61 -5
  18. package/dist/review-card/cross-check.js +50 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/index.d.ts +8 -2
  22. package/dist/run/index.js +2 -1
  23. package/dist/run/schemas.d.ts +16 -4
  24. package/dist/run/schemas.js +14 -0
  25. package/dist/synth/compose-from-recording.d.ts +4 -0
  26. package/dist/synth/compose-from-recording.js +34 -8
  27. package/dist/synth/deny-cases.d.ts +4 -1
  28. package/dist/synth/deny-cases.js +3 -1
  29. package/dist/synth/evaluate.js +130 -3
  30. package/dist/synth/permit-context.d.ts +15 -0
  31. package/dist/synth/permit-context.js +116 -0
  32. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist/synth/synthesize-from-mandate.js +27 -2
  34. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist/synth/synthesize-from-recording.js +80 -3
  36. package/dist/types.d.ts +9 -0
  37. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  38. package/dist-cjs/errors.d.ts +1 -1
  39. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  40. package/dist-cjs/install/build-add-context-rule.js +308 -0
  41. package/dist-cjs/ir/types.d.ts +4 -0
  42. package/dist-cjs/predicate/decode.d.ts +11 -0
  43. package/dist-cjs/predicate/decode.js +239 -0
  44. package/dist-cjs/predicate/encode.js +79 -14
  45. package/dist-cjs/predicate/from-json.d.ts +4 -0
  46. package/dist-cjs/predicate/from-json.js +116 -0
  47. package/dist-cjs/predicate/index.d.ts +2 -0
  48. package/dist-cjs/predicate/index.js +10 -2
  49. package/dist-cjs/registry/identify.js +20 -1
  50. package/dist-cjs/registry/protocols.d.ts +50 -1
  51. package/dist-cjs/registry/protocols.js +89 -1
  52. package/dist-cjs/review-card/builder.d.ts +13 -0
  53. package/dist-cjs/review-card/builder.js +62 -5
  54. package/dist-cjs/review-card/cross-check.js +50 -3
  55. package/dist-cjs/review-card/index.d.ts +1 -1
  56. package/dist-cjs/review-card/index.js +2 -1
  57. package/dist-cjs/run/index.d.ts +8 -2
  58. package/dist-cjs/run/index.js +2 -1
  59. package/dist-cjs/run/schemas.d.ts +16 -4
  60. package/dist-cjs/run/schemas.js +14 -0
  61. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  62. package/dist-cjs/synth/compose-from-recording.js +34 -8
  63. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  64. package/dist-cjs/synth/deny-cases.js +3 -0
  65. package/dist-cjs/synth/evaluate.js +130 -3
  66. package/dist-cjs/synth/permit-context.d.ts +15 -0
  67. package/dist-cjs/synth/permit-context.js +119 -0
  68. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  69. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  70. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  71. package/dist-cjs/synth/synthesize-from-recording.js +80 -3
  72. package/dist-cjs/types.d.ts +9 -0
  73. package/package.json +5 -2
  74. package/src/adapters/interpreter/adapter.ts +72 -11
  75. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  76. package/src/errors.ts +13 -0
  77. package/src/install/build-add-context-rule.ts +429 -0
  78. package/src/ir/types.ts +4 -0
  79. package/src/predicate/decode.ts +242 -0
  80. package/src/predicate/encode.ts +110 -13
  81. package/src/predicate/from-json.ts +124 -0
  82. package/src/predicate/index.ts +5 -1
  83. package/src/registry/identify.ts +22 -2
  84. package/src/registry/protocols.ts +90 -1
  85. package/src/review-card/builder.ts +57 -5
  86. package/src/review-card/cross-check.ts +50 -3
  87. package/src/review-card/index.ts +1 -0
  88. package/src/run/index.ts +16 -2
  89. package/src/run/schemas.ts +14 -0
  90. package/src/synth/compose-from-recording.ts +46 -7
  91. package/src/synth/deny-cases.ts +3 -1
  92. package/src/synth/evaluate.ts +131 -3
  93. package/src/synth/permit-context.ts +137 -0
  94. package/src/synth/synthesize-from-mandate.ts +40 -4
  95. package/src/synth/synthesize-from-recording.ts +116 -6
  96. package/src/types.ts +12 -0
@@ -52,6 +52,7 @@ import {
52
52
  type RecordedTransaction,
53
53
  SOROBAN_LIMITS,
54
54
  } from '../types.ts'
55
+ import type { SimulationResult } from '../verify/envelope.ts'
55
56
  import {
56
57
  type ComposeOptions,
57
58
  type ComposeUserResponses,
@@ -101,13 +102,36 @@ export interface SynthesizeFromRecordingOptions {
101
102
  userResponses?: ComposeUserResponses
102
103
  confidenceOverride?: { threshold: number }
103
104
  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. */
112
+ explain?: true
104
113
  }
105
114
 
106
115
  export function synthesizeFromRecording(
107
116
  tx: RecordedTransaction,
108
117
  opts: SynthesizeFromRecordingOptions,
109
118
  ozConfig: OzAdapterConfig
110
- ): ToolResponse<ProposedPolicy> {
119
+ ): ToolResponse<ProposedPolicy> & {
120
+ /** Present iff `opts.explain === true` and the synthesis succeeded. The
121
+ * `predicateTree` is the exact in-memory `PredicateNode` (canonical
122
+ * JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
123
+ * - reading the predicate tree from the policy document is therefore
124
+ * unnecessary; the orchestrator carries it through. The `simulation`
125
+ * is the verdict produced by the same self-verify pipeline that already
126
+ * runs in the recording path (runHarness + evaluate), so the value is
127
+ * REAL not synthetic. Absent when the synthesis did not engage the
128
+ * interpreter (no interpreter opts supplied) - in that case the CLI
129
+ * builds a minimal honest SimulationResult downstream. */
130
+ explain?: {
131
+ predicateTree: PredicateNode | null
132
+ simulation: SimulationResult
133
+ }
134
+ } {
111
135
  // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
112
136
  // with a string `.code`) inside the body is converted to a structured
113
137
  // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
@@ -159,7 +183,12 @@ function synthesizeFromRecordingInner(
159
183
  tx: RecordedTransaction,
160
184
  opts: SynthesizeFromRecordingOptions,
161
185
  ozConfig: OzAdapterConfig
162
- ): ToolResponse<ProposedPolicy> {
186
+ ): ToolResponse<ProposedPolicy> & {
187
+ explain?: {
188
+ predicateTree: PredicateNode | null
189
+ simulation: SimulationResult
190
+ }
191
+ } {
163
192
  // 0. validate inputs (fail closed - never synthesize from garbage).
164
193
  const invalid = validateOptions(opts)
165
194
  if (invalid) return { ok: false, error: invalid }
@@ -272,6 +301,15 @@ function synthesizeFromRecordingInner(
272
301
  }
273
302
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
274
303
 
304
+ // --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.
310
+ let explain: PredicateNode | null = null
311
+ let explainSim: SimulationResult | null = null
312
+
275
313
  // 5. OZ compile (always runs).
276
314
  const ozAdapter = createOzAdapter(ozConfig)
277
315
  const compileRes = ozAdapter.compile(composed.ir)
@@ -449,7 +487,6 @@ function synthesizeFromRecordingInner(
449
487
  },
450
488
  }
451
489
  }
452
-
453
490
  const evalResult = evaluate(finalPredicate, permitCtx)
454
491
  if (!evalResult.permit) {
455
492
  return {
@@ -464,6 +501,37 @@ function synthesizeFromRecordingInner(
464
501
  }
465
502
  }
466
503
 
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.
510
+ if (opts.explain) {
511
+ explain = finalPredicate
512
+ const evaluatedCases: SimulationResult['evaluatedCases'] = [
513
+ {
514
+ dimension: 'permit',
515
+ outcome: evalResult.permit ? 'permit' : 'deny',
516
+ reason: 'matches recorded call',
517
+ },
518
+ ]
519
+ for (const deny of harnessCases.denies) {
520
+ const r = evaluate(finalPredicate, deny.ctx)
521
+ evaluatedCases.push({
522
+ dimension: deny.dimension,
523
+ outcome: r.permit ? 'permit' : 'deny',
524
+ reason: r.permit ? 'no matching deny' : r.reason,
525
+ })
526
+ }
527
+ explainSim = {
528
+ permit: { tx: 'permit' },
529
+ evaluatedCases,
530
+ backend: 'ts-model',
531
+ simulatorVersion: 'ts-model-1.0.0',
532
+ }
533
+ }
534
+
467
535
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
468
536
  // canonical bytes back onto the PolicyDocument + PolicyRef. The
469
537
  // `encodePredicate` helper throws ToolError-shaped errors on cap
@@ -566,7 +634,38 @@ function synthesizeFromRecordingInner(
566
634
  ],
567
635
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
568
636
  }
569
- return { ok: true, data: proposed }
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.
644
+ const envelope: ToolResponse<ProposedPolicy> & {
645
+ explain?: {
646
+ predicateTree: PredicateNode | null
647
+ simulation: SimulationResult
648
+ }
649
+ } = { ok: true, data: proposed }
650
+ if (opts.explain) {
651
+ if (explainSim) {
652
+ envelope.explain = { predicateTree: explain, simulation: explainSim }
653
+ } else {
654
+ envelope.explain = {
655
+ predicateTree: null,
656
+ simulation: {
657
+ permit: {
658
+ tx: 'deny',
659
+ reason: 'No self-verification was performed (interpreter adapter was not engaged)',
660
+ },
661
+ evaluatedCases: [],
662
+ backend: 'ts-model',
663
+ simulatorVersion: 'not-run',
664
+ },
665
+ }
666
+ }
667
+ }
668
+ return envelope
570
669
  }
571
670
 
572
671
  // Re-export the throwToolError helper for callers that need to surface a
@@ -905,11 +1004,22 @@ function visitOracleLeaves(
905
1004
  }
906
1005
  }
907
1006
 
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
1010
+ * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
908
1011
  function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
909
- if (leaf.kind !== 'literal_i128') return undefined
1012
+ if (leaf.kind !== 'oracle_threshold') return undefined
1013
+ let value: bigint
910
1014
  try {
911
- return BigInt(leaf.value)
1015
+ value = BigInt(leaf.value)
912
1016
  } catch {
913
1017
  return undefined
914
1018
  }
1019
+ const normalised = 9n
1020
+ const decimals = BigInt(leaf.decimals)
1021
+ if (decimals <= normalised) return value * 10n ** (normalised - decimals)
1022
+ // Floor: a finer-grained threshold has no exact 9-dp representation; the
1023
+ // caller offsets by one from here to land on the right side of the bound.
1024
+ return value / 10n ** (decimals - normalised)
915
1025
  }
package/src/types.ts CHANGED
@@ -155,6 +155,13 @@ export type PredicateLeaf =
155
155
  // recorded ScVal type. A non-vec arg, missing element, missing field, or
156
156
  // type mismatch all DENY (fail-closed).
157
157
  | { kind: 'call_arg_field'; index: number; element: number; field: string }
158
+ // `call_arg_scaled(index, num, den)` evaluates to `args[index] * num / den`
159
+ // (truncating toward zero). The slippage-floor leaf: a swap policy
160
+ // expresses `call_arg[out] >= call_arg_scaled(in, num, den)` as "the
161
+ // minimum output must be at least this ratio of the input". `num`/`den`
162
+ // are decimal strings on i128 (mirrors `literal_i128`); the contract
163
+ // refuses `den == 0` and `num <= 0` / `den <= 0` at install.
164
+ | { kind: 'call_arg_scaled'; index: number; num: string; den: string }
158
165
  // `call_sub_invocation[i]` is NOT in v1 grammar. OZ `Policy::enforce`
159
166
  // receives one `Context`, not a sub-invocation tree; sub-invocations live under
160
167
  // `InvokerContractAuthEntry::Contract(SubContractInvocation)`, not in the
@@ -166,6 +173,11 @@ export type PredicateLeaf =
166
173
  | { kind: 'valid_until' }
167
174
  | { kind: 'invocation_count_in_window'; windowSecs: number }
168
175
  | { kind: 'oracle_price'; asset: string } // Stellar Address (SAC address for SEP-41; XLM uses its network SAC)
176
+ // The right-hand side of an oracle comparison. It carries its own decimal
177
+ // basis because the contract cannot recover it: prices normalise to 9 dp, so
178
+ // a raw 14-dp threshold is ~10^5 too large and `price <= threshold` becomes
179
+ // trivially true. `value` is a decimal string on i128, like `literal_i128`.
180
+ | { kind: 'oracle_threshold'; value: string; decimals: number }
169
181
  // Literal leaves: bare ScVal on the wire (no selector-tuple wrapper). Right-hand side of
170
182
  // comparisons, elements of `in` haystacks, and operands to future arithmetic nodes.
171
183
  | { kind: 'literal_address'; value: string }