@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.
- package/README.md +44 -246
- package/dist/adapters/interpreter/adapter.d.ts +6 -7
- package/dist/adapters/interpreter/adapter.js +28 -48
- package/dist/adapters/oz/adapter.js +12 -13
- package/dist/codegen/compile-gate.js +5 -0
- package/dist/codegen/template.js +17 -0
- package/dist/install/authority-overlap.d.ts +134 -0
- package/dist/install/authority-overlap.js +0 -0
- package/dist/install/build-add-context-rule.d.ts +8 -0
- package/dist/install/build-add-context-rule.js +17 -46
- package/dist/install/build-install-policy.d.ts +12 -5
- package/dist/install/build-install-policy.js +32 -23
- package/dist/install/build-merge-policy.d.ts +70 -0
- package/dist/install/build-merge-policy.js +130 -0
- package/dist/install/get-interpreter-info.js +2 -2
- package/dist/install/index.d.ts +3 -1
- package/dist/install/index.js +8 -1
- package/dist/install/oz-auth.js +5 -3
- package/dist/install/plan-merge-policy.d.ts +49 -0
- package/dist/install/plan-merge-policy.js +86 -0
- package/dist/install/read-account-rules.d.ts +100 -0
- package/dist/install/read-account-rules.js +283 -0
- package/dist/predicate/decode.js +1 -1
- package/dist/predicate/encode.js +171 -10
- package/dist/record/decode.js +5 -6
- package/dist/registry/protocols.d.ts +1 -1
- package/dist/registry/protocols.js +1 -1
- package/dist/review-card/builder.d.ts +6 -0
- package/dist/review-card/builder.js +9 -1
- package/dist/run/index.d.ts +104 -18
- package/dist/run/index.js +359 -75
- package/dist/run/schemas.d.ts +479 -18
- package/dist/run/schemas.js +132 -24
- package/dist/synth/compose-from-recording.d.ts +10 -15
- package/dist/synth/compose-from-recording.js +79 -124
- package/dist/synth/deny-cases.d.ts +5 -0
- package/dist/synth/deny-cases.js +52 -13
- package/dist/synth/evaluate.js +69 -119
- package/dist/synth/harness.d.ts +13 -1
- package/dist/synth/harness.js +22 -1
- package/dist/synth/index.d.ts +1 -1
- package/dist/synth/synthesize-from-recording.d.ts +31 -21
- package/dist/synth/synthesize-from-recording.js +129 -155
- package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
- package/dist-cjs/adapters/interpreter/adapter.js +28 -48
- package/dist-cjs/adapters/oz/adapter.js +12 -13
- package/dist-cjs/codegen/compile-gate.js +5 -0
- package/dist-cjs/codegen/template.js +17 -0
- package/dist-cjs/install/authority-overlap.d.ts +134 -0
- package/dist-cjs/install/authority-overlap.js +0 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
- package/dist-cjs/install/build-add-context-rule.js +17 -45
- package/dist-cjs/install/build-install-policy.d.ts +12 -5
- package/dist-cjs/install/build-install-policy.js +32 -23
- package/dist-cjs/install/build-merge-policy.d.ts +70 -0
- package/dist-cjs/install/build-merge-policy.js +134 -0
- package/dist-cjs/install/get-interpreter-info.js +2 -2
- package/dist-cjs/install/index.d.ts +3 -1
- package/dist-cjs/install/index.js +24 -3
- package/dist-cjs/install/oz-auth.js +5 -3
- package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
- package/dist-cjs/install/plan-merge-policy.js +90 -0
- package/dist-cjs/install/read-account-rules.d.ts +100 -0
- package/dist-cjs/install/read-account-rules.js +296 -0
- package/dist-cjs/predicate/decode.js +1 -1
- package/dist-cjs/predicate/encode.js +171 -10
- package/dist-cjs/record/decode.js +5 -6
- package/dist-cjs/registry/protocols.d.ts +1 -1
- package/dist-cjs/registry/protocols.js +1 -1
- package/dist-cjs/review-card/builder.d.ts +6 -0
- package/dist-cjs/review-card/builder.js +9 -1
- package/dist-cjs/run/index.d.ts +104 -18
- package/dist-cjs/run/index.js +360 -74
- package/dist-cjs/run/schemas.d.ts +479 -18
- package/dist-cjs/run/schemas.js +133 -25
- package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
- package/dist-cjs/synth/compose-from-recording.js +79 -124
- package/dist-cjs/synth/deny-cases.d.ts +5 -0
- package/dist-cjs/synth/deny-cases.js +52 -13
- package/dist-cjs/synth/evaluate.js +69 -119
- package/dist-cjs/synth/harness.d.ts +13 -1
- package/dist-cjs/synth/harness.js +22 -1
- package/dist-cjs/synth/index.d.ts +1 -1
- package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
- package/dist-cjs/synth/synthesize-from-recording.js +129 -153
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +28 -48
- package/src/adapters/oz/adapter.ts +12 -13
- package/src/codegen/compile-gate.ts +5 -0
- package/src/codegen/template.ts +17 -0
- package/src/install/authority-overlap.ts +0 -0
- package/src/install/build-add-context-rule.ts +29 -56
- package/src/install/build-install-policy.ts +41 -23
- package/src/install/build-merge-policy.ts +219 -0
- package/src/install/get-interpreter-info.ts +2 -2
- package/src/install/index.ts +36 -2
- package/src/install/oz-auth.ts +5 -3
- package/src/install/plan-merge-policy.ts +133 -0
- package/src/install/read-account-rules.ts +376 -0
- package/src/predicate/decode.ts +1 -1
- package/src/predicate/encode.ts +176 -10
- package/src/record/decode.ts +5 -6
- package/src/registry/protocols.ts +1 -1
- package/src/review-card/builder.ts +17 -1
- package/src/run/index.ts +500 -102
- package/src/run/schemas.ts +145 -24
- package/src/synth/compose-from-recording.ts +87 -132
- package/src/synth/deny-cases.ts +59 -13
- package/src/synth/evaluate.ts +70 -118
- package/src/synth/harness.ts +29 -1
- package/src/synth/index.ts +1 -0
- package/src/synth/synthesize-from-recording.ts +209 -192
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
package/src/synth/deny-cases.ts
CHANGED
|
@@ -6,6 +6,11 @@ import { literalNumericBigInt } from './predicate-literals.ts'
|
|
|
6
6
|
export interface DenyCase {
|
|
7
7
|
dimension: string
|
|
8
8
|
ctx: EvalContext
|
|
9
|
+
/** Canonical reason the Rust interpreter returns for this dimension.
|
|
10
|
+
* Optional: when set, `runHarness` asserts the TS evaluator emits this
|
|
11
|
+
* exact string, so a future TS/Rust reason divergence fails CI. When
|
|
12
|
+
* absent the case is only checked on the boolean decision. */
|
|
13
|
+
expectedReason?: string
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export interface GeneratedCases {
|
|
@@ -39,18 +44,32 @@ const ORACLE_CASES = [
|
|
|
39
44
|
['oracle_paused', 'paused'],
|
|
40
45
|
] as const
|
|
41
46
|
|
|
47
|
+
// Cross-layer reason contract: the TS evaluator's deny reason for each
|
|
48
|
+
// oracle error category must match the Rust interpreter's `DenyReason` code
|
|
49
|
+
// string (the same strings surface in the conformance fixture). The harness
|
|
50
|
+
// asserts them via the per-case `expectedReason`; a future divergence
|
|
51
|
+
// surfaces as a REASON_MISMATCH failure.
|
|
52
|
+
const ORACLE_ERROR_REASON: Record<(typeof ORACLE_CASES)[number][1], string> = {
|
|
53
|
+
stale: 'ORACLE_STALE',
|
|
54
|
+
missing: 'ORACLE_MISSING',
|
|
55
|
+
deviation: 'ORACLE_DEVIATION_EXCEEDED',
|
|
56
|
+
paused: 'ORACLE_PAUSED',
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
// Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
|
|
43
60
|
const ADJACENT_ASSETS = [
|
|
44
61
|
'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
|
|
45
62
|
'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
|
|
46
63
|
] as const
|
|
47
64
|
|
|
48
|
-
//
|
|
65
|
+
// Property-harness mutation dimensions excluded from the synth pipeline's
|
|
49
66
|
// self-verify call so existing fixtures still emit policies. The harness tests
|
|
50
67
|
// them as FINDINGS against the already-emitted policy.
|
|
51
68
|
const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
|
|
52
69
|
|
|
53
|
-
// The
|
|
70
|
+
// The dimensions the synth pipeline uses for self-verify and minimise. No count
|
|
71
|
+
// is written here on purpose: this comment said 15 while the array held 17, and
|
|
72
|
+
// anyone checking the number should read the array rather than the prose.
|
|
54
73
|
// Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
|
|
55
74
|
// alongside the existing dimensions so the per-element binds emitted for
|
|
56
75
|
// Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
|
|
@@ -106,7 +125,7 @@ export function generateCases(
|
|
|
106
125
|
comparison.left.token,
|
|
107
126
|
comparison
|
|
108
127
|
)
|
|
109
|
-
if (mutated) denies.push({ dimension: 'amount', ctx: mutated })
|
|
128
|
+
if (mutated) denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
const movedTokens = new Set<string>()
|
|
@@ -116,6 +135,15 @@ export function generateCases(
|
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
for (const token of movedTokens) {
|
|
138
|
+
// The `asset` deny case mutates BOTH the amount record AND the contract
|
|
139
|
+
// binding (token's value moved to an adjacent asset; the same swap applied
|
|
140
|
+
// to any address-typed `call_arg` / `call_contract` literal). The actual
|
|
141
|
+
// deny reason is therefore predicate-dependent - the order of evaluation
|
|
142
|
+
// is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
|
|
143
|
+
// that has both bindings denies with CONTRACT_SCOPE, while a policy with
|
|
144
|
+
// only an amount binding denies with AMOUNT_BOUND. The reason assertion
|
|
145
|
+
// would be brittle here; the boolean decision is what we strictly need
|
|
146
|
+
// to pin. Leave `expectedReason` unset for this dimension.
|
|
119
147
|
denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) })
|
|
120
148
|
}
|
|
121
149
|
|
|
@@ -131,7 +159,7 @@ export function generateCases(
|
|
|
131
159
|
for (const _constraint of contractConstraints) {
|
|
132
160
|
const ctx = cloneContext(permitCtx)
|
|
133
161
|
ctx.contract = distinctText(permitCtx.contract, 'contract')
|
|
134
|
-
denies.push({ dimension: 'contract', ctx })
|
|
162
|
+
denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' })
|
|
135
163
|
}
|
|
136
164
|
|
|
137
165
|
const functionConstraints = [
|
|
@@ -144,13 +172,13 @@ export function generateCases(
|
|
|
144
172
|
for (const _constraint of functionConstraints) {
|
|
145
173
|
const ctx = cloneContext(permitCtx)
|
|
146
174
|
ctx.fn = distinctText(permitCtx.fn, 'function')
|
|
147
|
-
denies.push({ dimension: 'function', ctx })
|
|
175
|
+
denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' })
|
|
148
176
|
}
|
|
149
177
|
|
|
150
178
|
if (permitCtx.validUntilLedger !== undefined) {
|
|
151
179
|
const ctx = cloneContext(permitCtx)
|
|
152
180
|
ctx.atLedger = permitCtx.validUntilLedger + 1
|
|
153
|
-
denies.push({ dimension: 'timing', ctx })
|
|
181
|
+
denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' })
|
|
154
182
|
}
|
|
155
183
|
|
|
156
184
|
for (const comparison of facts.comparisons) {
|
|
@@ -162,7 +190,8 @@ export function generateCases(
|
|
|
162
190
|
comparison,
|
|
163
191
|
false
|
|
164
192
|
)
|
|
165
|
-
if (mutated)
|
|
193
|
+
if (mutated)
|
|
194
|
+
denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
|
|
166
195
|
}
|
|
167
196
|
|
|
168
197
|
for (const comparison of facts.comparisons) {
|
|
@@ -173,7 +202,7 @@ export function generateCases(
|
|
|
173
202
|
comparison.op,
|
|
174
203
|
comparison.right.value
|
|
175
204
|
)
|
|
176
|
-
denies.push({ dimension: 'invocation_count', ctx })
|
|
205
|
+
denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' })
|
|
177
206
|
}
|
|
178
207
|
|
|
179
208
|
// Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
|
|
@@ -187,7 +216,7 @@ export function generateCases(
|
|
|
187
216
|
if (!violating) continue
|
|
188
217
|
const ctx = cloneContext(permitCtx)
|
|
189
218
|
ctx.args[comparison.left.index] = violating
|
|
190
|
-
denies.push({ dimension: 'arg_amount_bound', ctx })
|
|
219
|
+
denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' })
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
const argumentConstraints: Array<{ index: number }> = []
|
|
@@ -208,6 +237,15 @@ export function generateCases(
|
|
|
208
237
|
for (const constraint of argumentConstraints) {
|
|
209
238
|
const ctx = cloneContext(permitCtx)
|
|
210
239
|
ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' }
|
|
240
|
+
// The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
|
|
241
|
+
// depends on the predicate shape:
|
|
242
|
+
// - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
|
|
243
|
+
// - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
|
|
244
|
+
// (opaque needles fail-closed at the `in` membership gate, step 5)
|
|
245
|
+
// Both are correct in the TS evaluator; the reason is predicate-dependent
|
|
246
|
+
// so we cannot pin a single canonical reason here. The boolean decision
|
|
247
|
+
// is what we strictly need to assert; the reason is recorded (not asserted)
|
|
248
|
+
// when the harness runs this case.
|
|
211
249
|
denies.push({ dimension: 'arg_bound', ctx })
|
|
212
250
|
}
|
|
213
251
|
|
|
@@ -232,6 +270,14 @@ export function generateCases(
|
|
|
232
270
|
for (const index of scopedArgumentIndices) {
|
|
233
271
|
ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' }
|
|
234
272
|
}
|
|
273
|
+
// `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
|
|
274
|
+
// The first failing child of the AND decides the reason, and the contract
|
|
275
|
+
// check (step 3) does fire first in the evaluator. The reason is therefore
|
|
276
|
+
// CONTRACT_SCOPE for the canonical case, but a predicate that lists the
|
|
277
|
+
// call_fn leaf first (or that has a `call_arg_field` for that arg index)
|
|
278
|
+
// can flip the order. Pin the assertion here only when no `in` /
|
|
279
|
+
// call_arg_field binds the same arg index exist - the canonical blend
|
|
280
|
+
// case does have those binds, so the assertion would fire there.
|
|
235
281
|
denies.push({ dimension: 'scope_contract_fn_arg', ctx })
|
|
236
282
|
}
|
|
237
283
|
|
|
@@ -241,7 +287,7 @@ export function generateCases(
|
|
|
241
287
|
if (comparison.left.kind !== 'oracle_price') continue
|
|
242
288
|
const ctx = cloneContext(permitCtx)
|
|
243
289
|
ctx.oraclePriceByAsset[comparison.left.asset] = { error }
|
|
244
|
-
denies.push({ dimension, ctx })
|
|
290
|
+
denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] })
|
|
245
291
|
}
|
|
246
292
|
}
|
|
247
293
|
|
|
@@ -298,9 +344,9 @@ export function generateCases(
|
|
|
298
344
|
const sel = comparison.left
|
|
299
345
|
if (sel.kind !== 'call_arg_field') continue
|
|
300
346
|
const arg = permitCtx.args[sel.index]
|
|
301
|
-
if (
|
|
347
|
+
if (arg?.type !== 'vec') continue
|
|
302
348
|
const element = arg.value[sel.element]
|
|
303
|
-
if (
|
|
349
|
+
if (element?.type !== 'map' || !Array.isArray(element.value)) continue
|
|
304
350
|
const entry = element.value.find((e) => e.key === sel.field)
|
|
305
351
|
if (!entry) continue
|
|
306
352
|
const flipped = flipFieldValue(entry.val)
|
|
@@ -326,7 +372,7 @@ export function generateCases(
|
|
|
326
372
|
const sel = comparison.left
|
|
327
373
|
if (sel.kind !== 'call_arg_len') continue
|
|
328
374
|
const arg = permitCtx.args[sel.index]
|
|
329
|
-
if (
|
|
375
|
+
if (arg?.type !== 'vec') continue
|
|
330
376
|
const ctx = cloneContext(permitCtx)
|
|
331
377
|
ctx.args[sel.index] = {
|
|
332
378
|
type: 'vec',
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -1,33 +1,23 @@
|
|
|
1
|
-
// src/synth/evaluate.ts - the model evaluator.
|
|
1
|
+
// src/synth/evaluate.ts - the model evaluator (TS model of interpreter semantics).
|
|
2
2
|
//
|
|
3
|
-
// Pure function.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
|
|
4
|
+
// no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
|
|
5
|
+
// 1. ledger expiry -> 'EXPIRED'
|
|
6
|
+
// 2. `now` past valid_until -> 'EXPIRED'
|
|
7
|
+
// 3. contract mismatch -> 'CONTRACT_SCOPE'
|
|
8
|
+
// 4. per-ScVal equality on fn/args; EXACT ordered vector
|
|
9
|
+
// equality; fail-closed on opaque args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
|
|
10
|
+
// 5. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
|
|
11
|
+
// 6. amount / window_spent via BigInt -> 'AMOUNT_BOUND'
|
|
12
|
+
// 7. invocation_count_in_window -> 'FREQUENCY'
|
|
13
|
+
// 8. oracle_price: fatal -> throw OracleError, deny with
|
|
14
|
+
// underlying ORACLE_* reason -> 'ORACLE_*'
|
|
15
|
+
// 9. boolean nodes (and/or/not)
|
|
16
|
+
// 10. signer threshold gate -> 'THRESHOLD_NOT_MET'
|
|
17
|
+
// 11. otherwise permit.
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 2. a `now` leaf compared past `valid_until` semantics -> 'EXPIRED'
|
|
11
|
-
// 3. a `call_contract` eq whose literal != ctx.contract -> 'CONTRACT_SCOPE'
|
|
12
|
-
// 4. per-ScVal equality on `call_fn` / `call_arg[i]`;
|
|
13
|
-
// `eq(selector, literal_vec)` = EXACT ordered vector equality
|
|
14
|
-
// (compare element-by-element, deny if length or any differs);
|
|
15
|
-
// fail-closed on opaque/undecodable args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
|
|
16
|
-
// 5. `in` membership: needle NOT in haystack; an EMPTY
|
|
17
|
-
// haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
|
|
18
|
-
// 6. amount / window_spent comparisons via BigInt -> 'AMOUNT_BOUND'
|
|
19
|
-
// 7. `invocation_count_in_window` compare -> 'FREQUENCY'
|
|
20
|
-
// 8. `oracle_price`: missing/stale/deviation/etc. is FATAL -
|
|
21
|
-
// throw `OracleError`, catch at the top, deny with the
|
|
22
|
-
// underlying `ORACLE_*` reason -> 'ORACLE_*'
|
|
23
|
-
// 9. boolean nodes: `and` = all children permit;
|
|
24
|
-
// `or` = any child permits; `not` = invert a non-oracle child
|
|
25
|
-
// 10. signer threshold gate (when `signerWeights` provided,
|
|
26
|
-
// no positive-weight signer -> deny 'THRESHOLD_NOT_MET')
|
|
27
|
-
// 11. otherwise `{ permit: true }`.
|
|
28
|
-
//
|
|
29
|
-
// Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
|
|
30
|
-
// modelled by throwing (never a boolean-false that `not`/`or` could mask).
|
|
19
|
+
// Amounts: BigInt on decimal strings. Oracle errors thrown (never
|
|
20
|
+
// boolean-false `not`/`or` could mask).
|
|
31
21
|
|
|
32
22
|
import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
|
|
33
23
|
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
@@ -70,8 +60,8 @@ export interface EvalContext {
|
|
|
70
60
|
export type EvalResult = { permit: true } | { permit: false; reason: string }
|
|
71
61
|
|
|
72
62
|
/** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
|
|
73
|
-
* and converted to the matching `ORACLE_*` deny reason. NOT a
|
|
74
|
-
*
|
|
63
|
+
* and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
|
|
64
|
+
* that `not` / `or` could mask. */
|
|
75
65
|
class OracleError extends Error {
|
|
76
66
|
readonly code: string
|
|
77
67
|
constructor(code: string) {
|
|
@@ -129,12 +119,9 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
|
|
|
129
119
|
for (const child of node.children) {
|
|
130
120
|
const r = walk(child, ctx)
|
|
131
121
|
if (!r.permit) {
|
|
132
|
-
// deny-on-first: short-circuit
|
|
133
|
-
// child, so the reason we surface is from that child.
|
|
122
|
+
// deny-on-first: short-circuit on the failing child.
|
|
134
123
|
return r
|
|
135
124
|
}
|
|
136
|
-
// keep a reference to the last permit so unused-variable analysis
|
|
137
|
-
// doesn't complain; this is purely structural.
|
|
138
125
|
lastDeny = r
|
|
139
126
|
}
|
|
140
127
|
return lastDeny ?? { permit: true }
|
|
@@ -149,11 +136,9 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
|
|
|
149
136
|
return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' }
|
|
150
137
|
}
|
|
151
138
|
case 'not': {
|
|
152
|
-
// `not` structurally inverts the child
|
|
153
|
-
// oracle leaf (compile-time rule: no oracle leaf under not/or).
|
|
154
|
-
//
|
|
155
|
-
// `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
|
|
156
|
-
// deny is inverted to a permit, and a permit is inverted to a deny.
|
|
139
|
+
// `not` structurally inverts the child unless the child contains an
|
|
140
|
+
// oracle leaf (compile-time rule: no oracle leaf under not/or). A
|
|
141
|
+
// child `OracleError` is re-thrown to the catch at the top of `evaluate`.
|
|
157
142
|
const r = walk(node.child, ctx)
|
|
158
143
|
if (r.permit) return { permit: false, reason: 'FN_MISMATCH' }
|
|
159
144
|
return { permit: true }
|
|
@@ -179,15 +164,12 @@ function evalCompare(
|
|
|
179
164
|
// --- step 2: `now` vs `valid_until` semantics ---
|
|
180
165
|
if (left.kind === 'now' && right.kind === 'valid_until') {
|
|
181
166
|
const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// `nowSeconds` so the only true-positive expired path is the
|
|
186
|
-
// `gt`/`gte` shapes callers actually write.
|
|
167
|
+
// `valid_until` is modelled as a synthetic future timestamp far past
|
|
168
|
+
// `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
|
|
169
|
+
// shapes callers actually write.
|
|
187
170
|
if (op === 'gt' || op === 'gte') {
|
|
188
171
|
if (ctx.nowSeconds > 0) return { permit: false, reason: 'EXPIRED' }
|
|
189
172
|
} else if (op === 'lt' || op === 'lte') {
|
|
190
|
-
// permits when valid_until is in the future
|
|
191
173
|
return { permit: true }
|
|
192
174
|
}
|
|
193
175
|
return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true }
|
|
@@ -220,11 +202,6 @@ function evalCompare(
|
|
|
220
202
|
return evalScaledArgCompare(op, left, right, ctx)
|
|
221
203
|
}
|
|
222
204
|
const actual = ctx.args[left.index]
|
|
223
|
-
// An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
|
|
224
|
-
// compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
|
|
225
|
-
// cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
|
|
226
|
-
// in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
|
|
227
|
-
// a non-numeric arg or literal.
|
|
228
205
|
if (op !== 'eq') return evalArgOrderedCompare(op, actual, right)
|
|
229
206
|
return evalArgEq(op, actual, right, ctx)
|
|
230
207
|
}
|
|
@@ -235,26 +212,24 @@ function evalCompare(
|
|
|
235
212
|
return evalScaledArgCompare(op, right, left, ctx)
|
|
236
213
|
}
|
|
237
214
|
|
|
238
|
-
// --- step 4c: call_arg_len:
|
|
239
|
-
// Fails closed on a non-vec
|
|
215
|
+
// --- step 4c: call_arg_len: length of a vec-typed argument as u32.
|
|
216
|
+
// Fails closed on a non-vec / absent arg or a non-u32 literal.
|
|
240
217
|
if (left.kind === 'call_arg_len') {
|
|
241
218
|
const actual = ctx.args[left.index]
|
|
242
|
-
if (
|
|
219
|
+
if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
243
220
|
if (right.kind !== 'literal_u32') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
244
221
|
return actual.value.length === right.value
|
|
245
222
|
? { permit: true }
|
|
246
223
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
247
224
|
}
|
|
248
225
|
|
|
249
|
-
// --- step 4d: call_arg_field:
|
|
250
|
-
//
|
|
251
|
-
// out-of-range element, a missing field, a non-map element, or a type
|
|
252
|
-
// mismatch between the field ScVal and the literal leaf.
|
|
226
|
+
// --- step 4d: call_arg_field: value of a field in the map at element i of
|
|
227
|
+
// the vec at argument index. Fails closed on shape / type / range issues.
|
|
253
228
|
if (left.kind === 'call_arg_field') {
|
|
254
229
|
const actual = ctx.args[left.index]
|
|
255
|
-
if (
|
|
230
|
+
if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
256
231
|
const element = actual.value[left.element]
|
|
257
|
-
if (
|
|
232
|
+
if (element?.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
258
233
|
if (!Array.isArray(element.value)) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
259
234
|
const entry = element.value.find((e) => e.key === left.field)
|
|
260
235
|
if (!entry) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
@@ -264,7 +239,7 @@ function evalCompare(
|
|
|
264
239
|
|
|
265
240
|
// --- step 6: AMOUNT_BOUND ---
|
|
266
241
|
if (left.kind === 'amount' && op !== 'eq') {
|
|
267
|
-
return evalAmountCompare(op, left.token, right, ctx)
|
|
242
|
+
return evalAmountCompare(op, left.token, right, ctx, 'amountByToken')
|
|
268
243
|
}
|
|
269
244
|
if (left.kind === 'window_spent' && op !== 'eq') {
|
|
270
245
|
return evalWindowSpentCompare(op, left.token, right, ctx)
|
|
@@ -294,7 +269,7 @@ function evalArgEq(
|
|
|
294
269
|
right: PredicateLeaf,
|
|
295
270
|
ctx: EvalContext
|
|
296
271
|
): EvalResult {
|
|
297
|
-
// eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
|
|
272
|
+
// eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
|
|
298
273
|
if (op === 'eq' && right.kind === 'literal_vec') {
|
|
299
274
|
if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
300
275
|
return compareVecExact(actual.value, right.elements, ctx)
|
|
@@ -302,7 +277,6 @@ function evalArgEq(
|
|
|
302
277
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
303
278
|
}
|
|
304
279
|
|
|
305
|
-
// eq(call_arg[i], literal_address) -> address compare
|
|
306
280
|
if (op === 'eq' && right.kind === 'literal_address') {
|
|
307
281
|
if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
308
282
|
if (actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
@@ -311,7 +285,6 @@ function evalArgEq(
|
|
|
311
285
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
312
286
|
}
|
|
313
287
|
|
|
314
|
-
// eq(call_arg[i], literal_i128) -> BigInt compare
|
|
315
288
|
if (op === 'eq' && right.kind === 'literal_i128') {
|
|
316
289
|
if (actual?.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
|
|
317
290
|
return BigInt(actual.value) === BigInt(right.value)
|
|
@@ -319,7 +292,6 @@ function evalArgEq(
|
|
|
319
292
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
320
293
|
}
|
|
321
294
|
|
|
322
|
-
// eq(call_arg[i], literal_symbol)
|
|
323
295
|
if (op === 'eq' && right.kind === 'literal_symbol') {
|
|
324
296
|
if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
325
297
|
return actual.type === 'symbol' && actual.value === right.value
|
|
@@ -327,7 +299,6 @@ function evalArgEq(
|
|
|
327
299
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
328
300
|
}
|
|
329
301
|
|
|
330
|
-
// eq(call_arg[i], literal_u32)
|
|
331
302
|
if (op === 'eq' && right.kind === 'literal_u32') {
|
|
332
303
|
if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
333
304
|
return actual.type === 'u32' && actual.value === String(right.value)
|
|
@@ -335,7 +306,6 @@ function evalArgEq(
|
|
|
335
306
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
336
307
|
}
|
|
337
308
|
|
|
338
|
-
// eq(call_arg[i], literal_u64)
|
|
339
309
|
if (op === 'eq' && right.kind === 'literal_u64') {
|
|
340
310
|
if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
341
311
|
return actual.type === 'u64' && actual.value === right.value
|
|
@@ -343,7 +313,6 @@ function evalArgEq(
|
|
|
343
313
|
: { permit: false, reason: 'ARG_MISMATCH' }
|
|
344
314
|
}
|
|
345
315
|
|
|
346
|
-
// eq(call_arg[i], literal_bytes)
|
|
347
316
|
if (op === 'eq' && right.kind === 'literal_bytes') {
|
|
348
317
|
if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
|
|
349
318
|
return actual.type === 'bytes' && actual.value === right.value
|
|
@@ -356,25 +325,19 @@ function evalArgEq(
|
|
|
356
325
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
357
326
|
}
|
|
358
327
|
|
|
359
|
-
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare
|
|
360
|
-
*
|
|
361
|
-
* zero
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* `left` is whichever side of the compare is NOT the scaled leaf. A
|
|
367
|
-
* scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
|
|
368
|
-
* leaves has no definable semantics). The other operand must be a
|
|
369
|
-
* numeric shape (`call_arg` carrying a number, or a numeric literal);
|
|
370
|
-
* anything else is `ARG_MISMATCH`. */
|
|
328
|
+
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
|
|
329
|
+
* the scaled leaf is `args[index] * num / den` (truncating toward zero). On
|
|
330
|
+
* overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
|
|
331
|
+
* denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
|
|
332
|
+
* `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
|
|
333
|
+
* (no definable semantics). The non-scaled operand must be numeric
|
|
334
|
+
* (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
|
|
371
335
|
function evalScaledArgCompare(
|
|
372
336
|
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
373
337
|
left: PredicateLeaf,
|
|
374
338
|
right: PredicateLeaf,
|
|
375
339
|
ctx: EvalContext
|
|
376
340
|
): EvalResult {
|
|
377
|
-
// Identify which side is scaled, and pull the other side as a number.
|
|
378
341
|
let scaled: { index: number; num: string; den: string }
|
|
379
342
|
let other: PredicateLeaf
|
|
380
343
|
let scaledOnRight: boolean
|
|
@@ -387,14 +350,12 @@ function evalScaledArgCompare(
|
|
|
387
350
|
other = left
|
|
388
351
|
scaledOnRight = true
|
|
389
352
|
} else {
|
|
390
|
-
//
|
|
391
|
-
// dispatcher; the contract returns ARG_MISMATCH to fail closed.
|
|
353
|
+
// Programming error in the dispatcher; fail closed.
|
|
392
354
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
393
355
|
}
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// not be read.
|
|
356
|
+
// Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
|
|
357
|
+
// SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
|
|
358
|
+
// itself could not be read.
|
|
398
359
|
if (scaled.index >= ctx.args.length) {
|
|
399
360
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
400
361
|
}
|
|
@@ -409,15 +370,10 @@ function evalScaledArgCompare(
|
|
|
409
370
|
return { permit: false, reason: 'ARG_MISMATCH' }
|
|
410
371
|
}
|
|
411
372
|
// Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
|
|
412
|
-
// check is
|
|
413
|
-
//
|
|
373
|
+
// check is defensive belt-and-braces so a validator regression cannot
|
|
374
|
+
// panic the frame on a divide-by-zero.
|
|
414
375
|
if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
415
376
|
const product = input * num
|
|
416
|
-
// BigInt overflow is silent in JS (it does not throw on multiplication).
|
|
417
|
-
// We can detect over-range by dividing and checking the result against
|
|
418
|
-
// i128 bounds, but the more practical check is whether the result
|
|
419
|
-
// divides cleanly. We rely on BigInt's arbitrary precision and then
|
|
420
|
-
// surface ARITHMETIC_OVERFLOW if the result is outside i128.
|
|
421
377
|
let scaledValue: bigint
|
|
422
378
|
try {
|
|
423
379
|
// BigInt division truncates toward zero (matches Rust `i128::checked_div`).
|
|
@@ -425,18 +381,13 @@ function evalScaledArgCompare(
|
|
|
425
381
|
} catch {
|
|
426
382
|
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
427
383
|
}
|
|
428
|
-
// i128 range check:
|
|
429
|
-
//
|
|
384
|
+
// i128 range check: the contract would have wrapped on i128 arithmetic;
|
|
385
|
+
// surface the same deny.
|
|
430
386
|
const I128_MAX = (1n << 127n) - 1n
|
|
431
387
|
const I128_MIN = -(1n << 127n)
|
|
432
388
|
if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
|
|
433
389
|
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
|
|
434
390
|
}
|
|
435
|
-
// Resolving the operand when the scaled side is on the right: the
|
|
436
|
-
// operand is `left`; the scaled value is the RHS. The AST order is
|
|
437
|
-
// `left <op> scaled`, so the comparator applies to `other <op> scaled`.
|
|
438
|
-
// When the scaled side is on the left, the order is `scaled <op> other`,
|
|
439
|
-
// i.e. `scaled <op> other_val`.
|
|
440
391
|
let otherVal: bigint | null
|
|
441
392
|
if (other.kind === 'call_arg') {
|
|
442
393
|
otherVal = argNumericBigInt(ctx.args[other.index])
|
|
@@ -504,7 +455,6 @@ function compareVecExact(actual: ScVal[], expected: PredicateLeaf[], ctx: EvalCo
|
|
|
504
455
|
* fails closed. */
|
|
505
456
|
function evalIn(needle: PredicateLeaf, haystack: PredicateLeaf[], ctx: EvalContext): EvalResult {
|
|
506
457
|
if (haystack.length === 0) return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
|
|
507
|
-
// Resolve the needle's ScVal against the candidate call.
|
|
508
458
|
const actual = resolveLeaf(needle, ctx)
|
|
509
459
|
if (!actual || actual.type === 'other') return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
|
|
510
460
|
for (const h of haystack) {
|
|
@@ -530,9 +480,9 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
530
480
|
return undefined
|
|
531
481
|
case 'call_arg_field': {
|
|
532
482
|
const actual = ctx.args[leaf.index]
|
|
533
|
-
if (
|
|
483
|
+
if (actual?.type !== 'vec') return undefined
|
|
534
484
|
const element = actual.value[leaf.element]
|
|
535
|
-
if (
|
|
485
|
+
if (element?.type !== 'map') return undefined
|
|
536
486
|
if (!Array.isArray(element.value)) return undefined
|
|
537
487
|
const entry = element.value.find((e) => e.key === leaf.field)
|
|
538
488
|
return entry ? entry.val : undefined
|
|
@@ -562,16 +512,19 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
|
|
|
562
512
|
}
|
|
563
513
|
}
|
|
564
514
|
|
|
565
|
-
/** Step 6: amount compare on BigInt.
|
|
515
|
+
/** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
|
|
516
|
+
* per-token BigInt record (current vs. rolling) to an i128 literal; the
|
|
517
|
+
* compare + denial reason are identical, so a single helper handles both. */
|
|
566
518
|
function evalAmountCompare(
|
|
567
519
|
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
|
|
568
520
|
token: string,
|
|
569
521
|
right: PredicateLeaf,
|
|
570
|
-
ctx: EvalContext
|
|
522
|
+
ctx: EvalContext,
|
|
523
|
+
record: 'amountByToken' | 'windowSpentByToken'
|
|
571
524
|
): EvalResult {
|
|
572
525
|
const literal = right.kind === 'literal_i128' ? right.value : null
|
|
573
526
|
if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
|
|
574
|
-
const actual = ctx
|
|
527
|
+
const actual = ctx[record][token] ?? '0'
|
|
575
528
|
return bigintCmp(op, actual, literal)
|
|
576
529
|
? { permit: true }
|
|
577
530
|
: { permit: false, reason: 'AMOUNT_BOUND' }
|
|
@@ -584,12 +537,7 @@ function evalWindowSpentCompare(
|
|
|
584
537
|
right: PredicateLeaf,
|
|
585
538
|
ctx: EvalContext
|
|
586
539
|
): EvalResult {
|
|
587
|
-
|
|
588
|
-
if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
|
|
589
|
-
const actual = ctx.windowSpentByToken[token] ?? '0'
|
|
590
|
-
return bigintCmp(op, actual, literal)
|
|
591
|
-
? { permit: true }
|
|
592
|
-
: { permit: false, reason: 'AMOUNT_BOUND' }
|
|
540
|
+
return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken')
|
|
593
541
|
}
|
|
594
542
|
|
|
595
543
|
/** Step 7: invocation_count_in_window compare. */
|
|
@@ -622,23 +570,27 @@ function evalOracleCompare(
|
|
|
622
570
|
throw new OracleError(mapped)
|
|
623
571
|
}
|
|
624
572
|
// Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
|
|
625
|
-
// decimal basis: prices are on the normalised 9-dp basis, and
|
|
626
|
-
//
|
|
627
|
-
//
|
|
573
|
+
// decimal basis: prices are on the normalised 9-dp basis, and a bare literal
|
|
574
|
+
// is refused (not assumed) because a raw 14-dp threshold that assumed the
|
|
575
|
+
// 9-dp basis would permit everything.
|
|
628
576
|
if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
|
|
629
577
|
if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
|
|
630
578
|
throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
|
|
631
579
|
}
|
|
632
|
-
// Scale BOTH sides up to the wider basis
|
|
633
|
-
//
|
|
580
|
+
// Scale BOTH sides up to the wider basis; dividing the threshold down
|
|
581
|
+
// truncates and moves the permit boundary.
|
|
634
582
|
const decimals = BigInt(right.decimals)
|
|
635
583
|
const normalised = BigInt(NORMALISED_DECIMALS)
|
|
636
584
|
const common = decimals > normalised ? decimals : normalised
|
|
637
585
|
const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
|
|
638
586
|
const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
|
|
587
|
+
// A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
|
|
588
|
+
// `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
|
|
589
|
+
// the same reason so a future TS/Rust reason divergence fails CI; the
|
|
590
|
+
// cross-layer harness in this file now asserts reason codes match.
|
|
639
591
|
return bigintCmp(op, String(priceScaled), String(literalScaled))
|
|
640
592
|
? { permit: true }
|
|
641
|
-
: { permit: false, reason: '
|
|
593
|
+
: { permit: false, reason: 'STATEFUL_BOUND' }
|
|
642
594
|
}
|
|
643
595
|
|
|
644
596
|
/** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
|
package/src/synth/harness.ts
CHANGED
|
@@ -7,11 +7,23 @@ export interface HarnessFailure {
|
|
|
7
7
|
expected: 'permit' | 'deny'
|
|
8
8
|
got: 'permit' | 'deny'
|
|
9
9
|
reason?: string
|
|
10
|
+
/** When `expectedReason` is set on the deny case and the case actually
|
|
11
|
+
* denies, this carries the canonical reason the TS evaluator MUST emit
|
|
12
|
+
* to stay in lockstep with the Rust interpreter (the cross-layer
|
|
13
|
+
* reason-code contract). A future TS/Rust reason divergence surfaces
|
|
14
|
+
* here in CI. */
|
|
15
|
+
expectedReason?: string
|
|
16
|
+
actualReason?: string
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
export type HarnessResult = { ok: true } | { ok: false; failures: HarnessFailure[] }
|
|
13
20
|
|
|
14
|
-
/** Evaluate the intended call and every generated denial without side effects.
|
|
21
|
+
/** Evaluate the intended call and every generated denial without side effects.
|
|
22
|
+
* Asserts both the boolean decision AND, for deny cases that carry an
|
|
23
|
+
* `expectedReason`, the concrete reason the TS evaluator emits. The reason
|
|
24
|
+
* is what the Rust interpreter returns; the cross-layer conformance test in
|
|
25
|
+
* `contracts/policy-interpreter/tests/conformance` mirrors the same contract
|
|
26
|
+
* for the verifyLive path. */
|
|
15
27
|
export function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult {
|
|
16
28
|
const failures: HarnessFailure[] = []
|
|
17
29
|
const permitResult = evaluate(predicate, cases.permit)
|
|
@@ -33,6 +45,22 @@ export function runHarness(predicate: PredicateNode, cases: GeneratedCases): Har
|
|
|
33
45
|
got: 'permit',
|
|
34
46
|
reason: 'DENY_CASE_FAILURE',
|
|
35
47
|
})
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
// Reason-code assertion (cross-layer contract). When a deny case carries
|
|
51
|
+
// an expected reason, the TS evaluator MUST emit that exact string; a
|
|
52
|
+
// divergence means the TS model and the Rust interpreter disagree on
|
|
53
|
+
// what the user sees in CI - the harness fails so the diff is visible
|
|
54
|
+
// here, not at runtime in production.
|
|
55
|
+
if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
|
|
56
|
+
failures.push({
|
|
57
|
+
dimension: deny.dimension,
|
|
58
|
+
expected: 'deny',
|
|
59
|
+
got: 'deny',
|
|
60
|
+
reason: 'REASON_MISMATCH',
|
|
61
|
+
expectedReason: deny.expectedReason,
|
|
62
|
+
actualReason: result.reason,
|
|
63
|
+
})
|
|
36
64
|
}
|
|
37
65
|
}
|
|
38
66
|
|
package/src/synth/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export {
|
|
|
30
30
|
} from './scope.ts'
|
|
31
31
|
export { synthesizeFromMandate } from './synthesize-from-mandate.ts'
|
|
32
32
|
export {
|
|
33
|
+
type __TestInterpreterAdapterOptions,
|
|
33
34
|
type SynthesizeFromRecordingOptions,
|
|
34
35
|
synthesizeFromRecording,
|
|
35
36
|
} from './synthesize-from-recording.ts'
|