@crediolabs/policy-synth 0.1.17 → 0.2.0

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