@crediolabs/policy-synth 0.1.2 → 0.1.4

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 (92) hide show
  1. package/README.md +193 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  3. package/dist/adapters/interpreter/adapter.js +522 -0
  4. package/dist/adapters/interpreter/index.d.ts +1 -0
  5. package/dist/adapters/interpreter/index.js +2 -0
  6. package/dist/adapters/oz/adapter.js +20 -18
  7. package/dist/codegen/compile-gate.d.ts +33 -0
  8. package/dist/codegen/compile-gate.js +119 -0
  9. package/dist/codegen/index.d.ts +2 -0
  10. package/dist/codegen/index.js +8 -0
  11. package/dist/codegen/template.d.ts +18 -0
  12. package/dist/codegen/template.js +131 -0
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/ir/types.d.ts +13 -2
  17. package/dist/mandate/to-ir.js +4 -4
  18. package/dist/mandate/types.d.ts +1 -1
  19. package/dist/predicate/encode.d.ts +10 -0
  20. package/dist/predicate/encode.js +249 -0
  21. package/dist/predicate/index.d.ts +1 -0
  22. package/dist/predicate/index.js +2 -0
  23. package/dist/review-card/builder.d.ts +14 -0
  24. package/dist/review-card/builder.js +261 -0
  25. package/dist/review-card/conflict.d.ts +40 -0
  26. package/dist/review-card/conflict.js +111 -0
  27. package/dist/review-card/cross-check.d.ts +11 -0
  28. package/dist/review-card/cross-check.js +148 -0
  29. package/dist/review-card/index.d.ts +3 -0
  30. package/dist/review-card/index.js +4 -0
  31. package/dist/seams/types.d.ts +1 -1
  32. package/dist/synth/compose-from-recording.d.ts +35 -7
  33. package/dist/synth/compose-from-recording.js +231 -40
  34. package/dist/synth/deny-cases.d.ts +12 -0
  35. package/dist/synth/deny-cases.js +361 -0
  36. package/dist/synth/evaluate.d.ts +39 -0
  37. package/dist/synth/evaluate.js +425 -0
  38. package/dist/synth/harness.d.ts +16 -0
  39. package/dist/synth/harness.js +26 -0
  40. package/dist/synth/index.d.ts +4 -0
  41. package/dist/synth/index.js +4 -0
  42. package/dist/synth/lower.d.ts +2 -2
  43. package/dist/synth/minimize.d.ts +4 -0
  44. package/dist/synth/minimize.js +38 -0
  45. package/dist/synth/predicate-literals.d.ts +5 -0
  46. package/dist/synth/predicate-literals.js +25 -0
  47. package/dist/synth/synthesize-from-mandate.js +5 -5
  48. package/dist/synth/synthesize-from-recording.d.ts +32 -3
  49. package/dist/synth/synthesize-from-recording.js +491 -17
  50. package/dist/types.d.ts +23 -1
  51. package/dist/verify/envelope.d.ts +15 -0
  52. package/dist/verify/envelope.js +22 -0
  53. package/dist/verify/index.d.ts +3 -0
  54. package/dist/verify/index.js +3 -0
  55. package/dist/verify/simulate.d.ts +31 -0
  56. package/dist/verify/simulate.js +242 -0
  57. package/dist/verify/verify.d.ts +21 -0
  58. package/dist/verify/verify.js +173 -0
  59. package/package.json +1 -1
  60. package/src/adapters/interpreter/adapter.ts +642 -0
  61. package/src/adapters/interpreter/index.ts +8 -0
  62. package/src/adapters/oz/adapter.ts +20 -18
  63. package/src/codegen/compile-gate.ts +162 -0
  64. package/src/codegen/index.ts +17 -0
  65. package/src/codegen/template.ts +148 -0
  66. package/src/errors.ts +2 -0
  67. package/src/index.ts +2 -0
  68. package/src/ir/types.ts +9 -2
  69. package/src/mandate/to-ir.ts +4 -4
  70. package/src/mandate/types.ts +1 -1
  71. package/src/predicate/encode.ts +307 -0
  72. package/src/predicate/index.ts +3 -0
  73. package/src/review-card/builder.ts +303 -0
  74. package/src/review-card/conflict.ts +143 -0
  75. package/src/review-card/cross-check.ts +158 -0
  76. package/src/review-card/index.ts +12 -0
  77. package/src/seams/types.ts +1 -1
  78. package/src/synth/compose-from-recording.ts +283 -49
  79. package/src/synth/deny-cases.ts +447 -0
  80. package/src/synth/evaluate.ts +493 -0
  81. package/src/synth/harness.ts +40 -0
  82. package/src/synth/index.ts +12 -0
  83. package/src/synth/lower.ts +2 -2
  84. package/src/synth/minimize.ts +44 -0
  85. package/src/synth/predicate-literals.ts +27 -0
  86. package/src/synth/synthesize-from-mandate.ts +5 -5
  87. package/src/synth/synthesize-from-recording.ts +575 -18
  88. package/src/types.ts +19 -2
  89. package/src/verify/envelope.ts +28 -0
  90. package/src/verify/index.ts +5 -0
  91. package/src/verify/simulate.ts +292 -0
  92. package/src/verify/verify.ts +224 -0
@@ -0,0 +1,307 @@
1
+ // src/predicate/encode.ts - canonical predicate encoder.
2
+ //
3
+ // Pure function. Maps a `PredicateNode` AST to the canonical ScVal wire format
4
+ // described in `packages/policy-interpreter/INTERPRETER_INSTALL_PARAMS.md`:
5
+ // - every node is a `ScVal::Vec` whose head element is the tag `ScVal::Symbol`
6
+ // - children of `and` / `or` are sorted ascending by their canonical XDR bytes
7
+ // - `in` haystacks are ALWAYS sorted by canonical XDR bytes (pure set
8
+ // membership); an EXACT ordered sequence is expressed as
9
+ // `eq(selector, literal_vec)` where the `literal_vec` element order is
10
+ // preserved verbatim (the order IS the semantic)
11
+ // - `literal_vec` encodes to a bare `ScVal::Vec` of its element encodings;
12
+ // order is preserved, NOT sorted
13
+ // - i128 uses `Int128Parts{hi: Int64 (signed), lo: Uint64 (unsigned)}`,
14
+ // value = hi*2^64 + lo (NOT signed-magnitude)
15
+ // - no `ScMap` anywhere in the predicate
16
+ // - `encodedPredicate = base64(root.toXDR())`,
17
+ // `predicateHash = sha256(raw XDR bytes)` as hex
18
+ //
19
+ // Caps from `PREDICATE_CAPS` are enforced BEFORE returning; a cap breach throws
20
+ // a `ToolError` with the matching error code and `severity: 'error'`.
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import { Address, xdr } from '@stellar/stellar-sdk'
24
+ import type { ToolError } from '../errors.ts'
25
+ import { PREDICATE_CAPS, type PredicateLeaf, type PredicateNode } from '../types.ts'
26
+
27
+ export interface EncodedPredicate {
28
+ /** base64 of the canonical ScVal XDR of the predicate root. */
29
+ encodedPredicate: string
30
+ /** sha256 hex digest of the raw XDR bytes (post-canonicalisation). */
31
+ predicateHash: string
32
+ }
33
+
34
+ const INT64_MAX = (1n << 63n) - 1n
35
+ const INT64_MIN = -(1n << 63n)
36
+ const UINT64_MAX = (1n << 64n) - 1n
37
+
38
+ /** Encode a `PredicateNode` to the canonical ScVal wire format and hash it.
39
+ * Pure function: same input -> byte-identical output every run. */
40
+ export function encodePredicate(node: PredicateNode): EncodedPredicate {
41
+ // --- pass 1: structural cap checks (no encoding needed) ---
42
+ const stats = computeStats(node)
43
+ if (stats.depth > PREDICATE_CAPS.MAX_DEPTH) {
44
+ throw capError(
45
+ 'PREDICATE_TOO_DEEP',
46
+ `predicate depth ${stats.depth} exceeds MAX_DEPTH ${PREDICATE_CAPS.MAX_DEPTH}`
47
+ )
48
+ }
49
+ if (stats.leaves > PREDICATE_CAPS.MAX_LEAVES) {
50
+ throw capError(
51
+ 'TOO_MANY_LEAVES',
52
+ `predicate leaf count ${stats.leaves} exceeds MAX_LEAVES ${PREDICATE_CAPS.MAX_LEAVES}`
53
+ )
54
+ }
55
+ for (const c of stats.inCounts) {
56
+ if (c.count > PREDICATE_CAPS.MAX_IN_OPERAND_COUNT) {
57
+ throw capError(
58
+ 'IN_OPERAND_LIMIT',
59
+ `\`in\` operand count ${c.count} exceeds MAX_IN_OPERAND_COUNT ${PREDICATE_CAPS.MAX_IN_OPERAND_COUNT}`
60
+ )
61
+ }
62
+ }
63
+ // Two-round confirmation costs 2 oracle reads per UNIQUE asset referenced.
64
+ const oracleReads = stats.oracleAssets.size * 2
65
+ if (oracleReads > PREDICATE_CAPS.MAX_ORACLE_READS) {
66
+ throw capError(
67
+ 'PREDICATE_ORACLE_OVER_LIMIT',
68
+ `oracle reads ${oracleReads} exceed MAX_ORACLE_READS ${PREDICATE_CAPS.MAX_ORACLE_READS}`
69
+ )
70
+ }
71
+
72
+ // --- pass 2: build + canonicalise the ScVal ---
73
+ const root = encodeNode(node)
74
+ const rawBytes = root.toXDR()
75
+ if (rawBytes.length > PREDICATE_CAPS.MAX_PREDICATE_BYTES) {
76
+ throw capError(
77
+ 'PREDICATE_TOO_LARGE',
78
+ `predicate bytes ${rawBytes.length} exceed MAX_PREDICATE_BYTES ${PREDICATE_CAPS.MAX_PREDICATE_BYTES}`
79
+ )
80
+ }
81
+ const encodedPredicate = rawBytes.toString('base64')
82
+ const predicateHash = createHash('sha256').update(rawBytes).digest('hex')
83
+ return { encodedPredicate, predicateHash }
84
+ }
85
+
86
+ interface PredicateStats {
87
+ depth: number
88
+ leaves: number
89
+ inCounts: Array<{ count: number }>
90
+ /** Unique assets referenced by `oracle_price` leaves - each costs 2 reads. */
91
+ oracleAssets: Set<string>
92
+ }
93
+
94
+ function computeStats(node: PredicateNode): PredicateStats {
95
+ const inCounts: Array<{ count: number }> = []
96
+ const oracleAssets = new Set<string>()
97
+ const { depth, leaves } = walk(node, inCounts, oracleAssets)
98
+ return { depth, leaves, inCounts, oracleAssets }
99
+ }
100
+
101
+ function walk(
102
+ node: PredicateNode,
103
+ inCounts: Array<{ count: number }>,
104
+ oracleAssets: Set<string>
105
+ ): { depth: number; leaves: number } {
106
+ switch (node.op) {
107
+ case 'and':
108
+ case 'or': {
109
+ if (node.children.length === 0) return { depth: 1, leaves: 0 }
110
+ let maxChildDepth = 0
111
+ let totalLeaves = 0
112
+ for (const c of node.children) {
113
+ const child = walk(c, inCounts, oracleAssets)
114
+ if (child.depth > maxChildDepth) maxChildDepth = child.depth
115
+ totalLeaves += child.leaves
116
+ }
117
+ return { depth: maxChildDepth + 1, leaves: totalLeaves }
118
+ }
119
+ case 'not': {
120
+ const child = walk(node.child, inCounts, oracleAssets)
121
+ return { depth: child.depth + 1, leaves: child.leaves }
122
+ }
123
+ case 'eq':
124
+ case 'lt':
125
+ case 'lte':
126
+ case 'gt':
127
+ case 'gte': {
128
+ collectOracle(node.left, oracleAssets)
129
+ collectOracle(node.right, oracleAssets)
130
+ return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) }
131
+ }
132
+ case 'in': {
133
+ inCounts.push({ count: node.haystack.length })
134
+ collectOracle(node.needle, oracleAssets)
135
+ let haystackLeaves = 0
136
+ for (const h of node.haystack) {
137
+ collectOracle(h, oracleAssets)
138
+ haystackLeaves += leafCount(h)
139
+ }
140
+ return {
141
+ depth: 1,
142
+ leaves: leafCount(node.needle) + haystackLeaves,
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Leaf-count contribution of one PredicateLeaf: 1 for any flat leaf, and the
149
+ * sum of element leaf counts for `literal_vec` so MAX_LEAVES caps see nested
150
+ * vector elements. */
151
+ function leafCount(leaf: PredicateLeaf): number {
152
+ if (leaf.kind === 'literal_vec') {
153
+ let total = 1 // the literal_vec itself counts as one leaf node
154
+ for (const el of leaf.elements) total += leafCount(el)
155
+ return total
156
+ }
157
+ return 1
158
+ }
159
+
160
+ function collectOracle(leaf: PredicateLeaf, oracleAssets: Set<string>): void {
161
+ // literal_vec is the only nested leaf; recurse so an oracle_price buried in a
162
+ // vector literal (which the lowering would forbid, but the cap-walker must
163
+ // still see) is counted toward the oracle-read budget.
164
+ if (leaf.kind === 'oracle_price') {
165
+ oracleAssets.add(leaf.asset)
166
+ } else if (leaf.kind === 'literal_vec') {
167
+ for (const el of leaf.elements) collectOracle(el, oracleAssets)
168
+ }
169
+ }
170
+
171
+ function encodeNode(node: PredicateNode): xdr.ScVal {
172
+ switch (node.op) {
173
+ case 'and':
174
+ case 'or': {
175
+ const encoded = node.children.map(encodeNode)
176
+ // sort children by their canonical XDR bytes ascending.
177
+ const sorted = sortByCanonicalBytes(encoded)
178
+ return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)])
179
+ }
180
+ case 'not': {
181
+ return xdr.ScVal.scvVec([symbol('not'), encodeNode(node.child)])
182
+ }
183
+ case 'eq':
184
+ case 'lt':
185
+ case 'lte':
186
+ case 'gt':
187
+ case 'gte': {
188
+ return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)])
189
+ }
190
+ case 'in': {
191
+ const needle = encodeLeaf(node.needle)
192
+ // `in` is PURE set membership: the haystack is ALWAYS sorted by canonical
193
+ // XDR bytes ascending. An exact ordered sequence (e.g. a swap hop path)
194
+ // is expressed as `eq(selector, literal_vec)` where the vec's element
195
+ // order is preserved verbatim (handled in `encodeLeaf(literal_vec)`).
196
+ const haystack = sortByCanonicalBytes(node.haystack.map(encodeLeaf))
197
+ return xdr.ScVal.scvVec([symbol('in'), needle, xdr.ScVal.scvVec(haystack)])
198
+ }
199
+ }
200
+ }
201
+
202
+ function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
203
+ switch (leaf.kind) {
204
+ case 'call_contract':
205
+ return xdr.ScVal.scvVec([symbol('call_contract')])
206
+ case 'call_fn':
207
+ return xdr.ScVal.scvVec([symbol('call_fn')])
208
+ case 'call_arg':
209
+ return xdr.ScVal.scvVec([symbol('call_arg'), xdr.ScVal.scvU32(leaf.index)])
210
+ case 'amount':
211
+ return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)])
212
+ case 'window_spent':
213
+ return xdr.ScVal.scvVec([
214
+ symbol('window_spent'),
215
+ scvAddressFromStrkey(leaf.token),
216
+ scvU64FromValue(leaf.windowSeconds),
217
+ ])
218
+ case 'now':
219
+ return xdr.ScVal.scvVec([symbol('now')])
220
+ case 'valid_until':
221
+ return xdr.ScVal.scvVec([symbol('valid_until')])
222
+ case 'invocation_count_in_window':
223
+ return xdr.ScVal.scvVec([symbol('invocation_count'), scvU64FromValue(leaf.windowSecs)])
224
+ case 'oracle_price':
225
+ return xdr.ScVal.scvVec([symbol('oracle_price'), scvAddressFromStrkey(leaf.asset)])
226
+ case 'literal_address':
227
+ return scvAddressFromStrkey(leaf.value)
228
+ case 'literal_i128':
229
+ return scvI128FromDecimal(leaf.value)
230
+ case 'literal_symbol':
231
+ return xdr.ScVal.scvSymbol(leaf.value)
232
+ case 'literal_u32':
233
+ return xdr.ScVal.scvU32(leaf.value)
234
+ case 'literal_u64':
235
+ return scvU64FromValue(leaf.value)
236
+ case 'literal_bytes':
237
+ return xdr.ScVal.scvBytes(Buffer.from(leaf.value, 'hex'))
238
+ case 'literal_vec':
239
+ // Bare ScVal::Vec of element encodings - order is preserved verbatim
240
+ // because the order IS the semantic (exact ordered sequence equality).
241
+ return xdr.ScVal.scvVec(leaf.elements.map(encodeLeaf))
242
+ }
243
+ }
244
+
245
+ function symbol(s: string): xdr.ScVal {
246
+ return xdr.ScVal.scvSymbol(s)
247
+ }
248
+
249
+ function sortByCanonicalBytes(values: xdr.ScVal[]): xdr.ScVal[] {
250
+ const pairs = values.map((v) => ({ v, bytes: v.toXDR() }))
251
+ pairs.sort((a, b) => Buffer.compare(a.bytes, b.bytes))
252
+ return pairs.map((p) => p.v)
253
+ }
254
+
255
+ function scvAddressFromStrkey(strkey: string): xdr.ScVal {
256
+ return xdr.ScVal.scvAddress(Address.fromString(strkey).toScAddress())
257
+ }
258
+
259
+ function scvU64FromValue(value: number | string): xdr.ScVal {
260
+ // The Uint64 (UnsignedHyper) constructor accepts string | bigint | number;
261
+ // string is safest for values > 2^53.
262
+ return xdr.ScVal.scvU64(new xdr.Uint64(String(value)))
263
+ }
264
+
265
+ /** Build `ScVal::I128(Int128Parts{hi, lo})` from a signed decimal string.
266
+ * `Int128Parts` encodes the value as `(hi << 64) + lo` with `hi` a SIGNED
267
+ * 64-bit int and `lo` an UNSIGNED 64-bit int (this is NOT signed-magnitude).
268
+ * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
269
+ * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
270
+ * bigint/string/number. */
271
+ function scvI128FromDecimal(decimal: string): xdr.ScVal {
272
+ const v = BigInt(decimal)
273
+ const hi = v >> 64n
274
+ const lo = v & UINT64_MAX
275
+ // Guard against accidental over-range. Int128Parts.hi must be a valid Int64
276
+ // (-2^63 .. 2^63-1); values outside this range are invalid i128 entirely.
277
+ if (hi < INT64_MIN || hi > INT64_MAX) {
278
+ throw capError(
279
+ 'MALFORMED_PREDICATE',
280
+ `literal_i128 value ${decimal} is outside the Int128 range`
281
+ )
282
+ }
283
+ if (lo < 0n || lo > UINT64_MAX) {
284
+ throw capError(
285
+ 'MALFORMED_PREDICATE',
286
+ `literal_i128 value ${decimal} has an invalid Int128Parts.lo`
287
+ )
288
+ }
289
+ return xdr.ScVal.scvI128(
290
+ new xdr.Int128Parts({
291
+ hi: new xdr.Int64(hi),
292
+ lo: new xdr.Uint64(lo),
293
+ })
294
+ )
295
+ }
296
+
297
+ function capError(code: ToolError['code'], message: string): ToolError {
298
+ const err = new Error(message) as Error & {
299
+ code: ToolError['code']
300
+ severity: string
301
+ retryable: boolean
302
+ }
303
+ err.code = code
304
+ err.severity = 'error'
305
+ err.retryable = false
306
+ throw err
307
+ }
@@ -0,0 +1,3 @@
1
+ // src/predicate/index.ts - re-export the canonical predicate encoder.
2
+
3
+ export { type EncodedPredicate, encodePredicate } from './encode.ts'
@@ -0,0 +1,303 @@
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
+
32
+ import { createHash } from 'node:crypto'
33
+ import type {
34
+ ContextRuleDraft,
35
+ OZPrimitiveConfig,
36
+ PolicyRef,
37
+ PredicateLeaf,
38
+ PredicateNode,
39
+ } from '../types.ts'
40
+ import type { SimulationResult } from '../verify/envelope.ts'
41
+
42
+ export interface ReviewCardSummary {
43
+ ruleName: string
44
+ plainEnglish: string
45
+ constraints: string[]
46
+ expiry: string
47
+ backend: 'interpreter-v1' | 'ts-model'
48
+ /** Stable hash of the builder inputs - identical policy + summary = identical hash. */
49
+ contentHash: string
50
+ }
51
+
52
+ /** Build a deterministic review-card summary from a policy + context rule +
53
+ * simulation result. Pure: same inputs -> byte-identical output. */
54
+ export function buildReviewCardSummary(
55
+ predicate: PredicateNode | null,
56
+ policyRefs: PolicyRef[],
57
+ contextRule: ContextRuleDraft,
58
+ simulation: SimulationResult
59
+ ): ReviewCardSummary {
60
+ const constraints: string[] = []
61
+ for (const ref of policyRefs) {
62
+ const line = renderOzPrimitive(ref)
63
+ if (line !== null) constraints.push(line)
64
+ }
65
+ if (predicate !== null) {
66
+ walkPredicate(predicate, (node) => {
67
+ const line = renderConstraint(node)
68
+ if (line !== null) constraints.push(line)
69
+ })
70
+ }
71
+
72
+ const ruleName = contextRule.name
73
+ const plainEnglish = renderPlainEnglish(ruleName, constraints)
74
+ const expiry = renderExpiry(contextRule.validUntilLedger)
75
+ const backend = simulation.backend
76
+
77
+ const contentHash = computeContentHash({
78
+ ruleName,
79
+ plainEnglish,
80
+ constraints,
81
+ expiry,
82
+ backend,
83
+ })
84
+
85
+ return { ruleName, plainEnglish, constraints, expiry, backend, contentHash }
86
+ }
87
+
88
+ /** Render the OZ built-in primitive summary line. Only `spending_limit` is
89
+ * quoted by the review card (it is the only primitive that defines a
90
+ * transactional bound). Other primitives (threshold) are signer-config
91
+ * concerns handled by the OZ adapter's own `uncovered` machinery.
92
+ * Spending_limit takes `period_ledgers` on-chain (~5s/ledger); the card
93
+ * states the window in seconds so the user reads it consistently with the
94
+ * interpreter templates. */
95
+ function renderOzPrimitive(ref: PolicyRef): string | null {
96
+ if (ref.kind !== 'oz_builtin') return null
97
+ const primitive: OZPrimitiveConfig = ref.primitive
98
+ if (primitive.primitive !== 'spending_limit') return null
99
+ const params = primitive.params as { spending_limit?: string; period_ledgers?: number }
100
+ const limit = params.spending_limit ?? '0'
101
+ const periodLedgers = params.period_ledgers ?? 0
102
+ const windowSecs = periodLedgers * 5
103
+ return `spending_limit(${limit}, ${windowSecs})`
104
+ }
105
+
106
+ /** Walk every comparison / membership node of the predicate and invoke
107
+ * `visit` on each. The walk is depth-first, left-to-right, so the
108
+ * constraint list is stable across runs. Pure boolean nodes contribute no
109
+ * constraint lines themselves; their leaf children do, via the visitor. */
110
+ function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void): void {
111
+ switch (node.op) {
112
+ case 'and':
113
+ case 'or':
114
+ for (const child of node.children) walkPredicate(child, visit)
115
+ return
116
+ case 'not':
117
+ walkPredicate(node.child, visit)
118
+ return
119
+ case 'in':
120
+ visit(node)
121
+ return
122
+ case 'eq':
123
+ case 'lt':
124
+ case 'lte':
125
+ case 'gt':
126
+ case 'gte':
127
+ visit(node)
128
+ return
129
+ }
130
+ }
131
+
132
+ /** Render ONE constraint sentence for ONE interpreter predicate node. The
133
+ * shape of the output is pinned by Task 7b so the test suite can assert
134
+ * byte-for-byte equality. Returns `null` when the node is a structural
135
+ * boolean (`and` / `or` / `not`) - those are not constraint leaves. */
136
+ function renderConstraint(node: PredicateNode): string | null {
137
+ switch (node.op) {
138
+ case 'and':
139
+ case 'or':
140
+ case 'not':
141
+ return null
142
+ case 'eq':
143
+ case 'lt':
144
+ case 'lte':
145
+ case 'gt':
146
+ case 'gte':
147
+ return renderComparison(node)
148
+ case 'in':
149
+ return renderMembership(node)
150
+ }
151
+ }
152
+
153
+ function renderComparison(
154
+ node: Extract<PredicateNode, { op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' }>
155
+ ): string | null {
156
+ const left = node.left
157
+ const right = node.right
158
+
159
+ // eq(call_contract, literal_address) -> Contract must be <addr>
160
+ if (left.kind === 'call_contract' && node.op === 'eq' && right.kind === 'literal_address') {
161
+ return `Contract must be ${right.value}`
162
+ }
163
+
164
+ // eq(call_fn, literal_symbol) -> Function must be <sym>
165
+ if (left.kind === 'call_fn' && node.op === 'eq' && right.kind === 'literal_symbol') {
166
+ return `Function must be ${right.value}`
167
+ }
168
+
169
+ // eq(call_arg[i], literal_vec) -> Path must be exactly [list]
170
+ if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
171
+ return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
172
+ }
173
+
174
+ // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
175
+ if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
176
+ return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
177
+ }
178
+
179
+ // amount <= v -> Amount <= v
180
+ if (left.kind === 'amount' && right.kind === 'literal_i128') {
181
+ return `Amount <= ${right.value}`
182
+ }
183
+
184
+ // oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
185
+ if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
186
+ return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`
187
+ }
188
+
189
+ // Any other comparison shape is a structural fail-closed: do not surface
190
+ // a misleading line. Cross-check still requires every leaf produce a
191
+ // constraint string; the only leaves we emit lines for are the ones we
192
+ // recognise above, so the test fixtures cover exactly the supported
193
+ // shapes.
194
+ return null
195
+ }
196
+
197
+ function renderMembership(node: Extract<PredicateNode, { op: 'in' }>): string | null {
198
+ // call_arg[i] in [list] -> Recipient/arg must be one of [list]
199
+ if (node.needle.kind === 'call_arg') {
200
+ const list = node.haystack.map(renderHaystackElement).join(', ')
201
+ return `Recipient/arg must be one of [${list}]`
202
+ }
203
+ return null
204
+ }
205
+
206
+ function renderVecElement(leaf: PredicateLeaf): string {
207
+ switch (leaf.kind) {
208
+ case 'literal_address':
209
+ return leaf.value
210
+ case 'literal_i128':
211
+ return leaf.value
212
+ case 'literal_symbol':
213
+ return leaf.value
214
+ case 'literal_u32':
215
+ return String(leaf.value)
216
+ case 'literal_u64':
217
+ return leaf.value
218
+ case 'literal_bytes':
219
+ return leaf.value
220
+ case 'literal_vec':
221
+ return `[${leaf.elements.map(renderVecElement).join(', ')}]`
222
+ case 'call_contract':
223
+ case 'call_fn':
224
+ case 'call_arg':
225
+ case 'amount':
226
+ case 'window_spent':
227
+ case 'now':
228
+ case 'valid_until':
229
+ case 'invocation_count_in_window':
230
+ case 'oracle_price':
231
+ return `<${leaf.kind}>`
232
+ }
233
+ }
234
+
235
+ function renderHaystackElement(leaf: PredicateLeaf): string {
236
+ if (leaf.kind === 'literal_address') return leaf.value
237
+ if (leaf.kind === 'literal_i128') return leaf.value
238
+ if (leaf.kind === 'literal_symbol') return leaf.value
239
+ if (leaf.kind === 'literal_u32') return String(leaf.value)
240
+ if (leaf.kind === 'literal_u64') return leaf.value
241
+ if (leaf.kind === 'literal_bytes') return leaf.value
242
+ if (leaf.kind === 'literal_vec') {
243
+ return `[${leaf.elements.map(renderHaystackElement).join(', ')}]`
244
+ }
245
+ return `<${leaf.kind}>`
246
+ }
247
+
248
+ function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
249
+ switch (op) {
250
+ case 'lt':
251
+ return '<'
252
+ case 'lte':
253
+ return '<='
254
+ case 'gt':
255
+ return '>'
256
+ case 'gte':
257
+ return '>='
258
+ case 'eq':
259
+ return '=='
260
+ }
261
+ }
262
+
263
+ /** Render the plain-English one-liner. Format: `<ruleName>: <constraints>`,
264
+ * joined by `; ` so the user reads one sentence per constraint. */
265
+ function renderPlainEnglish(ruleName: string, constraints: string[]): string {
266
+ if (constraints.length === 0) return `${ruleName}: (no constraints)`
267
+ return `${ruleName}: ${constraints.join('; ')}`
268
+ }
269
+
270
+ /** Render the expiry line. `null` -> "No expiry"; a ledger sequence -> the
271
+ * ledger number so the user reads it in the same units the OZ context rule
272
+ * applies it. */
273
+ function renderExpiry(validUntilLedger: number | null): string {
274
+ if (validUntilLedger === null) return 'No expiry'
275
+ return `Valid until ledger ${validUntilLedger}`
276
+ }
277
+
278
+ function computeContentHash(input: {
279
+ ruleName: string
280
+ plainEnglish: string
281
+ constraints: string[]
282
+ expiry: string
283
+ backend: 'interpreter-v1' | 'ts-model'
284
+ }): string {
285
+ return createHash('sha256').update(canonicalStringify(input)).digest('hex')
286
+ }
287
+
288
+ /** Canonical JSON with recursively sorted object keys (stable across runs). */
289
+ function canonicalStringify(value: unknown): string {
290
+ return JSON.stringify(sortKeys(value))
291
+ }
292
+
293
+ function sortKeys(value: unknown): unknown {
294
+ if (Array.isArray(value)) return value.map(sortKeys)
295
+ if (value && typeof value === 'object') {
296
+ const out: Record<string, unknown> = {}
297
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
298
+ out[key] = sortKeys((value as Record<string, unknown>)[key])
299
+ }
300
+ return out
301
+ }
302
+ return value
303
+ }