@crediolabs/policy-synth 0.1.3 → 0.1.5

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -5
  3. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  4. package/dist/adapters/interpreter/adapter.js +522 -0
  5. package/dist/adapters/interpreter/index.d.ts +1 -0
  6. package/dist/adapters/interpreter/index.js +2 -0
  7. package/dist/adapters/oz/adapter.js +2 -0
  8. package/dist/codegen/compile-gate.d.ts +33 -0
  9. package/dist/codegen/compile-gate.js +119 -0
  10. package/dist/codegen/index.d.ts +2 -0
  11. package/dist/codegen/index.js +8 -0
  12. package/dist/codegen/template.d.ts +18 -0
  13. package/dist/codegen/template.js +131 -0
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/ir/types.d.ts +13 -2
  18. package/dist/predicate/encode.d.ts +10 -0
  19. package/dist/predicate/encode.js +249 -0
  20. package/dist/predicate/index.d.ts +1 -0
  21. package/dist/predicate/index.js +2 -0
  22. package/dist/record/decode.d.ts +6 -3
  23. package/dist/record/decode.js +13 -3
  24. package/dist/review-card/builder.d.ts +14 -0
  25. package/dist/review-card/builder.js +261 -0
  26. package/dist/review-card/conflict.d.ts +40 -0
  27. package/dist/review-card/conflict.js +111 -0
  28. package/dist/review-card/cross-check.d.ts +11 -0
  29. package/dist/review-card/cross-check.js +148 -0
  30. package/dist/review-card/index.d.ts +3 -0
  31. package/dist/review-card/index.js +4 -0
  32. package/dist/run/index.d.ts +31 -0
  33. package/dist/run/index.js +164 -0
  34. package/dist/run/schemas.d.ts +2003 -0
  35. package/dist/run/schemas.js +215 -0
  36. package/dist/synth/compose-from-recording.d.ts +35 -7
  37. package/dist/synth/compose-from-recording.js +225 -34
  38. package/dist/synth/deny-cases.d.ts +12 -0
  39. package/dist/synth/deny-cases.js +377 -0
  40. package/dist/synth/evaluate.d.ts +39 -0
  41. package/dist/synth/evaluate.js +425 -0
  42. package/dist/synth/harness.d.ts +16 -0
  43. package/dist/synth/harness.js +26 -0
  44. package/dist/synth/index.d.ts +4 -0
  45. package/dist/synth/index.js +4 -0
  46. package/dist/synth/minimize.d.ts +4 -0
  47. package/dist/synth/minimize.js +38 -0
  48. package/dist/synth/predicate-literals.d.ts +5 -0
  49. package/dist/synth/predicate-literals.js +25 -0
  50. package/dist/synth/synthesize-from-recording.d.ts +37 -5
  51. package/dist/synth/synthesize-from-recording.js +550 -18
  52. package/dist/types.d.ts +40 -1
  53. package/dist/types.js +17 -0
  54. package/dist/verify/envelope.d.ts +15 -0
  55. package/dist/verify/envelope.js +22 -0
  56. package/dist/verify/index.d.ts +3 -0
  57. package/dist/verify/index.js +3 -0
  58. package/dist/verify/simulate.d.ts +31 -0
  59. package/dist/verify/simulate.js +258 -0
  60. package/dist/verify/verify.d.ts +21 -0
  61. package/dist/verify/verify.js +189 -0
  62. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  63. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  64. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  65. package/dist-cjs/adapters/interpreter/index.js +8 -0
  66. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  67. package/dist-cjs/adapters/oz/adapter.js +289 -0
  68. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  69. package/dist-cjs/adapters/oz/index.js +8 -0
  70. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  71. package/dist-cjs/codegen/compile-gate.js +123 -0
  72. package/dist-cjs/codegen/index.d.ts +2 -0
  73. package/dist-cjs/codegen/index.js +14 -0
  74. package/dist-cjs/codegen/template.d.ts +18 -0
  75. package/dist-cjs/codegen/template.js +134 -0
  76. package/dist-cjs/errors.d.ts +37 -0
  77. package/dist-cjs/errors.js +3 -0
  78. package/dist-cjs/index.d.ts +11 -0
  79. package/dist-cjs/index.js +27 -0
  80. package/dist-cjs/ir/index.d.ts +1 -0
  81. package/dist-cjs/ir/index.js +3 -0
  82. package/dist-cjs/ir/types.d.ts +108 -0
  83. package/dist-cjs/ir/types.js +12 -0
  84. package/dist-cjs/mandate/index.d.ts +2 -0
  85. package/dist-cjs/mandate/index.js +6 -0
  86. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  87. package/dist-cjs/mandate/to-ir.js +63 -0
  88. package/dist-cjs/mandate/types.d.ts +20 -0
  89. package/dist-cjs/mandate/types.js +9 -0
  90. package/dist-cjs/package.json +3 -0
  91. package/dist-cjs/predicate/encode.d.ts +10 -0
  92. package/dist-cjs/predicate/encode.js +252 -0
  93. package/dist-cjs/predicate/index.d.ts +1 -0
  94. package/dist-cjs/predicate/index.js +6 -0
  95. package/dist-cjs/record/decode.d.ts +79 -0
  96. package/dist-cjs/record/decode.js +398 -0
  97. package/dist-cjs/record/freshness.d.ts +17 -0
  98. package/dist-cjs/record/freshness.js +55 -0
  99. package/dist-cjs/record/index.d.ts +21 -0
  100. package/dist-cjs/record/index.js +166 -0
  101. package/dist-cjs/record/movements.d.ts +20 -0
  102. package/dist-cjs/record/movements.js +192 -0
  103. package/dist-cjs/record/rpc.d.ts +22 -0
  104. package/dist-cjs/record/rpc.js +74 -0
  105. package/dist-cjs/record/validate.d.ts +22 -0
  106. package/dist-cjs/record/validate.js +63 -0
  107. package/dist-cjs/registry/identify.d.ts +11 -0
  108. package/dist-cjs/registry/identify.js +87 -0
  109. package/dist-cjs/registry/index.d.ts +3 -0
  110. package/dist-cjs/registry/index.js +15 -0
  111. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  112. package/dist-cjs/registry/known-addresses.js +53 -0
  113. package/dist-cjs/registry/protocols.d.ts +38 -0
  114. package/dist-cjs/registry/protocols.js +153 -0
  115. package/dist-cjs/review-card/builder.d.ts +14 -0
  116. package/dist-cjs/review-card/builder.js +264 -0
  117. package/dist-cjs/review-card/conflict.d.ts +40 -0
  118. package/dist-cjs/review-card/conflict.js +114 -0
  119. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  120. package/dist-cjs/review-card/cross-check.js +151 -0
  121. package/dist-cjs/review-card/index.d.ts +3 -0
  122. package/dist-cjs/review-card/index.js +10 -0
  123. package/dist-cjs/run/index.d.ts +31 -0
  124. package/dist-cjs/run/index.js +178 -0
  125. package/dist-cjs/run/schemas.d.ts +2003 -0
  126. package/dist-cjs/run/schemas.js +218 -0
  127. package/dist-cjs/seams/index.d.ts +1 -0
  128. package/dist-cjs/seams/index.js +3 -0
  129. package/dist-cjs/seams/types.d.ts +66 -0
  130. package/dist-cjs/seams/types.js +12 -0
  131. package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
  132. package/dist-cjs/synth/compose-from-recording.js +356 -0
  133. package/dist-cjs/synth/deny-cases.d.ts +12 -0
  134. package/dist-cjs/synth/deny-cases.js +380 -0
  135. package/dist-cjs/synth/evaluate.d.ts +39 -0
  136. package/dist-cjs/synth/evaluate.js +428 -0
  137. package/dist-cjs/synth/harness.d.ts +16 -0
  138. package/dist-cjs/synth/harness.js +29 -0
  139. package/dist-cjs/synth/index.d.ts +9 -0
  140. package/dist-cjs/synth/index.js +23 -0
  141. package/dist-cjs/synth/lower.d.ts +23 -0
  142. package/dist-cjs/synth/lower.js +119 -0
  143. package/dist-cjs/synth/minimize.d.ts +4 -0
  144. package/dist-cjs/synth/minimize.js +41 -0
  145. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  146. package/dist-cjs/synth/predicate-literals.js +28 -0
  147. package/dist-cjs/synth/scope.d.ts +26 -0
  148. package/dist-cjs/synth/scope.js +81 -0
  149. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  150. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  151. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  152. package/dist-cjs/synth/synthesize-from-recording.js +711 -0
  153. package/dist-cjs/types.d.ts +288 -0
  154. package/dist-cjs/types.js +55 -0
  155. package/dist-cjs/verify/envelope.d.ts +15 -0
  156. package/dist-cjs/verify/envelope.js +23 -0
  157. package/dist-cjs/verify/index.d.ts +3 -0
  158. package/dist-cjs/verify/index.js +8 -0
  159. package/dist-cjs/verify/simulate.d.ts +31 -0
  160. package/dist-cjs/verify/simulate.js +261 -0
  161. package/dist-cjs/verify/verify.d.ts +21 -0
  162. package/dist-cjs/verify/verify.js +192 -0
  163. package/package.json +43 -3
  164. package/src/adapters/interpreter/adapter.ts +642 -0
  165. package/src/adapters/interpreter/index.ts +8 -0
  166. package/src/adapters/oz/adapter.ts +2 -0
  167. package/src/codegen/compile-gate.ts +162 -0
  168. package/src/codegen/index.ts +17 -0
  169. package/src/codegen/template.ts +148 -0
  170. package/src/errors.ts +2 -0
  171. package/src/index.ts +2 -0
  172. package/src/ir/types.ts +9 -2
  173. package/src/predicate/encode.ts +307 -0
  174. package/src/predicate/index.ts +3 -0
  175. package/src/record/decode.ts +21 -10
  176. package/src/review-card/builder.ts +303 -0
  177. package/src/review-card/conflict.ts +143 -0
  178. package/src/review-card/cross-check.ts +158 -0
  179. package/src/review-card/index.ts +12 -0
  180. package/src/run/index.ts +213 -0
  181. package/src/run/schemas.ts +242 -0
  182. package/src/synth/compose-from-recording.ts +277 -43
  183. package/src/synth/deny-cases.ts +466 -0
  184. package/src/synth/evaluate.ts +493 -0
  185. package/src/synth/harness.ts +40 -0
  186. package/src/synth/index.ts +12 -0
  187. package/src/synth/minimize.ts +44 -0
  188. package/src/synth/predicate-literals.ts +27 -0
  189. package/src/synth/synthesize-from-recording.ts +655 -19
  190. package/src/types.ts +38 -2
  191. package/src/verify/envelope.ts +28 -0
  192. package/src/verify/index.ts +5 -0
  193. package/src/verify/simulate.ts +311 -0
  194. package/src/verify/verify.ts +243 -0
@@ -17,6 +17,7 @@
17
17
  // No network calls. No randomness. No globals.
18
18
  import { Address, StrKey, xdr } from '@stellar/stellar-sdk';
19
19
  import { identifyProtocol } from "../registry/identify.js";
20
+ import { MAX_SCVAL_DEPTH, } from "../types.js";
20
21
  /** Decode a TransactionEnvelope XDR (base64) into a fully parsed invocation tree. */
21
22
  export function decodeEnvelopeXdr(envelopeXdrB64, events = [], _authEntries = [], ledgerSequence = 0, knownContracts = new Set(), network = null) {
22
23
  const envelope = xdr.TransactionEnvelope.fromXDR(envelopeXdrB64, 'base64');
@@ -226,8 +227,11 @@ function symbolValueFromBufferOrString(s) {
226
227
  /** Map an arbitrary ScVal into the normalised `ScVal` subset defined in
227
228
  * types.ts. Anything outside the subset is wrapped as `{type:'other', value}`
228
229
  * AND recorded in `opaqueScVals` so the freshness module can reduce
229
- * parseConfidence. */
230
- export function scValToSubset(val, path, opaqueScVals) {
230
+ * parseConfidence. Recursion is bounded by `MAX_SCVAL_DEPTH` so a
231
+ * hand-crafted nested-vec payload cannot RangeError the JS stack; the
232
+ * over-depth branch is collapsed to a single opaque `depth-exceeded` token
233
+ * (the recorder's parseConfidence gate then fails the recording closed). */
234
+ export function scValToSubset(val, path, opaqueScVals, depth = 0) {
231
235
  const kind = val.switch().name;
232
236
  switch (kind) {
233
237
  case 'scvAddress': {
@@ -252,10 +256,16 @@ export function scValToSubset(val, path, opaqueScVals) {
252
256
  return { type: 'symbol', value: val.sym().toString() };
253
257
  }
254
258
  case 'scvVec': {
259
+ if (depth >= MAX_SCVAL_DEPTH) {
260
+ // Over-depth branch: collapse to a single opaque terminal so the
261
+ // downstream parseConfidence gate sees the fail-closed freshness hit.
262
+ opaqueScVals.push({ path, type: 'depth-exceeded' });
263
+ return { type: 'other', value: 'depth-exceeded' };
264
+ }
255
265
  const arr = (val.vec() ?? []);
256
266
  return {
257
267
  type: 'vec',
258
- value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals)),
268
+ value: arr.map((v, i) => scValToSubset(v, `${path}[${i}]`, opaqueScVals, depth + 1)),
259
269
  };
260
270
  }
261
271
  case 'scvBytes': {
@@ -0,0 +1,14 @@
1
+ import type { ContextRuleDraft, PolicyRef, PredicateNode } from '../types.ts';
2
+ import type { SimulationResult } from '../verify/envelope.ts';
3
+ export interface ReviewCardSummary {
4
+ ruleName: string;
5
+ plainEnglish: string;
6
+ constraints: string[];
7
+ expiry: string;
8
+ backend: 'interpreter-v1' | 'ts-model';
9
+ /** Stable hash of the builder inputs - identical policy + summary = identical hash. */
10
+ contentHash: string;
11
+ }
12
+ /** Build a deterministic review-card summary from a policy + context rule +
13
+ * simulation result. Pure: same inputs -> byte-identical output. */
14
+ export declare function buildReviewCardSummary(predicate: PredicateNode | null, policyRefs: PolicyRef[], contextRule: ContextRuleDraft, simulation: SimulationResult): ReviewCardSummary;
@@ -0,0 +1,261 @@
1
+ // src/review-card/builder.ts - deterministic, pure review-card summary
2
+ // builder.
3
+ //
4
+ // `buildReviewCardSummary` renders the per-policy text the user-facing review
5
+ // card quotes. The whole DX win of non-engineer reviewability hinges on the
6
+ // summary being REPRODUCIBLE + TESTABLE + NON-HALLUCINABLE, so this module is
7
+ // pure: same inputs -> byte-identical output, no clock, no randomness, no I/O.
8
+ //
9
+ // The builder walks two inputs and emits ONE constraint string per leaf or
10
+ // primitive, in a fixed deterministic order:
11
+ //
12
+ // 1. The OZ built-in `PolicyRef`s. Each `spending_limit` primitive becomes a
13
+ // `spending_limit(token, limitAmount, windowSecs)` line; other OZ
14
+ // primitives (threshold) are skipped - the review card does not quote
15
+ // them (they are signer-config concerns, not transactional bounds).
16
+ //
17
+ // 2. The interpreter `PredicateNode`. One string per constraint leaf,
18
+ // rendered by enclosing-comparison kind. Templates (Task 7b):
19
+ // - invocation_count_in_window <= N -> Invocations <= N per <window> seconds
20
+ // - call_arg[i] in [list] -> Recipient/arg must be one of [list]
21
+ // - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
22
+ // - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
23
+ // - call_fn == x -> Function must be x
24
+ // - call_contract == c -> Contract must be c
25
+ // - amount <= v -> Amount <= v
26
+ //
27
+ // The content hash is a stable sha256 hex of a canonical JSON of
28
+ // { ruleName, plainEnglish, constraints, expiry, backend } - identical
29
+ // inputs (incl. the context-rule expiry and simulation backend) -> identical
30
+ // hash. There is no clock; the hash never includes a timestamp.
31
+ import { createHash } from 'node:crypto';
32
+ /** Build a deterministic review-card summary from a policy + context rule +
33
+ * simulation result. Pure: same inputs -> byte-identical output. */
34
+ export function buildReviewCardSummary(predicate, policyRefs, contextRule, simulation) {
35
+ const constraints = [];
36
+ for (const ref of policyRefs) {
37
+ const line = renderOzPrimitive(ref);
38
+ if (line !== null)
39
+ constraints.push(line);
40
+ }
41
+ if (predicate !== null) {
42
+ walkPredicate(predicate, (node) => {
43
+ const line = renderConstraint(node);
44
+ if (line !== null)
45
+ constraints.push(line);
46
+ });
47
+ }
48
+ const ruleName = contextRule.name;
49
+ const plainEnglish = renderPlainEnglish(ruleName, constraints);
50
+ const expiry = renderExpiry(contextRule.validUntilLedger);
51
+ const backend = simulation.backend;
52
+ const contentHash = computeContentHash({
53
+ ruleName,
54
+ plainEnglish,
55
+ constraints,
56
+ expiry,
57
+ backend,
58
+ });
59
+ return { ruleName, plainEnglish, constraints, expiry, backend, contentHash };
60
+ }
61
+ /** Render the OZ built-in primitive summary line. Only `spending_limit` is
62
+ * quoted by the review card (it is the only primitive that defines a
63
+ * transactional bound). Other primitives (threshold) are signer-config
64
+ * concerns handled by the OZ adapter's own `uncovered` machinery.
65
+ * Spending_limit takes `period_ledgers` on-chain (~5s/ledger); the card
66
+ * states the window in seconds so the user reads it consistently with the
67
+ * interpreter templates. */
68
+ function renderOzPrimitive(ref) {
69
+ if (ref.kind !== 'oz_builtin')
70
+ return null;
71
+ const primitive = ref.primitive;
72
+ if (primitive.primitive !== 'spending_limit')
73
+ return null;
74
+ const params = primitive.params;
75
+ const limit = params.spending_limit ?? '0';
76
+ const periodLedgers = params.period_ledgers ?? 0;
77
+ const windowSecs = periodLedgers * 5;
78
+ return `spending_limit(${limit}, ${windowSecs})`;
79
+ }
80
+ /** Walk every comparison / membership node of the predicate and invoke
81
+ * `visit` on each. The walk is depth-first, left-to-right, so the
82
+ * constraint list is stable across runs. Pure boolean nodes contribute no
83
+ * constraint lines themselves; their leaf children do, via the visitor. */
84
+ function walkPredicate(node, visit) {
85
+ switch (node.op) {
86
+ case 'and':
87
+ case 'or':
88
+ for (const child of node.children)
89
+ walkPredicate(child, visit);
90
+ return;
91
+ case 'not':
92
+ walkPredicate(node.child, visit);
93
+ return;
94
+ case 'in':
95
+ visit(node);
96
+ return;
97
+ case 'eq':
98
+ case 'lt':
99
+ case 'lte':
100
+ case 'gt':
101
+ case 'gte':
102
+ visit(node);
103
+ return;
104
+ }
105
+ }
106
+ /** Render ONE constraint sentence for ONE interpreter predicate node. The
107
+ * shape of the output is pinned by Task 7b so the test suite can assert
108
+ * byte-for-byte equality. Returns `null` when the node is a structural
109
+ * boolean (`and` / `or` / `not`) - those are not constraint leaves. */
110
+ function renderConstraint(node) {
111
+ switch (node.op) {
112
+ case 'and':
113
+ case 'or':
114
+ case 'not':
115
+ return null;
116
+ case 'eq':
117
+ case 'lt':
118
+ case 'lte':
119
+ case 'gt':
120
+ case 'gte':
121
+ return renderComparison(node);
122
+ case 'in':
123
+ return renderMembership(node);
124
+ }
125
+ }
126
+ function renderComparison(node) {
127
+ const left = node.left;
128
+ const right = node.right;
129
+ // eq(call_contract, literal_address) -> Contract must be <addr>
130
+ if (left.kind === 'call_contract' && node.op === 'eq' && right.kind === 'literal_address') {
131
+ return `Contract must be ${right.value}`;
132
+ }
133
+ // eq(call_fn, literal_symbol) -> Function must be <sym>
134
+ if (left.kind === 'call_fn' && node.op === 'eq' && right.kind === 'literal_symbol') {
135
+ return `Function must be ${right.value}`;
136
+ }
137
+ // eq(call_arg[i], literal_vec) -> Path must be exactly [list]
138
+ if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
139
+ return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
140
+ }
141
+ // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
142
+ if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
143
+ return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
144
+ }
145
+ // amount <= v -> Amount <= v
146
+ if (left.kind === 'amount' && right.kind === 'literal_i128') {
147
+ return `Amount <= ${right.value}`;
148
+ }
149
+ // oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
150
+ if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
151
+ return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`;
152
+ }
153
+ // Any other comparison shape is a structural fail-closed: do not surface
154
+ // a misleading line. Cross-check still requires every leaf produce a
155
+ // constraint string; the only leaves we emit lines for are the ones we
156
+ // recognise above, so the test fixtures cover exactly the supported
157
+ // shapes.
158
+ return null;
159
+ }
160
+ function renderMembership(node) {
161
+ // call_arg[i] in [list] -> Recipient/arg must be one of [list]
162
+ if (node.needle.kind === 'call_arg') {
163
+ const list = node.haystack.map(renderHaystackElement).join(', ');
164
+ return `Recipient/arg must be one of [${list}]`;
165
+ }
166
+ return null;
167
+ }
168
+ function renderVecElement(leaf) {
169
+ switch (leaf.kind) {
170
+ case 'literal_address':
171
+ return leaf.value;
172
+ case 'literal_i128':
173
+ return leaf.value;
174
+ case 'literal_symbol':
175
+ return leaf.value;
176
+ case 'literal_u32':
177
+ return String(leaf.value);
178
+ case 'literal_u64':
179
+ return leaf.value;
180
+ case 'literal_bytes':
181
+ return leaf.value;
182
+ case 'literal_vec':
183
+ return `[${leaf.elements.map(renderVecElement).join(', ')}]`;
184
+ case 'call_contract':
185
+ case 'call_fn':
186
+ case 'call_arg':
187
+ case 'amount':
188
+ case 'window_spent':
189
+ case 'now':
190
+ case 'valid_until':
191
+ case 'invocation_count_in_window':
192
+ case 'oracle_price':
193
+ return `<${leaf.kind}>`;
194
+ }
195
+ }
196
+ function renderHaystackElement(leaf) {
197
+ if (leaf.kind === 'literal_address')
198
+ return leaf.value;
199
+ if (leaf.kind === 'literal_i128')
200
+ return leaf.value;
201
+ if (leaf.kind === 'literal_symbol')
202
+ return leaf.value;
203
+ if (leaf.kind === 'literal_u32')
204
+ return String(leaf.value);
205
+ if (leaf.kind === 'literal_u64')
206
+ return leaf.value;
207
+ if (leaf.kind === 'literal_bytes')
208
+ return leaf.value;
209
+ if (leaf.kind === 'literal_vec') {
210
+ return `[${leaf.elements.map(renderHaystackElement).join(', ')}]`;
211
+ }
212
+ return `<${leaf.kind}>`;
213
+ }
214
+ function comparisonOpText(op) {
215
+ switch (op) {
216
+ case 'lt':
217
+ return '<';
218
+ case 'lte':
219
+ return '<=';
220
+ case 'gt':
221
+ return '>';
222
+ case 'gte':
223
+ return '>=';
224
+ case 'eq':
225
+ return '==';
226
+ }
227
+ }
228
+ /** Render the plain-English one-liner. Format: `<ruleName>: <constraints>`,
229
+ * joined by `; ` so the user reads one sentence per constraint. */
230
+ function renderPlainEnglish(ruleName, constraints) {
231
+ if (constraints.length === 0)
232
+ return `${ruleName}: (no constraints)`;
233
+ return `${ruleName}: ${constraints.join('; ')}`;
234
+ }
235
+ /** Render the expiry line. `null` -> "No expiry"; a ledger sequence -> the
236
+ * ledger number so the user reads it in the same units the OZ context rule
237
+ * applies it. */
238
+ function renderExpiry(validUntilLedger) {
239
+ if (validUntilLedger === null)
240
+ return 'No expiry';
241
+ return `Valid until ledger ${validUntilLedger}`;
242
+ }
243
+ function computeContentHash(input) {
244
+ return createHash('sha256').update(canonicalStringify(input)).digest('hex');
245
+ }
246
+ /** Canonical JSON with recursively sorted object keys (stable across runs). */
247
+ function canonicalStringify(value) {
248
+ return JSON.stringify(sortKeys(value));
249
+ }
250
+ function sortKeys(value) {
251
+ if (Array.isArray(value))
252
+ return value.map(sortKeys);
253
+ if (value && typeof value === 'object') {
254
+ const out = {};
255
+ for (const key of Object.keys(value).sort()) {
256
+ out[key] = sortKeys(value[key]);
257
+ }
258
+ return out;
259
+ }
260
+ return value;
261
+ }
@@ -0,0 +1,40 @@
1
+ /** Minimal rule reference the classifier needs to compute the conflict kind.
2
+ * Carries the rule id (used in the annotation), the scope (contract +
3
+ * method), and an optional spending cap. The cap captures both the
4
+ * amount and the window so the classifier can compare apples to apples. */
5
+ export interface RuleRef {
6
+ id: string;
7
+ scope: {
8
+ contract?: string;
9
+ method?: string;
10
+ };
11
+ /** When set, the rule enforces this per-window spend cap (token +
12
+ * decimal-string amount + seconds window). */
13
+ spendingLimit?: {
14
+ token: string;
15
+ amount: string;
16
+ windowSeconds: number;
17
+ };
18
+ }
19
+ /** Structured annotation a single new-vs-existing rule comparison yields.
20
+ * Four mutually exclusive kinds; the union is exhaustive (the classifier
21
+ * never throws). */
22
+ export type ConflictAnnotation = {
23
+ kind: 'subsume';
24
+ existingRuleId: string;
25
+ detail: string;
26
+ } | {
27
+ kind: 'disjoint';
28
+ } | {
29
+ kind: 'counter_permissive';
30
+ existingRuleId: string;
31
+ newLimit: string;
32
+ existingLimit: string;
33
+ } | {
34
+ kind: 'window_divergent';
35
+ existingRuleId: string;
36
+ effectiveUnion: string;
37
+ };
38
+ /** Classify the relationship between a new rule and one existing rule.
39
+ * Pure: same pair -> same annotation. */
40
+ export declare function classifyConflict(newRule: RuleRef, existingRule: RuleRef): ConflictAnnotation;
@@ -0,0 +1,111 @@
1
+ // src/review-card/conflict.ts - the ConflictAnnotation 4-kind enum + the
2
+ // `classifyConflict` classifier.
3
+ //
4
+ // When a user installs a new policy alongside an existing rule set, the
5
+ // review card surfaces the relationship between the new rule and each
6
+ // existing rule as a structured annotation. A boolean (conflict / no
7
+ // conflict) is wrong: four qualitatively different shapes are possible, and
8
+ // each one tells the user something different about what would happen if
9
+ // both rules installed together.
10
+ //
11
+ // - subsume new rule's scope is a SUPERSET of an existing
12
+ // rule's. The new rule is broader; installing it on
13
+ // top of the existing rule weakens the existing
14
+ // restriction. The existing rule becomes a no-op.
15
+ // - disjoint new rule and existing rule are unrelated: their
16
+ // scopes do not overlap. Safe to install both.
17
+ // - counter_permissive new rule and existing rule allow CONFLICTING
18
+ // things at the same scope (one caps lower than the
19
+ // other on the same axis). The stricter one wins
20
+ // at evaluate; the looser is dead weight.
21
+ // - window_divergent new rule and existing rule have the same scope
22
+ // and the same per-window limit but DIFFERENT
23
+ // rolling windows. Effective union = sum (the user
24
+ // can spend `limit` per each window, independently).
25
+ //
26
+ // The classifier consumes a minimal `RuleRef` shape: the rule id, scope
27
+ // (contract + method), and an optional spending limit + window. Shapes
28
+ // without a limit fall back to scope-only classification.
29
+ /** Classify the relationship between a new rule and one existing rule.
30
+ * Pure: same pair -> same annotation. */
31
+ export function classifyConflict(newRule, existingRule) {
32
+ // --- step 1: subsume? (new.scope ⊋ existing.scope) ---
33
+ //
34
+ // Subsume requires existing to be fully scoped (both contract AND method
35
+ // pinned) AND new to be at least partially broader than existing. A new
36
+ // rule with identical scope to existing is NOT a superset - it is the
37
+ // same rule and falls through to the same-scope branch below. If existing
38
+ // is "any contract" / "any method" then it is the wildest rule in the
39
+ // rule set; a new rule covering it is just a duplicate, not a subsume -
40
+ // fall through to disjoint.
41
+ const existingHasContract = existingRule.scope.contract !== undefined;
42
+ const existingHasMethod = existingRule.scope.method !== undefined;
43
+ const newIsBroaderOnContract = newRule.scope.contract === undefined && existingHasContract;
44
+ const newIsBroaderOnMethod = newRule.scope.method === undefined && existingHasMethod;
45
+ const newMatchesOnContract = newRule.scope.contract === undefined ||
46
+ (existingHasContract && newRule.scope.contract === existingRule.scope.contract);
47
+ const newMatchesOnMethod = newRule.scope.method === undefined ||
48
+ (existingHasMethod && newRule.scope.method === existingRule.scope.method);
49
+ if (existingHasContract &&
50
+ existingHasMethod &&
51
+ newMatchesOnContract &&
52
+ newMatchesOnMethod &&
53
+ (newIsBroaderOnContract || newIsBroaderOnMethod)) {
54
+ return {
55
+ kind: 'subsume',
56
+ existingRuleId: existingRule.id,
57
+ detail: `new rule scope (${describeScope(newRule)}) is a superset of existing rule (${describeScope(existingRule)})`,
58
+ };
59
+ }
60
+ // --- step 2: disjoint by scope? ---
61
+ if (existingHasContract && newRule.scope.contract !== undefined) {
62
+ if (newRule.scope.contract !== existingRule.scope.contract) {
63
+ return { kind: 'disjoint' };
64
+ }
65
+ }
66
+ if (existingHasMethod &&
67
+ newRule.scope.method !== undefined &&
68
+ newRule.scope.method !== existingRule.scope.method) {
69
+ return { kind: 'disjoint' };
70
+ }
71
+ // --- step 3: same scope -> compare spend caps (when both sides carry them) ---
72
+ if (newRule.spendingLimit && existingRule.spendingLimit) {
73
+ const a = newRule.spendingLimit;
74
+ const b = existingRule.spendingLimit;
75
+ if (a.token !== b.token) {
76
+ // Same scope, different tokens: each binds a different axis. They
77
+ // do not counter each other; treat as disjoint.
78
+ return { kind: 'disjoint' };
79
+ }
80
+ if (a.windowSeconds === b.windowSeconds) {
81
+ // Same scope, same token, same window: counter-permissive when the
82
+ // amounts disagree (the stricter wins at evaluate; the looser is
83
+ // dead weight). Equal amounts -> duplicate rule, treat as disjoint.
84
+ if (a.amount === b.amount) {
85
+ return { kind: 'disjoint' };
86
+ }
87
+ return {
88
+ kind: 'counter_permissive',
89
+ existingRuleId: existingRule.id,
90
+ newLimit: `${a.amount} ${a.token} / ${a.windowSeconds}s`,
91
+ existingLimit: `${b.amount} ${b.token} / ${b.windowSeconds}s`,
92
+ };
93
+ }
94
+ // Same scope + token, different windows: effective union is the sum
95
+ // of the two per-window caps (each window ticks independently).
96
+ const union = (BigInt(a.amount) + BigInt(b.amount)).toString();
97
+ return {
98
+ kind: 'window_divergent',
99
+ existingRuleId: existingRule.id,
100
+ effectiveUnion: `${union} ${a.token} per ${a.windowSeconds}s (new) + ${b.windowSeconds}s (existing)`,
101
+ };
102
+ }
103
+ // Same scope without comparable spend caps on both sides -> disjoint.
104
+ return { kind: 'disjoint' };
105
+ }
106
+ function describeScope(rule) {
107
+ const parts = [];
108
+ parts.push(rule.scope.contract ? `contract=${rule.scope.contract}` : 'contract=*');
109
+ parts.push(rule.scope.method ? `method=${rule.scope.method}` : 'method=*');
110
+ return parts.join(', ');
111
+ }
@@ -0,0 +1,11 @@
1
+ import type { PredicateNode } from '../types.ts';
2
+ import type { ReviewCardSummary } from './builder.ts';
3
+ /** Assert every leaf in the predicate appears as a constraint string in
4
+ * `summary.constraints`. Returns the missing templates (without the leaf
5
+ * values filled in) when any leaf was dropped. */
6
+ export declare function summaryCrossCheck(predicate: PredicateNode | null, summary: ReviewCardSummary): {
7
+ ok: true;
8
+ } | {
9
+ ok: false;
10
+ missingConstraints: string[];
11
+ };
@@ -0,0 +1,148 @@
1
+ // src/review-card/cross-check.ts - the non-hallucination guard.
2
+ //
3
+ // `summaryCrossCheck` walks EVERY leaf of the predicate and asserts the
4
+ // review-card summary quoted a constraint string for it. Returns
5
+ // `{ ok: false, missingConstraints }` if any leaf was dropped (or rendered
6
+ // into a template shape the cross-check does not recognise). A
7
+ // `PredicateNode = null` is always ok - the policy is OZ-only and the
8
+ // interpreter predicate did not exist.
9
+ //
10
+ // The guard is structural: every leaf the builder is supposed to render has
11
+ // exactly one template shape. The cross-check enumerates those shapes and
12
+ // demands the summary carries the corresponding string. A naive summary
13
+ // that drops a leaf (or fabricates a string not in the predicate) trips the
14
+ // guard.
15
+ /** Assert every leaf in the predicate appears as a constraint string in
16
+ * `summary.constraints`. Returns the missing templates (without the leaf
17
+ * values filled in) when any leaf was dropped. */
18
+ export function summaryCrossCheck(predicate, summary) {
19
+ if (predicate === null)
20
+ return { ok: true };
21
+ const expected = [];
22
+ collect(predicate, expected);
23
+ if (expected.length === 0)
24
+ return { ok: true };
25
+ const present = new Set(summary.constraints);
26
+ const missing = expected.filter((s) => !present.has(s));
27
+ if (missing.length === 0)
28
+ return { ok: true };
29
+ return { ok: false, missingConstraints: missing };
30
+ }
31
+ /** Walk the predicate and emit the EXACT constraint string the builder is
32
+ * expected to produce for each leaf, in walk order. Strings here MUST
33
+ * match the templates in `builder.ts` byte-for-byte; the cross-check is
34
+ * the structural claim that "every supported leaf shape is rendered". */
35
+ function collect(node, out) {
36
+ switch (node.op) {
37
+ case 'and':
38
+ case 'or':
39
+ for (const child of node.children)
40
+ collect(child, out);
41
+ return;
42
+ case 'not':
43
+ collect(node.child, out);
44
+ return;
45
+ case 'eq':
46
+ case 'lt':
47
+ case 'lte':
48
+ case 'gt':
49
+ case 'gte':
50
+ pushComparison(node.left, node.right, node.op, out);
51
+ return;
52
+ case 'in':
53
+ pushMembership(node.needle, node.haystack, out);
54
+ return;
55
+ }
56
+ }
57
+ function pushComparison(left, right, op, out) {
58
+ if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
59
+ out.push(`Contract must be ${right.value}`);
60
+ return;
61
+ }
62
+ if (left.kind === 'call_fn' && op === 'eq' && right.kind === 'literal_symbol') {
63
+ out.push(`Function must be ${right.value}`);
64
+ return;
65
+ }
66
+ if (left.kind === 'call_arg' && op === 'eq' && right.kind === 'literal_vec') {
67
+ out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
68
+ return;
69
+ }
70
+ if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
71
+ out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
72
+ return;
73
+ }
74
+ if (left.kind === 'amount' && right.kind === 'literal_i128') {
75
+ out.push(`Amount <= ${right.value}`);
76
+ return;
77
+ }
78
+ if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
79
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
80
+ return;
81
+ }
82
+ }
83
+ function pushMembership(needle, haystack, out) {
84
+ if (needle.kind !== 'call_arg')
85
+ return;
86
+ const list = haystack.map(renderHaystackElement).join(', ');
87
+ out.push(`Recipient/arg must be one of [${list}]`);
88
+ }
89
+ function renderVecElement(leaf) {
90
+ switch (leaf.kind) {
91
+ case 'literal_address':
92
+ return leaf.value;
93
+ case 'literal_i128':
94
+ return leaf.value;
95
+ case 'literal_symbol':
96
+ return leaf.value;
97
+ case 'literal_u32':
98
+ return String(leaf.value);
99
+ case 'literal_u64':
100
+ return leaf.value;
101
+ case 'literal_bytes':
102
+ return leaf.value;
103
+ case 'literal_vec':
104
+ return `[${leaf.elements.map(renderVecElement).join(', ')}]`;
105
+ case 'call_contract':
106
+ case 'call_fn':
107
+ case 'call_arg':
108
+ case 'amount':
109
+ case 'window_spent':
110
+ case 'now':
111
+ case 'valid_until':
112
+ case 'invocation_count_in_window':
113
+ case 'oracle_price':
114
+ return `<${leaf.kind}>`;
115
+ }
116
+ }
117
+ function renderHaystackElement(leaf) {
118
+ if (leaf.kind === 'literal_address')
119
+ return leaf.value;
120
+ if (leaf.kind === 'literal_i128')
121
+ return leaf.value;
122
+ if (leaf.kind === 'literal_symbol')
123
+ return leaf.value;
124
+ if (leaf.kind === 'literal_u32')
125
+ return String(leaf.value);
126
+ if (leaf.kind === 'literal_u64')
127
+ return leaf.value;
128
+ if (leaf.kind === 'literal_bytes')
129
+ return leaf.value;
130
+ if (leaf.kind === 'literal_vec') {
131
+ return `[${leaf.elements.map(renderHaystackElement).join(', ')}]`;
132
+ }
133
+ return `<${leaf.kind}>`;
134
+ }
135
+ function comparisonOpText(op) {
136
+ switch (op) {
137
+ case 'lt':
138
+ return '<';
139
+ case 'lte':
140
+ return '<=';
141
+ case 'gt':
142
+ return '>';
143
+ case 'gte':
144
+ return '>=';
145
+ case 'eq':
146
+ return '==';
147
+ }
148
+ }
@@ -0,0 +1,3 @@
1
+ export { buildReviewCardSummary, type ReviewCardSummary, } from './builder.ts';
2
+ export { type ConflictAnnotation, classifyConflict, type RuleRef, } from './conflict.ts';
3
+ export { summaryCrossCheck } from './cross-check.ts';
@@ -0,0 +1,4 @@
1
+ // src/review-card/index.ts - re-export the deterministic review-card surface.
2
+ export { buildReviewCardSummary, } from "./builder.js";
3
+ export { classifyConflict, } from "./conflict.js";
4
+ export { summaryCrossCheck } from "./cross-check.js";
@@ -0,0 +1,31 @@
1
+ import { type ProposedPolicy, type RecordedTransaction, type ToolResponse } from '../index.ts';
2
+ import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
+ export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
+ export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
5
+ export type RunRecordTransactionInput = RecordTransactionInput;
6
+ export type RunSynthesizePolicyInput = SynthesizePolicyInput;
7
+ /** `record_transaction` body - wraps `recordTransaction`. The tool input
8
+ * matches the core RecordInput minus the injected `fetcher` (the transport
9
+ * layer does not own the RPC). Returns the core ToolResponse unchanged.
10
+ *
11
+ * Item 6: a try/catch envelope wraps the core call. The core's own
12
+ * `recordTransaction` already returns `{ok:false, error}` for Zod + decoder
13
+ * failures, but a throw from the SDK (e.g. a malformed hash handed to
14
+ * `@stellar/stellar-sdk`'s StrKey decoder) would otherwise surface as
15
+ * "[object Object]" in the MCP transport. The envelope converts any
16
+ * non-ToolResponse throw into a structured `RECORDING_FAILED` ToolError so
17
+ * the agent always sees a machine-readable error code. */
18
+ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse<RecordedTransaction>>;
19
+ /** `synthesize_policy` body - discriminated union on `source`:
20
+ * - `mandate` -> synthesizeFromMandate
21
+ * - `recording` -> synthesizeFromRecording
22
+ * Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
23
+ * letting the agent pick the deterministic or the inferred path. The CLI
24
+ * routes the same way.
25
+ *
26
+ * Item 6: same try/catch envelope as `runRecordTransaction`. The core
27
+ * `synthesizeFromRecording` already converts ToolError-shaped throws to
28
+ * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
29
+ * XDR decode error in the adapter) would otherwise surface as
30
+ * "[object Object]" in the MCP transport. */
31
+ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;