---
title: "Guardian Operations"
description: "Operator runbook for the guardian key: the levers, their arguments, what to check before and after, and when to escalate to the owner."
audience: tech
type: guide
status: live
lang: en
updated: "2026-09-04"
publish: true
---
# Guardian operations

For the holder of an address in `AccessControl.isGuardian`. Who may do what, and why the guardian
surface is shaped the way it is, belongs to
[Access Control, Roles & Emergency Powers §2](/docs/3-1-access-control-roles-emergency-powers#2-guardian-surface-safe-direction)
and [§4](/docs/3-1-access-control-roles-emergency-powers#4-halt-authority). This page is the
procedure: the calls, their arguments, the reads that tell you whether a call will work and whether
it worked, and the point at which the job is no longer yours.

---

## 1. What the key is

Membership is the **address itself**. `AccessControl.setGuardian(g, s)` whitelists one address:
an EOA before `armQuorumPolicy`, a Safe-compatible k-of-n after it. The signers of a guardian Safe
are not guardians; the Safe address is the entry. `guardianQuorumMax` defaults to
`QuorumLib.GUARDIAN_QUORUM_DEFAULT = 1` and is hard-capped at `GUARDIAN_QUORUM_CEILING = 2`, because
above that the lever stops being fast.

Three properties define the whole remit, and every entrypoint below obeys all three:

1. **Direction.** Halt, tighten, cancel. Never un-halt, never widen, never grant.
2. **No value.** No guardian entrypoint moves a token or changes a recipient.
3. **No reverse.** No lever you pull is released by you. Most are released by the owner;
   `UpgradeGate.pause()` is the one that leaves a state to undo. `ExternalOracleV4.updateFeed` is
   released only by the owner's timelocked `requestFeedWiden` → `executeFeedWiden`, which you may
   veto but not call (§2).

One entrypoint, `setFeedExpBias`, sits outside properties 1 and 2 as a break-glass price write; it
carries its own warning in §2.

The consequence is that a wrong guardian call costs downtime and a late one costs the pool. Size
your hesitation accordingly.

---

## 2. The levers

Each call below runs `requireGuardianOrOwner(AC)` (or, on `UpgradeGate`,
`isGuardianOrAuth(msg.sender, authority)`). The `AC` a target reads is its own immutable: a
guardian on one deployment is nobody on another.

| Contract | Call | Effect | Released by |
|---|---|---|---|
| `Admin` | `haltAsset(address pool, address token, uint16 src)` | Sets halt bits on one leg | `unhaltAsset`, owner |
| `Admin` | `batchRiskOp(address[] pools, address[] tokens, BatchOp op, uint16 src)` | Same, across pairs, one tx | `batchRiskOp(UNHALT)`, owner |
| `Admin` | `collapseAnchor(address pool, address token, address newAnchor)` | Re-anchors one leg onto a strict ancestor **and** halts it. **Unreachable on a depth-1 pool — see below** | `unhaltAsset` + `UPDATE_ANCHOR` queue, owner |
| `Admin` | `cancelTimelock(address pool, uint8 opType, bytes32 subject)` | Vetoes any queued pool governance op | Owner re-requests |
| `Admin` / `Flash` | `pause()` | Freezes `executeUpgrade` on that singleton | `unpause`, owner |
| `Admin` / `Flash` | `cancelUpgrade()` | Discards the pending UUPS request | Owner re-requests |
| `PoolFactory` | `cancelReferenceUpgrade()` | Discards the pending beacon request | Owner re-requests |
| `ExternalOracleV4` | `pauseFeed(bytes32 feedId)` | Fail-closes one feed. Next release: also clears the lane and anchors the frozen mark, so the feed reads DEAD through the pause **and** across the release (§4) | `unpauseFeed`, owner |
| `ExternalOracleV4` | `updateFeed(bytes32 feedId, uint16 maxDeviationBps, uint16 ttlSecs)` | Tightens the per-push band **and** the TTL. Tighten-or-equal on both; **nothing widens either back** | Nothing. Owner cannot loosen it either |
| `ExternalOracleV4` | `revokeSession()` | Kills the live push session; the granted relay can land nothing more | `openSession`, k-of-n quorum |
| `ExternalOracleV4` | `revokeSigner(address signer)` | Removes one attester key immediately | `requestSignerGrant` → `BASE` delay, owner |
| `ExternalOracleV4` | `setFeedExpBias(bytes32 feedId, int8 newBias)` | **Break-glass, and a price write.** See the warning below | Another `setFeedExpBias`; no timelock either way |
| `ExternalOracleV4` | `cancelSignerGrant()` | Vetoes the pending signer addition | Owner re-requests |
| `ExternalOracleV4` | `cancelSignerThresholdDecrease()` | Vetoes the pending quorum decrease | Owner re-requests |
| `ExternalOracleV4` | `cancelFeedWiden(bytes32 feedId)` | Vetoes a pending band/ttl widen or wedge release, live or already expired | Owner re-requests |
| `AccessControl` | `cancelRole(Role role)` | Vetoes a queued `FACTORY` / `TREASURY` rotation | Owner re-queues |

**`updateFeed` is a ratchet, and its only inverse is an owner ceremony you can veto but not call.**
The owner's `updateFeed` is the same tighten-or-equal function as yours. **Shipping in the next
release**, a band or TTL tightened too far — or a feed wedged past `10·maxDeviationBps` — is released by the
owner's `requestFeedWiden(feedId, maxDeviationBps, ttlSecs)` → `BASE` delay →
`executeFeedWiden(feedId)`. `narrowMaxDeviation` still does not exist on V4 in any release.

Three things follow for this key. **Your tighten always wins.** The request snapshots the live band
and ttl and the execute reverts `InvalidState` unless both still match, so an `updateFeed` you land
during the delay voids the pending widen rather than being silently undone by it. **You hold the
veto.** `cancelFeedWiden(feedId)` is guardian-or-owner and works on a live request and on an expired
one alike; the owner may re-request immediately, so a cancel buys review time, not an outcome.
**You cannot release.** A wedged feed handed back to quoting is an owner action, and until the
release ships the fallback is a fresh oracle plus a `BASE`-tier `UPDATE_ORACLE` repoint per leg —
37 legs on Arc. Treat your own tighten as irreversible from this key, because it is.

> **`setFeedExpBias` sits outside the three properties above, and it is the one lever on this page
> that writes a price.** Decode is `mark = mant << (exp + bias)`, so a bias change moves the
> published mark by a power of two. It is `_onlyGuardianOrAdmin`, untimelocked, and kept as
> break-glass because the guardian already holds pause and halt. Blast radius, exactly: the target
> lane's price, σ and confidence are zeroed (the feed reads STALE until its next push), the whole
> slot's clock is stamped to now, so **the other seven lanes sharing that slot lose one push cycle**,
> and the new bias takes effect for every subsequent decode of that lane. The routine path is the
> quorum-signed `setFeedExpBiasSigned`, which carries the same k-of-n as a push
> ([Oracles §11.3](/docs/3-4-oracles#113-the-exponent-window-and-expbias)). Whether the guardian arm
> should exist at all is an **open design question**: it is a price lever on a key whose stated remit
> is halt, tighten and cancel. Do not reach for it outside an incident where the alternative is a
> dark feed, and escalate to the owner the moment you do.

**`collapseAnchor` needs a tree at least two deep, so it cannot fire on the live fleet.** The walk starts at the leg's current anchor and steps to *that* node's anchor before its first comparison, so `newAnchor` must be a **strict** ancestor of the current anchor — a grandparent or higher. On the Arc pools every spoke anchors directly to the base and the base anchors to nothing, so the first step lands on `address(0)` and the call reverts `InvalidAnchor` for every leg, base included. It is a topology repair for a depth ≥ 2 pool, not an incident lever. During an incident on this fleet the leg-level lever is `haltAsset` / `batchRiskOp`; re-rooting a leg is the owner's `UPDATE_ANCHOR` op at the `CRITICAL` tier.

`cancelRole(TREASURY_OWNER)` is the one cancel the guardian cannot reach: its authority set is
`owner` or the incumbent `treasuryOwner`, deliberately, so that routing the treasury veto through an
owner-appointed guardian cannot become an indirect seizure path.

Every oracle row above is `_onlyGuardianOrAdmin`, except `revokeSession`, which any one signer may
also call. Which instance a deployment's pools actually read is
[Oracles §11.9](/docs/3-4-oracles#119-migration-status); pausing the idle one stops nothing. On Arc
the live pair is the V4 primary `0x842c2736…` and the V4 reference `0xC17920b2…`; V3 is the rollback
and holds no live leg.

---

## 3. Arguments that decide whether the call does what you meant

**`src` on `haltAsset` / `batchRiskOp`.** One or more bits of `PoolConstantsLib.HALT_MASK`:
`HALT_RISK_BIT` = bit 0 (`0x0001`), `HALT_GUARDIAN_BIT` = bit 6 (`0x0040`). Sources **refcount**:
`unhaltAsset(pool, token, src)` clears only the bits passed, so a leg stays halted while any other
source still holds it. Halt under your own bit. Halting under `HALT_RISK_BIT` is permitted (authority
is by edge, not by source), but it makes your action indistinguishable from an owner risk halt, and
the owner clearing theirs will then relist a leg you meant to hold.

**`op` on `batchRiskOp`.** `BatchOp.HALT` = `0`, `BatchOp.UNHALT` = `1`. A guardian passing `1`
reverts `NotAuth` before any leg is touched.

**Per-leg outcomes on `batchRiskOp`.** The loop is `try`/`catch` per leg, so one bad pair never
bricks the sweep, and a failed leg is **not halted**. The receipt distinguishes them: `BatchRiskOp`
per applied leg, `BatchLegSkipped` per skipped one. Count the events against the arrays you sent.

**`subject` on `cancelTimelock`.** The third key component. For token-keyed ops it is the asset
address left-padded (`bytes32(uint256(uint160(token)))`), and for `UPDATE_CURVE` it is the preset
id. `MIGRATE_BASE_TOKEN`, `UPDATE_TREASURY` and `UPDATE_FEES` are pool-level and ignore it. Cancel
shares `_keyOf` with `requestOp`, so every key a request can create is reachable and a wrong subject
reverts `NoPending` rather than cancelling something else. An unknown or `NONE` `opType` reverts
`InvalidInput`.

**Both arguments on `updateFeed`.** `maxDeviationBps` and `ttlSecs` are written together, and each
must be non-zero and **less than or equal to** the live value; anything else reverts `InvalidInput`.
There is no way to pass one and leave the other alone, so read both off `getFeed(feedId)` first and
pass the current TTL back verbatim unless you mean to shorten it. Passing the live values unchanged
is accepted and is a loud no-op that still emits `FeedUpdated`. A shortened TTL is as irreversible as
a narrowed band, and it fail-closes the leg sooner: at `ttlSecs` the feed reverts `StaleData` and the
leg stops trading.

**`revokeSigner`.** Silently a no-op if the address is not a granted signer. Revoking below
`signerThreshold` is deliberately unblocked: it halts pushing, which is the fail-safe response to a
suspected key compromise, not an error.

---

## 4. What you are the last line of defence against

The automated breakers cover the cases that can be stated as a predicate: staleness TTL, confidence
ceiling, base parity, the per-asset reference band
([Depeg Halt](/docs/3-5-depeg-halt), [Oracle Price-Push Security §5](/docs/3-6-oracle-price-push-security#5-how-a-push-is-guarded-defense-in-depth)).
The guardian exists for what they do not cover:

- A mark that is authentic, in band, fresh, and wrong.
- A queued governance op that should not land.
- A signer key believed leaked.
- An upgrade that should not execute at its ETA.

| Situation | Lever | What it does not fix |
|---|---|---|
| One feed suspect, rest of the catalog healthy | `pauseFeed(feedId)` | The mark is not corrected, only withheld. Every consumer of that feed fail-closes, through the pause and until a push lands after it |
| Attester key believed leaked | `revokeSigner(addr)`, repeat per key | Pushing stops for every feed on that oracle once the set drops below `signerThreshold` |
| Band too wide for the observed tape | `updateFeed` (band down, TTL passed back unchanged) | Nothing already written; it bounds the next push only, and it cannot be widened back |
| Queued loosening you do not trust | The matching `cancel*` | The owner may re-request immediately; a cancel buys review time, not a veto on the outcome |
| A queued band widen you do not trust | `cancelFeedWiden(feedId)`, or an `updateFeed` tighten, which voids it by compare-and-swap | Neither reaches a widen already executed. A released feed reads DEAD until its next push, and that push is unbanded (§2) |
| Matured upgrade you want frozen, not killed | `pause()` on `Admin` / `Flash` | Only `executeUpgrade` is blocked. The request survives and remains executable after `unpause` |
| One leg's anchor chain suspect, on a pool at least two deep | `collapseAnchor(pool, token, newAnchor)` | `newAnchor` must be a **strict ancestor** of the leg's current anchor (`PoolConfig.collapseAnchor` walks up to `AnchorTreeLib.MAX_DEPTH`, else `InvalidAnchor`). On a depth-1 pool no such node exists and the call always reverts (§2). The write halts the leg in the same transaction |
| An asset must stop trading now | `haltAsset` / `batchRiskOp` | Halting a leg does not stop the same asset elsewhere; enumerate ([§4.2](/docs/3-1-access-control-roles-emergency-powers#42-fleet-sweep)) |

Two scope notes are worth internalising before an incident:

- **`Admin.haltAsset`** calls `IPool(pool).adminHaltAsset`, which is gated on the `Admin` singleton,
  not on the pool's creator: pool deployment is permissionless, but administration resolves to the
  same singletons, so the lever reaches **any** pool the fleet's `Admin` governs, official or not.
- **A paused feed** is **skipped**, not reverted: reverting on one paused feed would starve every
  other feed in the same blob. On V4 the same fail-soft covers an unregistered lane, a sentinel
  write, a band breach and an over-cap σ; a skipped lane is reported in
  `LanesSkipped(slotId, laneMask)`. Only framing, quorum, session and acceptance-window failures
  revert the whole push.
- **The pause is fail-closed on release too, shipping in the next release.** `pauseFeed` no longer
  merely sets the bit and leaves the mark standing: it clears the lane's price and confidence and
  anchors the mark it froze together with the observation second the slot then held. So the feed
  reads **DEAD** for the whole pause window and stays dead after `unpauseFeed` until a push lands —
  a consumer reading `getFeed` without `gate` sees the same thing — and that first push back is
  banded over the **real** gap since the frozen mark was observed, not over one cadence. Before this,
  the slot clock kept advancing through a pause (a paused lane's entries still count as accepted, and
  its slot-mates keep pushing), so an unpaused feed reported its stale mark at age ~0 with a zero
  staleness premium, and the correcting push was banded so tightly that the release itself could
  wedge the feed. Two operational consequences: **a pause is not a way to hold a mark**, and the leg
  stays dark for one push cycle after the unpause. The slot clock is deliberately not stamped, so
  the pause costs the other seven lanes nothing ([Oracles §8.1](/docs/3-4-oracles#81-manipulation-resistance)).

A guardian key that can pause feeds is also what the feed-liveness keeper holds; its automated
`pauseFeed` path and the budget that bounds it are
[Oracle Keeper Operations §6](/docs/3-10-oracle-keeper-operations#6-the-feed-liveness-guard).

---

## 5. Before you act

- **Chain.** Confirm the wallet's chain before signing. Pool salts include `block.chainid`, so the
  same nominal address is a different pool elsewhere.
- **Role.** `AccessControl.isGuardian(you)` on the `AC` the *target* reads: `Admin.AC()`,
  `ExternalOracle.AC()`, `PoolFactory` likewise. `quorumStatus()` reports whether the policy is armed
  and whether the live multisig configuration still satisfies it.
- **That something is actually pending**, before any cancel. `pendingSigner` /
  `pendingSignerGrantOp`, `pendingSignerThresholdOp`, `pendingFeedWiden(feedId)` (0 = none),
  `AccessControl.pendingRole(role)`, `UpgradeGate.pendingUpgrade`. `Admin.pendingOps` is **internal**:
  there is no on-chain read for it, so the queue's ETA comes from the `TimelockRequested` event or
  the indexer. A cancel with nothing pending reverts `NoPending` and costs only gas.
- **The legs, before a halt.** Discovery is `PoolFactory.getOfficialPoolsForToken(token)` →
  `getPoolTokens(pool)` ([§4.2](/docs/3-1-access-control-roles-emergency-powers#42-fleet-sweep)).
  `batchRiskOp` takes the two arrays positionally; a length mismatch reverts `InvalidInput`.
- **Both current values, before a tighten.** `getFeed(feedId).maxDeviationBps` and `.ttlSecs`:
  `updateFeed` reverts on anything above either, and a revert here is easy to misread as a role
  problem. Check `pendingFeedWiden(feedId)` in the same read: a tighten on a feed with a request
  queued voids that request, which may be the point or may be an accident.

---

## 6. After you act

Read state, not the transaction. Halt and pause events are snapshot triggers rather than indexed
topics, so the indexer will not answer "what is halted right now"
([Observability §12](/docs/3-8-observability#12-risk-parameter-retuning)).

| You called | Verify | Event |
|---|---|---|
| `haltAsset` / `batchRiskOp` | `Pool.getAsset(token).flags` carries your bit | `AssetHalted` / `BatchRiskOp`, `BatchLegSkipped` |
| `collapseAnchor` | `getAsset(token).anchor` is the new anchor and the guardian bit is set | `AnchorUpdated` |
| `pauseFeed` | `getFeed(feedId)` flags bit 0 set; `isFeedFresh(feedId)` false. Next release: the mark also reads 0 (DEAD), which is the lever working, not a failed write | `FeedPaused` |
| `updateFeed` | `getFeed(feedId).maxDeviationBps` **and** `.ttlSecs` | `FeedUpdated` |
| `revokeSession` | `session()` returns a zero relay | `SessionRevoked` |
| `setFeedExpBias` | `getFeed(feedId)` reads stale; `expHeadroom(feedId)` is `(0, 0)` until the next push | `FeedExpBiasUpdated` |
| `revokeSigner` | `signers(addr)` false; `signerCount` vs `signerThreshold` | `SignerRevoked` |
| `cancelTimelock` / `cancel*` | The pending read from §5 is now zero | `TimelockCancelled`, `SignerGrantCancelled`, `SignerThresholdDecreaseCancelled`, `FeedWidenCancelled`, `UpgradeCancelled`, `RoleCancelled` |
| `pause()` | `paused()` true on that singleton | `Paused(true)` |

Then check the blast radius you did not intend. After a `revokeSigner` sweep, `signerCount` below
`signerThreshold` means **no feed on that oracle can be pushed** and every one of them will age into
its TTL; that is a correct fail-safe, and it is also a deadline. After a halt, the fraction of the
book still tradable is `feeds.tradableRatio` and `bot_tradable_ratio`
([Observability §6](/docs/3-8-observability#6-pool-mark-state-and-the-gate-mirror), §7).

---

## 7. Escalate

You cannot reverse anything you just did, so the owner is not an optional follow-up; it is the
second half of the action. Hand over as soon as the lever has landed:

- What you halted or paused, with the `src` bits and feed ids, so the release clears exactly those.
- Why, with the read that triggered it.
- What is now blocked that the incident did not require blocking.

Intake and contact are [Access Control §4.1](/docs/3-1-access-control-roles-emergency-powers#41-escalation)
and `security@btr.markets`. A suspected vulnerability rather than an operational fault goes through
[Bug Bounty](/docs/3-7-bug-bounty).

Escalate rather than act when the remedy is a widen, an un-halt, a grant, a param write or a
value movement. None of those are reachable from this key. A feed wedged outside its band is the
clearest case: the remedy is the owner's `requestFeedWiden` → `BASE` delay → `executeFeedWiden`
(shipping in the next release; until then, an oracle redeploy plus a per-leg `BASE`-tier repoint).
Hand it over with the feed ids **grouped by slot**, because the execute stamps one clock per slot and
every wedged lane of a stalled slot has to be released in the same batch — a lane left out is
fail-open on its own leg. `pauseFeed` is the instant lever for any lane that batch will not cover,
and it is yours ([Oracles §8.3](/docs/3-4-oracles#83-deviation-bounds)). Attempting any of the rest
from here burns gas and time during an incident.

---

## 8. Checklist

**On grant, before you are needed**

- [ ] `isGuardian(you)` is true on every `AC` you are expected to cover, and you know which
      `Admin` / `PoolFactory` / `ExternalOracle` instances read each one.
- [ ] The signing address is the whitelisted address (post-arm: the Safe itself, not its signers),
      and its threshold is within `guardianQuorumMax`.
- [ ] `quorumStatus()` reads armed with `ownerOk` and `treasuryOwnerOk` true; if not, raise it now
      rather than during an incident ([§1.1](/docs/3-1-access-control-roles-emergency-powers#11-arming-the-quorum-policy)).
- [ ] You can produce a feed id and an asset address for every leg you cover, from a source that is
      not the front-end.
- [ ] You have signed a no-op transaction from this key on this chain at least once.

**Before signing**

- [ ] Chain id matches the target deployment.
- [ ] The lever is in §2: halt, tighten or cancel. If it is not, it is an owner action.
- [ ] Arguments checked against §3: `src` bits, `BatchOp.HALT`, `subject` padding, and for
      `updateFeed` **both** a band and a TTL at or below the live ones.
- [ ] For a cancel: the pending read from §5 is non-zero.
- [ ] For a halt: the leg list is enumerated, and the two arrays are the same length.

**After the transaction lands**

- [ ] The §6 state read confirms the change, not just a successful receipt.
- [ ] `BatchLegSkipped` count is zero, or every skipped leg is accounted for and retried.
- [ ] `signerCount` versus `signerThreshold` checked if you revoked.
- [ ] `feeds.tradableRatio` reviewed for collateral damage.
- [ ] Owner notified with the exact arguments used, because only the owner can undo them.

---

## 9. Related

| Page | Content |
|---|---|
| [Access Control, Roles & Emergency Powers](/docs/3-1-access-control-roles-emergency-powers) | Authority matrix, halt sources, timelock schedule |
| [Oracle Keeper Operations](/docs/3-10-oracle-keeper-operations) | The push side of the same oracle, and the automated guardian |
| [Risk Steward Operations](/docs/3-11-risk-steward-operations) | The other bounded key |
| [Depeg Halt](/docs/3-5-depeg-halt) | The automated breakers that run without you |
| [Observability](/docs/3-8-observability) | The reads named above, and where they are served |
| [Deployment & Upgrades](/docs/3-2-deployment-upgrades) | Upgrade procedure a `pause()` interrupts |
