@crediolabs/policy-synth 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +31 -5
  18. package/dist/review-card/cross-check.js +12 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/schemas.d.ts +4 -4
  22. package/dist/synth/compose-from-recording.d.ts +4 -0
  23. package/dist/synth/compose-from-recording.js +34 -8
  24. package/dist/synth/deny-cases.d.ts +4 -1
  25. package/dist/synth/deny-cases.js +3 -1
  26. package/dist/synth/evaluate.js +130 -3
  27. package/dist/synth/permit-context.d.ts +15 -0
  28. package/dist/synth/permit-context.js +116 -0
  29. package/dist/synth/synthesize-from-recording.js +14 -2
  30. package/dist/types.d.ts +9 -0
  31. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  32. package/dist-cjs/errors.d.ts +1 -1
  33. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  34. package/dist-cjs/install/build-add-context-rule.js +308 -0
  35. package/dist-cjs/ir/types.d.ts +4 -0
  36. package/dist-cjs/predicate/decode.d.ts +11 -0
  37. package/dist-cjs/predicate/decode.js +239 -0
  38. package/dist-cjs/predicate/encode.js +79 -14
  39. package/dist-cjs/predicate/from-json.d.ts +4 -0
  40. package/dist-cjs/predicate/from-json.js +116 -0
  41. package/dist-cjs/predicate/index.d.ts +2 -0
  42. package/dist-cjs/predicate/index.js +10 -2
  43. package/dist-cjs/registry/identify.js +20 -1
  44. package/dist-cjs/registry/protocols.d.ts +50 -1
  45. package/dist-cjs/registry/protocols.js +89 -1
  46. package/dist-cjs/review-card/builder.d.ts +13 -0
  47. package/dist-cjs/review-card/builder.js +32 -5
  48. package/dist-cjs/review-card/cross-check.js +12 -3
  49. package/dist-cjs/review-card/index.d.ts +1 -1
  50. package/dist-cjs/review-card/index.js +2 -1
  51. package/dist-cjs/run/schemas.d.ts +4 -4
  52. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  53. package/dist-cjs/synth/compose-from-recording.js +34 -8
  54. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  55. package/dist-cjs/synth/deny-cases.js +3 -0
  56. package/dist-cjs/synth/evaluate.js +130 -3
  57. package/dist-cjs/synth/permit-context.d.ts +15 -0
  58. package/dist-cjs/synth/permit-context.js +119 -0
  59. package/dist-cjs/synth/synthesize-from-recording.js +14 -2
  60. package/dist-cjs/types.d.ts +9 -0
  61. package/package.json +5 -2
  62. package/src/adapters/interpreter/adapter.ts +72 -11
  63. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  64. package/src/errors.ts +13 -0
  65. package/src/install/build-add-context-rule.ts +429 -0
  66. package/src/ir/types.ts +4 -0
  67. package/src/predicate/decode.ts +242 -0
  68. package/src/predicate/encode.ts +110 -13
  69. package/src/predicate/from-json.ts +124 -0
  70. package/src/predicate/index.ts +5 -1
  71. package/src/registry/identify.ts +22 -2
  72. package/src/registry/protocols.ts +90 -1
  73. package/src/review-card/builder.ts +31 -5
  74. package/src/review-card/cross-check.ts +12 -3
  75. package/src/review-card/index.ts +1 -0
  76. package/src/synth/compose-from-recording.ts +46 -7
  77. package/src/synth/deny-cases.ts +3 -1
  78. package/src/synth/evaluate.ts +131 -3
  79. package/src/synth/permit-context.ts +137 -0
  80. package/src/synth/synthesize-from-recording.ts +13 -2
  81. package/src/types.ts +12 -0
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ // src/synth/permit-context.ts - build the EvalContext that represents the
3
+ // recorded call being replayed.
4
+ //
5
+ // Extracted from the over-permissiveness harness so the same construction
6
+ // feeds both the in-process battery and the on-chain replay
7
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
8
+ // separately would be two chances to disagree about what "the recorded
9
+ // call" means, which is the one thing both must share.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.buildPermitContext = buildPermitContext;
12
+ const deny_cases_ts_1 = require("./deny-cases.js");
13
+ function buildPermitContext(tx, responses, predicate) {
14
+ const amountByToken = {};
15
+ const totals = new Map();
16
+ for (const m of tx.tokenMovements) {
17
+ const current = totals.get(m.token) ?? 0n;
18
+ totals.set(m.token, current + BigInt(m.amount));
19
+ }
20
+ for (const [token, total] of totals) {
21
+ amountByToken[token] = total.toString();
22
+ }
23
+ const topLevel = tx.invocations[0];
24
+ const scopeContract = topLevel?.contract ?? '';
25
+ const oraclePriceByAsset = {};
26
+ visitOracleLeaves(predicate, (asset, op, bound) => {
27
+ let price;
28
+ switch (op) {
29
+ case 'lt':
30
+ price = bound - 1n;
31
+ break;
32
+ case 'gt':
33
+ price = bound + 1n;
34
+ break;
35
+ default:
36
+ price = bound;
37
+ }
38
+ if (price < 0n)
39
+ price = 0n;
40
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
41
+ });
42
+ const ctx = {
43
+ contract: scopeContract,
44
+ fn: topLevel?.fn ?? '',
45
+ args: (topLevel?.args ?? []).map(deny_cases_ts_1.cloneScVal),
46
+ atLedger: tx.ledgerSequence,
47
+ nowSeconds: tx.fetchedAt,
48
+ amountByToken,
49
+ windowSpentByToken: {},
50
+ invocationCountByWindow: {},
51
+ oraclePriceByAsset,
52
+ };
53
+ if (responses.validUntilLedger !== undefined) {
54
+ ctx.validUntilLedger = responses.validUntilLedger;
55
+ }
56
+ return ctx;
57
+ }
58
+ function visitOracleLeaves(node, visit) {
59
+ switch (node.op) {
60
+ case 'and':
61
+ case 'or':
62
+ for (const child of node.children)
63
+ visitOracleLeaves(child, visit);
64
+ return;
65
+ case 'not':
66
+ visitOracleLeaves(node.child, visit);
67
+ return;
68
+ case 'eq':
69
+ case 'lt':
70
+ case 'lte':
71
+ case 'gt':
72
+ case 'gte': {
73
+ const leftIsOracle = node.left.kind === 'oracle_price';
74
+ const rightIsOracle = node.right.kind === 'oracle_price';
75
+ let asset;
76
+ let literal;
77
+ if (leftIsOracle) {
78
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
79
+ literal = oracleThresholdNormalised(node.right);
80
+ }
81
+ else if (rightIsOracle) {
82
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
83
+ literal = oracleThresholdNormalised(node.left);
84
+ }
85
+ if (asset !== undefined && literal !== undefined) {
86
+ visit(asset, node.op, literal);
87
+ }
88
+ return;
89
+ }
90
+ case 'in':
91
+ return;
92
+ }
93
+ }
94
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
95
+ * oracle.rs. */
96
+ const NORMALISED_DECIMALS = 9n;
97
+ /** A threshold restated on the normalised basis, so a derived permit price is
98
+ * comparable to it. Thresholds carry their own basis, so the conversion has
99
+ * to happen here - reading the digits raw would build a context off by a
100
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
101
+ function oracleThresholdNormalised(leaf) {
102
+ if (leaf.kind !== 'oracle_threshold')
103
+ return undefined;
104
+ let value;
105
+ try {
106
+ value = BigInt(leaf.value);
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ const decimals = BigInt(leaf.decimals);
112
+ if (decimals <= NORMALISED_DECIMALS) {
113
+ return value * 10n ** (NORMALISED_DECIMALS - decimals);
114
+ }
115
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
116
+ // caller only needs a price that lands on the right side of the bound, and
117
+ // it offsets by one from here.
118
+ return value / 10n ** (decimals - NORMALISED_DECIMALS);
119
+ }
@@ -812,13 +812,25 @@ function visitOracleLeaves(node, visit) {
812
812
  return;
813
813
  }
814
814
  }
815
+ /** An oracle threshold restated on the normalised 9-dp basis prices use.
816
+ * Thresholds carry their own basis, so reading the digits raw would build a
817
+ * permit context off by 10^(decimals-9) and the intended call would not
818
+ * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
815
819
  function oracleLiteralFromLeaf(leaf) {
816
- if (leaf.kind !== 'literal_i128')
820
+ if (leaf.kind !== 'oracle_threshold')
817
821
  return undefined;
822
+ let value;
818
823
  try {
819
- return BigInt(leaf.value);
824
+ value = BigInt(leaf.value);
820
825
  }
821
826
  catch {
822
827
  return undefined;
823
828
  }
829
+ const normalised = 9n;
830
+ const decimals = BigInt(leaf.decimals);
831
+ if (decimals <= normalised)
832
+ return value * 10n ** (normalised - decimals);
833
+ // Floor: a finer-grained threshold has no exact 9-dp representation; the
834
+ // caller offsets by one from here to land on the right side of the bound.
835
+ return value / 10n ** (decimals - normalised);
824
836
  }
@@ -190,6 +190,11 @@ export type PredicateLeaf = {
190
190
  index: number;
191
191
  element: number;
192
192
  field: string;
193
+ } | {
194
+ kind: 'call_arg_scaled';
195
+ index: number;
196
+ num: string;
197
+ den: string;
193
198
  } | {
194
199
  kind: 'amount';
195
200
  token: string;
@@ -207,6 +212,10 @@ export type PredicateLeaf = {
207
212
  } | {
208
213
  kind: 'oracle_price';
209
214
  asset: string;
215
+ } | {
216
+ kind: 'oracle_threshold';
217
+ value: string;
218
+ decimals: number;
210
219
  } | {
211
220
  kind: 'literal_address';
212
221
  value: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -67,11 +67,14 @@
67
67
  "prepack": "bun run build"
68
68
  },
69
69
  "dependencies": {
70
- "@stellar/stellar-sdk": "14.4.0",
71
70
  "zod": "3.25.76"
72
71
  },
72
+ "peerDependencies": {
73
+ "@stellar/stellar-sdk": "^14.4.0"
74
+ },
73
75
  "devDependencies": {
74
76
  "@biomejs/biome": "2.5.5",
77
+ "@stellar/stellar-sdk": "14.4.0",
75
78
  "@types/node": "^26.1.1",
76
79
  "typescript": "5.9.3"
77
80
  }
@@ -90,7 +90,10 @@ export interface InterpreterAdapterConfig {
90
90
  const CAPABILITIES: CustodyCapabilities = {
91
91
  supportsSpendWindow: true,
92
92
  supportsThreshold: false, // thresholds are the OZ adapter's job
93
- supportsTimeExpiry: true, // via valid_until ledger expiry
93
+ // False for the predicate-leaf path: the interpreter refuses a `valid_until`
94
+ // leaf at install. Expiry is still available, but as the context rule's
95
+ // validUntilLedger, which the smart account enforces - not as a predicate.
96
+ supportsTimeExpiry: false,
94
97
  supportsOraclePrice: true,
95
98
  supportsInvocationCount: true,
96
99
  supportsGeneralPredicate: true,
@@ -296,24 +299,64 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
296
299
  function unsupportedConstruct(cond: IRCondition): string | null {
297
300
  switch (cond.op) {
298
301
  case 'in':
299
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
300
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
301
- : null
302
+ // The oracle position rule is a hard error and must be raised before a
303
+ // construct can be written off as uncovered - otherwise a subtree that
304
+ // is BOTH misplaced-oracle and unsourceable would be silently dropped
305
+ // instead of rejected.
306
+ assertNoOracleDescendants(cond)
307
+ return (
308
+ unsourceableSelector(cond.selector) ??
309
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
310
+ ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
311
+ : null)
312
+ )
302
313
  case 'eq_seq':
303
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
304
- ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
305
- : null
314
+ return (
315
+ unsourceableSelector(cond.selector) ??
316
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
317
+ ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
318
+ : null)
319
+ )
306
320
  case 'compare': {
307
321
  const s = cond.compare.selector
308
322
  if (s.kind === 'calldata') return 'EVM calldata comparison (predicate DSL)'
309
323
  if (s.kind === 'value') return 'tx.value comparison (predicate DSL)'
310
- return null
324
+ // The on-chain interpreter sees ONE authorized call - there is no
325
+ // `Context.sub_invocations` in v1 - so it cannot observe the
326
+ // transaction's token movements. `amount` has no value to read, and
327
+ // `window_spent` accumulates BY that amount, so its counter would never
328
+ // move. Deriving either from the call payload would quietly swap "value
329
+ // actually moved" for "value the caller declared" - a weaker guarantee
330
+ // than the review card would be claiming.
331
+ //
332
+ // Rolling spend caps belong to the OZ `spending_limit` primitive, which
333
+ // is already audited and which the OZ adapter emits. A per-call cap is
334
+ // expressible here as `arg_field`; bounding it with `invocation_count`
335
+ // gives an enforceable ceiling per window.
336
+ return unsourceableSelector(s)
311
337
  }
338
+ // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
339
+ // pre-scan, which only sees top-level constraints.
312
340
  case 'and':
341
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
313
342
  case 'or':
343
+ assertNoOracleDescendants(cond)
344
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
314
345
  case 'not':
315
- return null
346
+ assertNoOracleDescendants(cond)
347
+ return unsupportedConstruct(cond.child)
348
+ }
349
+ }
350
+
351
+ /** Selectors whose value the on-chain interpreter has no way to obtain. */
352
+ function unsourceableSelector(s: IRSelector): string | null {
353
+ if (s.kind === 'amount') {
354
+ return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`
355
+ }
356
+ if (s.kind === 'window_spent') {
357
+ return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`
316
358
  }
359
+ return null
317
360
  }
318
361
 
319
362
  /** Lower one IR condition to a PredicateNode. Enforces the oracle position
@@ -393,10 +436,15 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
393
436
  return { kind: 'call_arg_len', index: s.argIndex }
394
437
  case 'arg_field':
395
438
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
439
+ // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
440
+ // before lowering - the interpreter cannot source either on chain. Reaching
441
+ // here means the pre-scan was bypassed, so fail loudly rather than emit a
442
+ // leaf the contract will refuse.
396
443
  case 'amount':
397
- return { kind: 'amount', token: s.token }
398
444
  case 'window_spent':
399
- return { kind: 'window_spent', token: s.token, windowSeconds: s.windowSeconds }
445
+ throw new Error(
446
+ `interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`
447
+ )
400
448
  case 'invocation_count':
401
449
  return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds }
402
450
  case 'now':
@@ -427,6 +475,19 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
427
475
  * - invocation_count -> u32 (counts are small non-negative integers)
428
476
  * - now / valid_until -> u64 (unix timestamps in seconds) */
429
477
  function literalFromIRCompare(c: IRCompare): PredicateLeaf {
478
+ // An oracle comparand is not a bare literal: the interpreter refuses one,
479
+ // because assuming it shares the normalised 9-dp basis is what made a raw
480
+ // 14-dp threshold permit everything it was written to deny.
481
+ if (c.selector.kind === 'oracle_price') {
482
+ if (c.valueDecimals === undefined) {
483
+ throw toolError(
484
+ 'ORACLE_THRESHOLD_BASIS_REQUIRED',
485
+ 'An oracle price bound must declare the decimal basis of its threshold ' +
486
+ '(oracle prices normalise to 9 decimals).'
487
+ )
488
+ }
489
+ return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals }
490
+ }
430
491
  const scalarType: IRScalarType = selectorScalarType(c.selector)
431
492
  return literalFromScalar(c.value, scalarType)
432
493
  }
@@ -113,15 +113,40 @@ pub fn add_context_rule(
113
113
  - `add_context_rule` is called by the **smart account itself** (the contract owning the context rules).
114
114
  - Auth is enforced by Soroban's `require_auth` on the calling context — the smart account's master signer (or a delegated signer with sufficient weight) must authorize the call.
115
115
  - The interpreter's `install` function is called **within the same transaction** as `add_context_rule` and receives the same `smart_account` address.
116
- - **The interpreter does NOT need to read the smart account's master set** — the master signer authorizes `add_context_rule` directly; the interpreter receives the already-authorized `ContextRule` and `smart_account` address.
116
+ - The interpreter **cannot** read the smart account's master set; it receives only the already-authorized `ContextRule` and `smart_account` address.
117
+
118
+ > **CORRECTED 2026-07-27 - do not read the line above as a safety guarantee.**
119
+ > An earlier version of this file said the interpreter "does NOT need to" read the
120
+ > master set because the master authorizes `add_context_rule` directly. The master
121
+ > *can* authorize it. It is **not** true that only the master can.
122
+ >
123
+ > Verified at this pinned commit: `storage.rs` states `add_policy` /
124
+ > `add_context_rule` modify storage "without requiring authorization. Ensure proper
125
+ > access control is implemented at the contract level"; `mod.rs:439` and `mod.rs:246`
126
+ > implement that as `e.current_contract_address().require_auth()`, a self-auth
127
+ > resolved through the account's own context rules; and `mod.rs:80` states that
128
+ > "a `Default` rule matches any context".
129
+ >
130
+ > Therefore any signer on a `Default` rule can attach or replace a policy - including
131
+ > a delegated agent, on its own rule. See `plans/security-finding-install-authorization.md`.
117
132
 
118
133
  ### Safe mechanism for master-only install
119
134
 
120
- The committed safe mechanism is **two-step install** (not out-of-band master const):
135
+ Two-step install is the install **path**. It is NOT relied on as the authorization
136
+ **guarantee**, because the auth it inherits is whatever the account's own rules permit:
137
+
121
138
  1. Owner calls `add_context_rule` with the interpreter address + initial rule.
122
- 2. Interpreter's `install` is called within the same auth context — the `Context` passed to `Policy::enforce` confirms the call is authorized by the smart account's current signer set.
139
+ 2. Interpreter's `install` is called within the same auth context.
123
140
  3. Re-install is structurally possible via `install_nonce` (stored nonce + 1 check).
124
141
 
142
+ The guarantee comes from the interpreter pinning its own authorized installer set at
143
+ first install for `(smart_account, rule_id)`, and denying later installs and
144
+ uninstalls from outside that set with `MASTER_AUTH_REQUIRED`. Rotation goes through
145
+ `rotate_master_signer_set(new_set)`, gated by the old set.
146
+
147
+ `install_nonce` alone does not close this: it blocks replay of an *old* document, but
148
+ a *fresh* looser document with `nonce + 1` would otherwise be accepted.
149
+
125
150
  ---
126
151
 
127
152
  ## Framework Limits (OZ Hard Limits)
package/src/errors.ts CHANGED
@@ -39,9 +39,20 @@ export type ErrorCode =
39
39
  | 'AMOUNT_OVERFLOW'
40
40
  | 'RULE_SIGNERS_CHANGED'
41
41
  | 'SCOPE_SELF_CALL'
42
+ | 'ARG_MISMATCH'
43
+ | 'CONTRACT_SCOPE'
44
+ | 'UNSUPPORTED_NODE'
45
+ | 'STATEFUL_BOUND'
46
+ | 'NOT_IN_ALLOWLIST'
47
+ | 'FREQUENCY'
48
+ // A `call_arg >= call_arg_scaled(..)` slippage floor was not met. Distinct
49
+ // from the generic ARG_MISMATCH/STATEFUL_BOUND so a violated swap floor
50
+ // reads as itself on the review card.
51
+ | 'SLIPPAGE_FLOOR'
42
52
  // --- oracle denies (fatal evaluator errors) ---
43
53
  | 'ORACLE_STALE'
44
54
  | 'ORACLE_MISSING'
55
+ | 'ORACLE_NO_CONFIRMATION'
45
56
  | 'ORACLE_DEVIATION_EXCEEDED'
46
57
  | 'ORACLE_MALFORMED_HISTORY'
47
58
  | 'ORACLE_FINGERPRINT_DRIFT'
@@ -49,6 +60,8 @@ export type ErrorCode =
49
60
  | 'ORACLE_LEAF_INVALID_POSITION'
50
61
  | 'ORACLE_PARAMS_OUT_OF_RANGE'
51
62
  | 'ORACLE_DECIMALS_MISMATCH'
63
+ | 'ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE'
64
+ | 'ORACLE_THRESHOLD_BASIS_REQUIRED'
52
65
  // --- escape-hatch compile gate ---
53
66
  | 'COMPILE_OK'
54
67
  | 'COMPILE_GATE_FAILED'