---
title: "Risk Steward Operations"
description: "Operator runbook for the bounded risk key: the one entrypoint, the exact clamp rules, what fails closed, and what to verify before and after a retune."
audience: tech
type: guide
status: live
lang: en
updated: "2026-09-04"
publish: true
---
# Risk steward operations

For the holder of a risk-steward grant (`AccessControl.isRiskSteward(addr)`) and for whoever
operates the automation that signs as one. The place of the role in the authority model is
[Access Control, Roles & Emergency Powers §3](/docs/3-1-access-control-roles-emergency-powers#3-risk-steward);
why risk parameters carry no timelock at all is
[Deployment & Upgrades §7.3](/docs/3-2-deployment-upgrades#73-risk-parameters-are-deliberately-not-timelocked).
This page is the procedure and the exact bounds.

---

## 1. The role is one function

`Admin.setAssetParamsBounded(address pool, address token, uint128 minLiquidity, uint16 minFeePbps,
uint16 vegaBps, uint16 haircutSuppressorBps)`, gated by `_onlyRiskStewardOrAdmin`. That is the entire
surface. It **never queues**: the call either writes in the same transaction or reverts.

The grant itself is contract-only: `AccessControl.setRiskSteward(s, true)` runs `_validateAddr`,
which reverts `NotCode` for an address with no code. Revocation is never policy-checked, by the same
rule guardians follow: a steward that turns out to be wrong must stay instantly removable.

| The steward can | The steward cannot |
|---|---|
| Raise or lower `minFeePbps` and `vegaBps` inside the owner's fences | Move `minLiquidity` at all (§2) |
| Write `haircutSuppressorBps`, always relatively clamped and floored on a decrease | List an asset, install a curve, repoint an oracle, or change fees for the pool |
| Act with no delay, at the tempo the market moves | Halt, un-halt, pause, or cancel anything |
| - | Queue or execute a timelocked op, or move value |

Everything in the right-hand column is an owner action, except halting, which is a guardian action
([Guardian Operations](/docs/3-9-guardian-operations)). The steward exists so that the fast half of
risk management does not require the owner key, and so that reaching for it cannot become a
back-door to the rest.

---

## 2. The clamp rules, exactly

`setAssetParamsBounded` applies five checks in this order. Getting the order and the exemptions right
is the difference between a plan that lands and one that reverts opaquely mid-incident.

**1. Fences must be armed.** `RiskFences` are per `(pool, token)` and set by the owner via
`setRiskFences`. If `maxDeltaBps == 0` the call reverts `NotConfigured(ASSET, token)`. The lane
**fails closed** until the owner arms it: an unfenced asset is not a free-for-all, it is unreachable.

**2. `minLiquidity` must pass through unchanged.** If the value differs from the live
`Asset.minLiquidity`, the call reverts `InvalidInput`. Read the live asset and echo the field back.
Deposit-cap changes are an owner action on the unbounded `setAssetParams` lane, not this one.

**3. Hard fences (`_enforceHard`), absolute and always applied.**

| Field | Bound | Revert |
|---|---|---|
| `minFeePbps` | `[minFeeHardMinPbps, minFeeHardMaxPbps]` | `ThresholdViolation` naming the breached side |
| `vegaBps` | `[vegaHardMinBps, vegaHardMaxBps]` | `ThresholdViolation` |
| `haircutSuppressorBps` | `<= haircutSuppressorHardMaxBps` | `ThresholdViolation` |

Note the asymmetry: the suppressor is fenced from **above only** here. Its lower bound is check 5.

**4. The relative clamp, and the exemption that applies to two fields and not the third.**

```
tighten = (minFeePbps >= cur.minFeePbps) && (vegaBps >= cur.vegaBps)
```

When `tighten` is true, the relative clamp is skipped for `minFeePbps` and `vegaBps`. It is
**never** skipped for `haircutSuppressorBps`: that field is relatively clamped on every call,
tighten or not. The reason is that a suppressor *drop* is defensive for the pool but realizes LP
loss on the spot, so exempting it would let the lower-trust key zero it in one unbounded call while
the owner lane queues the restore for a day.

The clamp itself (`_relOk(old, new, maxDeltaBps)`):

- `new == old` passes trivially.
- `old == 0` reverts `BadConfig`. **A steward can never move a parameter off zero**; the owner seeds
  it first.
- otherwise `|new - old| · 10000 <= old · maxDeltaBps`, else `ThresholdViolation`.

**5. The absolute floor on a suppressor decrease.** If `haircutSuppressorBps < cur.haircutSuppressorBps`
**and** `haircutSuppressorBps < haircutSuppressorHardMinBps`, revert `ThresholdViolation`. This is
the check that actually stops a ratchet: `_relOk` is stateless per call, bounding each *step* and
never the cumulative *displacement*, so N calls in a single block compose geometrically. `minFeePbps`
and `vegaBps` survive that composition because they have absolute bounds on both sides; the
suppressor had only a ceiling, leaving the one direction that realizes LP loss unfenced. It is gated
on a strict decrease rather than written as a flat bound because a coverage-walled asset (κ > 0) is
required to hold `haircutSuppressorBps == 0` permanently, and a flat floor would forbid that legal
resting value.

On success the call emits `BoundedAssetParamsUpdated(pool, token, minFeePbps, vegaBps, tighten)`;
the `tighten` flag tells you which lane the write actually took.

---

## 3. What the owner's fences mean for you

You consume fences; you never write them. Their shape constrains what plans are even expressible:

- `maxDeltaBps` is in `(0, 10000]`. It is the per-step relative budget for every clamped field.
- `minFeeHardMinPbps` is required non-zero. A zero floor is not a floor; it would leave the ratchet
  open while looking fenced.
- `haircutSuppressorHardMaxBps == 0` is the **pinned** case, not a hole: every non-zero suppressor is
  then rejected, so the only writable value is `0`, which is the resting value a κ-walled asset must
  hold. In that configuration `haircutSuppressorHardMinBps` must also be `0`.
- The owner's own unbounded `setAssetParams` is still floored by an armed `minFeeHardMinPbps`
  (`_requireFeeFloor`), re-applied at `executeSetAssetParams`. Lowering below an armed fence is
  deliberately two transactions, owner or not.
- That fee floor is the **only** fence that binds the owner lane. `vegaHardMinBps` /
  `vegaHardMaxBps`, `haircutSuppressorHardMaxBps` / `haircutSuppressorHardMinBps` and `maxDeltaBps`
  are read by `setAssetParamsBounded` alone: the owner path writes any vega or suppressor the global
  `PoolConfig` bounds admit, immediately when the write is a defensive tighten and through the LOW
  queue otherwise. A fence set to constrain a steward does not constrain the key that set it.

If a plan you need is not expressible inside the live fences, the answer is a fence change by the
owner, not a sequence of steward calls that walks there.

---

## 4. What this key is the last line of defence against

Risk parameters have no timelock on purpose: adaptivity is the edge, and a fee floor that is correct
for yesterday's tape is a subsidy today. The steward lane is what makes that safe: a key that can
move the fast scalars within the hour, but only inside bounds the owner set in advance and only in
steps small enough to be observed and reversed.

Concretely, the exposures it closes:

| Exposure | Lever | Bound |
|---|---|---|
| Adverse selection outrunning the deployed fee floor | Raise `minFeePbps` | Exempt from the relative clamp while tightening; still inside `minFeeHardMaxPbps` |
| Realized volatility outrunning the quoted band | Raise `vegaBps`, the sensitivity knob of the adaptive dispersion law | Same exemption, still inside `vegaHardMaxBps` |
| A fee floor left punitively high after a regime passes | Lower `minFeePbps` | Relatively clamped; floored by `minFeeHardMinPbps` |

σ itself is not a steward lever and does not need to be: it adapts at push cadence through the
signed oracle blob with zero governance latency
([Oracles §8.3](/docs/3-4-oracles#83-deviation-bounds)). Sub-hour band adaptation therefore needs
only vega to track its target.

Shape is not a steward lever either. Splines are not hot-updatable, so a shape change repoints at a
pre-certified preset through the owner's `requestOp(UPDATE_PROFILE)` queue at the `LOW` tier. The
dispersion band's ceiling is not a lever at all; it is the protocol constant
`PoolConstantsLib.MAX_DISPERSION_PBPS`.

---

## 5. The standing invariants

The steward lane is not the thing that detects a breach; it is the thing that fixes one. Detection
is the risk keeper's guardian predicates, which **observe and alert only**: that module holds no
executor and builds no plan, so there is no path from a breach to a transaction. The predicate set
and its severities are [Observability §12](/docs/3-8-observability#12-risk-parameter-retuning); the
re-page cooldown is six hours per predicate and key, chosen so a standing breach stays visible
without training the operator to filter the channel.

The one to internalise, because it couples this role to the oracle keeper:

> **`minFeePbps >= 2θ`** per supervised pool × asset, where θ is what the push keeper is *actually*
> running for that feed, not what the config you are editing says.

A fee floor below twice the push threshold is an anti-pick-off failure: the mark can move by θ
between pushes and a round trip inside `2θ` is free. The oracle keeper checks the same invariant
over its own `pools` list, which is empty in some shipped configs and therefore silent; the risk
keeper checks it over every supervised pool because it already holds both halves of the comparison.

θ and the curve parameters are one optimisation variable. A θ change therefore ships as an **atomic
bundle** (the fast scalars, the preset repoint, and an edit to the push keeper's `oracle.*.toml`),
never as three independent deploys. A partial deploy silently breaks the between-push discipline.

---

## 6. If the automation signs for you

`btr-keeper risk` is the supervised implementation of this role. What it enforces is worth knowing
even for a manual retune, because the same reasoning applies.

- **Double gate.** Live requires both `--execute` and `RISK_EXECUTE=1`, and signs with
  a dedicated steward key, never a money-path key.
- **Refuses the stronger key.** Startup reads `AccessControl.owner()` and aborts if it equals the
  configured signer. The owner also satisfies `_onlyRiskStewardOrAdmin`, and a keeper signing as
  owner is a far larger blast radius than the bounded lane.
- **Refuses to arm without the role.** `AccessControl.isRiskSteward(signer)` is read at startup:
  two `eth_call`s, never per sweep. Fatal when armed; in dry-run it reports and disables transaction
  simulation, so that a `NotAuth` revert is not the only thing a dry run ever shows.
- **Global circuit breaker.** `max_updates_per_h` caps broadcast param updates per rolling hour
  across every pool and asset, and must land in `[1, 30]`: an armed keeper rejects `0` and anything
  above the `MAX_UPDATES_PER_H_CEILING` of 30. Per-asset limits alone are not a cap: every asset can
  satisfy its own simultaneously.
- **Refuses stale inputs.** `max_fit_age_h` bounds the artifact half, in `[1, 8760]` hours, and
  `max_measure_age_h` the live tape half, in `[1, 168]`. A class-default fallback row, or one whose
  `tapeStatus` is not `ok`, is refused outright unless `allow_provisional` is set; with it set the
  row may only **tighten**, and a loosen off a provisional row holds as `ProvisionalLoosen`.
- **Asymmetric deadbands and dwell.** A tighten crosses a smaller deadband than a loosen, and a
  tighten **bypasses dwell entirely**: only a loosen has to persist. Which floor it persists against
  is decided by the lever, not by the word "band": a θ move **or** a `minDispersionPbps` (scale) move
  takes `dwell_shape_s`, floored at 24 h, because both re-base the whole quote; the 1 h
  `dwell_band_s` floor applies only to the fee/vega FAST lane. `cooldown_s` is required to be at
  least `dwell_band_s`, so the emission tempo can never outrun the fastest dwell, and a successful
  emission resets the dwell clock rather than carrying the previous window forward.
- **An explicit hold list.** Assets under review are named in config, because a hold that lives only
  in a shell comment is not a control.
- **A pre-submit mirror of the chain.** The clamp rules of §2 are re-implemented off chain and a plan
  that would revert is never built, so failures read as a named invariant rather than an opaque
  `ThresholdViolation`.

None of the numbers above are protocol constants, and none of them are on chain. Deadbands, dwell,
caps, θ floors and per-asset vega targets are deployment-specific and live in the risk keeper's
`risk.<chain>.toml`, bounded by the keeper-side floors and ceilings named above, which no config may
go under or over; the fences that bound the writes themselves live on chain in
`Admin.riskFences(pool, token)`.

---

## 7. Before a write

- Read the live asset, `IPool(pool).getAsset(token)`: you need `minLiquidity` verbatim,
  and `minFeePbps` / `vegaBps` / `haircutSuppressorBps` as the denominators of every relative check.
- Read `Admin.riskFences(pool, token)`. `maxDeltaBps == 0` means the lane is shut; stop and ask the
  owner to arm it rather than retrying.
- Classify your own plan: compute `tighten` yourself. If it is false, both `minFeePbps` and `vegaBps`
  must individually fit `maxDeltaBps`, and a plan that assumed an exemption will revert.
- Check the suppressor separately, always. It is clamped on every call and floored on any strict
  decrease.
- Check the destination against the hard fences before the step size, because a step that fits
  `maxDeltaBps` can still land outside `[hardMin, hardMax]`.
- Check `minFeePbps >= 2θ` against the θ the push keeper is running now (§5).
- Simulate. The lane never queues, so there is no window in which to notice a mistake.

---

## 8. After a write

- `getAsset(token)` reflects the new `minFeePbps` and `vegaBps`, and `minLiquidity` is untouched.
- `BoundedAssetParamsUpdated` is in the receipt, and its `tighten` flag matches your classification.
  A mismatch means your model of the live state was stale.
- Realized fee is at or above the new floor: `feeAvgBps` against `minFeePbps / 100`
  ([Observability §8](/docs/3-8-observability#8-swap-pricing-and-units)). There is no maximum; below
  the floor means mispriced risk.
- No second write followed within the same block or sweep unless you intended a composed move;
  `_relOk` bounds the step, not the displacement.
- The guardian predicates that motivated the change have cleared, and no new one has opened.

Parameter and halt events are snapshot triggers rather than indexed topics, so retuning is **not**
queryable from the indexer ([Observability §12](/docs/3-8-observability#12-risk-parameter-retuning)).
Verify against chain state.

---

## 9. Escalate

| Need | Owner or guardian |
|---|---|
| Fences too tight for the required plan, or unarmed (`NotConfigured`) | Owner: `setRiskFences` |
| A `minLiquidity` / deposit-cap change | Owner: `setAssetParams`; queues unless it is a defensive tighten |
| A preset repoint or dispersion floor change | Owner: `requestOp(UPDATE_PROFILE)` at the `LOW` tier |
| Fee floor must go below an armed `minFeeHardMinPbps` | Owner: two transactions, `setRiskFences` first, by design |
| The leg must stop trading now | Guardian: `haltAsset` ([Guardian Operations §2](/docs/3-9-guardian-operations#2-the-levers)) |
| A feed is the problem, not the parameters | Guardian: `pauseFeed`, or `updateFeed(feedId, maxDeviationBps, ttlSecs)` on `ExternalOracleV4` to tighten the band and the TTL. Both fields ratchet down from every instant lever, on the owner lane too; loosening either is the owner's timelocked `requestFeedWiden` → `executeFeedWiden` ([Oracles §8.3](/docs/3-4-oracles#83-deviation-bounds)) |

Contact and intake: `security@btr.markets`,
[Access Control §4.1](/docs/3-1-access-control-roles-emergency-powers#41-escalation).

---

## 10. Checklist

**On grant**

- [ ] `isRiskSteward(you)` is true on the `AC` that `Admin.AC()` returns, and the granted address is
      the address that will sign.
- [ ] The signing key is not the owner key and not a money-path key.
- [ ] `riskFences(pool, token)` is armed for every asset you are expected to cover: `maxDeltaBps`,
      both fee bounds, both vega bounds, and the suppressor pair.
- [ ] You can read live `getAsset` for every covered leg without the front-end.
- [ ] You know the θ each covered feed is running, and where that number is configured.

**Before each write**

- [ ] Live `Asset` read this block; `minLiquidity` echoed verbatim.
- [ ] `maxDeltaBps != 0`.
- [ ] `tighten` computed by hand; if false, every clamped field fits `maxDeltaBps`.
- [ ] Suppressor checked against both the relative clamp and, on any decrease,
      `haircutSuppressorHardMinBps`.
- [ ] Destination inside every hard fence.
- [ ] `minFeePbps >= 2θ` still holds after the change.
- [ ] Simulated against current state, not against the plan that generated it.

**After each write**

- [ ] `getAsset` matches the intended values; `minLiquidity` unchanged.
- [ ] `BoundedAssetParamsUpdated.tighten` matches your classification.
- [ ] Realized `feeAvgBps` at or above the new floor once flow resumes.
- [ ] The rolling-hour update budget still has headroom for a reversal.
- [ ] The originating predicate has cleared and is not re-paging.

---

## 11. Related

| Page | Content |
|---|---|
| [Access Control, Roles & Emergency Powers](/docs/3-1-access-control-roles-emergency-powers) | Where this role sits, and the owner lane beside it |
| [Deployment & Upgrades](/docs/3-2-deployment-upgrades) | Why risk parameters are not timelocked |
| [Guardian Operations](/docs/3-9-guardian-operations) | The halt lever this role escalates to |
| [Oracle Keeper Operations](/docs/3-10-oracle-keeper-operations) | The θ half of the `minFee >= 2θ` invariant |
| [Observability](/docs/3-8-observability) | The predicates and the fee metrics named above |
| [Oracles](/docs/3-4-oracles) | σ adaptation, which is not a steward lever |
