---
title: "Access Control, Roles & Emergency Powers"
description: "Who may do what: principals, guardian surface, halt authority, timelock schedule, and owner-gated op classes."
audience: tech
type: reference
status: live
lang: en
updated: "2026-09-04"
publish: true
---
# Access Control, Roles & Emergency Powers

Single source of truth for authority in the deployed contract set: which principals exist, what each
may do, how long each action takes to become executable, and which actions are immediate. Most
residual power is fail-closed, so a hostile guardian can stop the protocol indefinitely while never
moving value or widening a bound. Upgrade procedure:
[Deployment & Upgrades](/docs/3-2-deployment-upgrades). Live addresses:
[Contract Addresses](/docs/2-1-contract-addresses).

---

## 1. Principals

| Principal | Storage | Scope |
|-----------|---------|--------|
| `owner()` | `AccessControl` | Pool/Admin/Factory/oracle governance; halt and un-halt; fences; listing |
| `treasuryOwner()` | `AccessControl` | Custody-split authority: `YieldHook.setIncentivesReceiver`, treasury / treasuryOwner rotation |
| `isGuardian(addr)` | `mapping` whitelist | Halt / tighten / cancel only (never un-halt, widen, or write pool params). One exception: `ExternalOracleV4.setFeedExpBias`, §2 |
| `isRiskSteward(addr)` | `mapping` whitelist | `Admin.setAssetParamsBounded` under owner fences + relative risk-up clamp |
| `isKeeper(addr)` | `mapping` whitelist | Keeper paths outside the oracle; oracle pushes authorize by signature, not by this whitelist |

Not OpenZeppelin role bitmasks, and there is no `PAUSER_ROLE`. Owner sets whitelists via `setGuardian` / `setRiskSteward` / `setKeeper`.

`owner` and `treasuryOwner` are independent principals. Pool **deployment** is permissionless (`PoolFactory.createPool`); pool **administration** always resolves to `AccessControl.owner()` (no per-pool curator). `AccessControl.Role` is exactly `NONE, FACTORY, TREASURY, TREASURY_OWNER`.

`transferOwnership` and `renounceOwnership` both revert `FeatureDisabled(TRANSFER)` (`AccessControl.sol`). The only route to a new owner is Solady's two-step handover with its 48 h request expiry, and `completeOwnershipHandover` is overridden to reject `address(0)` and, once the quorum policy is armed (§1.1), to require the incoming owner to satisfy the same k-of-n admin policy. That override is the complete enforcement point for the `owner` principal.

### 1.1. Arming the quorum policy

`AccessControl.armQuorumPolicy(guardians)` is a **one-way latch**: callable once, by the owner, never unset. It refuses to arm unless every condition below already holds, so arming asserts that the deployment is production-shaped rather than making it so.

| Condition | Check |
|---|---|
| Every governance lane gives usable notice | shortest tier delay `>= Constants.MIN_ARMED_DELAY` (1 h) |
| `owner()` is a k-of-n multisig | `QuorumLib.checkAdmin`: `n` in `[3, 16]`, `k >= ceil(2n/3)` |
| `treasuryOwner` likewise | same predicate |
| At least `MIN_GUARDIANS` (1) guardian appointed | `guardianCount >= MIN_GUARDIANS` |
| The attested guardian array **is** the live set | `length == guardianCount`, strictly ascending, every entry `isGuardian` |
| Each guardian is itself a small multisig | `QuorumLib.checkGuardian(g, guardianQuorumMax)`: `n` in `[1, 16]` and `1 <= k <= guardianQuorumMax <= 2`. Guardian policy is a **ceiling**, not a supermajority floor — the point is a fast lever |

Two consequences follow arming:

- **`setGuardian(g, false)`** reverts once it would drop `guardianCount` to zero (`ThresholdViolation`).
- **Every newly granted guardian** must itself pass the quorum check.

`quorumStatus()` is the monitoring view: a multisig that lowers its own threshold post-arm is invisible to every on-chain gate but visible there.

---

## 2. Guardian surface (safe-direction)

| Domain | Can | Cannot |
|--------|-----|--------|
| Assets | `haltAsset` / `batchRiskOp` halt legs; `collapseAnchor` (re-anchor one leg toward the root and halt it in the same write, and only where the leg has a grandparent — see below) | `unhaltAsset` |
| Timelocks | Cancel pending ops on `Admin` (`cancelTimelock`), `AccessControl` (`cancelRole`, every role except `TREASURY_OWNER`), `PoolFactory`, `UpgradeGate` | Execute or re-queue as owner |
| Upgrades | `cancelUpgrade` and `pause()` on `Admin` / `Flash`; `PoolFactory.cancelReferenceUpgrade` | `unpause`, request, or execute an upgrade |
| Oracle | `revokeSigner`, `pauseFeed`, `updateFeed` (tighten-or-equal on band **and** ttl), `revokeSession`, cancel signer-grant / threshold-decrease / feed-widen | Unpause feed, widen band or ttl, release a wedged feed, grant signers |

Guardians stop; they never restart or widen. Entry: `Admin` (`_onlyGuardianOrAdmin`), `UpgradeGate` (`onlyGuardianOrUpgradeAuthority`), `AccessControl` (`isGuardianOrAuth`).

`collapseAnchor` requires a **strict** ancestor of the leg's current anchor: the walk steps to the anchor's own anchor before its first comparison, so the target is a grandparent or higher. Every Arc leg anchors directly to the base and the base anchors to nothing, so the call reverts `InvalidAnchor` on the whole live fleet. It is a depth ≥ 2 topology repair, not an incident lever ([Guardian Operations §2](/docs/3-9-guardian-operations#2-the-levers)).

**The only inverse of `updateFeed` is an owner ceremony.** `updateFeed` is tighten-or-equal for the
owner as well as the guardian, and `narrowMaxDeviation` does not exist on V4 in any release.
**Shipping in the next release**, a band or ttl tightened too far — or a feed banded out past
$10\,d_{max}$ — is released by the owner's `requestFeedWiden(feedId, maxDeviationBps, ttlSecs)` →
`BASE` delay → `executeFeedWiden(feedId)`, with a guardian-or-owner `cancelFeedWiden` veto that works
on a live request and an expired one alike. The guardian keeps the safe direction on both halves: a
tighten landed during the delay voids the pending widen by compare-and-swap on both fields, and a
guardian can veto a widen but never execute one. Until the release ships, the recovery is a new
oracle plus a `BASE`-tier `UPDATE_ORACLE` repoint per leg
([Oracles §8.3](/docs/3-4-oracles#83-deviation-bounds)).

**One guardian entrypoint writes a price**, and it is the exception to the row above:
`ExternalOracleV4.setFeedExpBias(feedId, newBias)` is `_onlyGuardianOrAdmin` and untimelocked. Decode
is `mark = mant << (exp + bias)`, so a bias write moves the published mark by a power of two. It
zeroes the target lane (the feed reads stale until its next push) and stamps the slot clock, costing
the other seven lanes in that slot one push cycle. It is kept as break-glass on the argument that the
guardian already holds pause and halt; the routine path is the quorum-signed `setFeedExpBiasSigned`,
which clears the same k-of-n bar as a push. Whether the guardian arm should exist is an **open design
question**, not a settled property of this surface. Runbook:
[Guardian Operations §2](/docs/3-9-guardian-operations#2-the-levers).

`UpgradeGate.pause()` freezes a matured upgrade request without cancelling it and is the one guardian
lever with a state to undo; only the upgrade authority may `unpause`. `TREASURY_OWNER` rotation is
the single cancel a guardian cannot reach; its distinct authority set is owner or the incumbent
`treasuryOwner`.

Residual: a guardian can veto every queued governance op and can drop oracle quorum below threshold (fail-closed). Size the set accordingly.

---

## 3. Risk steward

`setAssetParamsBounded` only, and it never queues. Owner fences plus a relative `maxDeltaBps` clamp on risk-increasing moves; the tighten exemption covers `minFeePbps` and `vegaBps` only, because a `haircutSuppressorBps` drop is defensive for the pool but realizes LP loss on the spot, so it is **always** relatively clamped and additionally floored by `haircutSuppressorHardMinBps` on any strict decrease.

`minLiquidity` cannot move on this lane at all: `setAssetParamsBounded` reverts `InvalidInput` if the passed value differs from the live one.

Owner retains the unbounded `setAssetParams`, which is exempt from the relative clamp but **not** from an armed fee fence: `_requireFeeFloor` applies `minFeeHardMinPbps` on the owner lane too, and re-applies it at `executeSetAssetParams` in case the fence was armed while the op sat in the queue. Lowering below an armed fence therefore takes two transactions (`setRiskFences` first), by design. Hard protocol bounds (`minFeePbps`, haircut/kappa consistency, `validateOracleMode`) apply to every lane.

**The fee floor is the only fence that binds the owner lane.** `setAssetParams` calls
`_requireFeeFloor` and nothing else: the vega hard bounds, the suppressor hard max and min, and
`maxDeltaBps` are read by `setAssetParamsBounded` alone. Do not read an armed `RiskFences` row as a
bound on the owner.

---

## 4. Halt authority

Halt is immediate (no timelock). Release is owner-only.

| Action | Who | Delay |
|--------|-----|-------|
| `haltAsset` / `batchRiskOp` halt | `owner` or `isGuardian` | None |
| `unhaltAsset` / `batchRiskOp` un-halt | `owner` only | None |
| `collapseAnchor` (re-anchor toward the root, halts the leg; reverts on a depth-1 pool, [§2](#2-guardian-surface-safe-direction)) | `owner` or `isGuardian` | None |
| `ExternalOracleV4.pauseFeed` / `updateFeed` / `revokeSigner` / `setFeedExpBias` | `owner` or `isGuardian` | None |
| `ExternalOracleV4.revokeSession` | any signer, `owner` or `isGuardian` | None |
| `ExternalOracleV4.unpauseFeed` | `owner` only | None |
| `UpgradeGate.pause()` on `Admin` / `Flash` | `owner` or `isGuardian` | None |
| `UpgradeGate.unpause()` | `owner` only | None |
| Ownership handover | Solady 2-step `completeOwnershipHandover` | 48 h request expiry |
| Cancel beacon upgrade | `owner` or guardian | During timelock / grace |

There is one **asset-level** lever, not two. `haltAsset(pool, token, src)` sets one or more source bits in `PoolConstantsLib.HALT_MASK` (`HALT_RISK_BIT` = bit 0, `HALT_GUARDIAN_BIT` = bit 6); `unhaltAsset(pool, token, src)` clears only the bits passed, so lifting a fleet-wide guardian halt cannot relist a leg an owner risk halt still holds down. Authority is by edge, not by source: halting is guardian-or-owner for every `src`, un-halting is owner-only for every `src`. The other levers in the table act on a feed or on an upgrade request, not on an asset.

A queued timelock (`MIGRATE_BASE_TOKEN` / `setBaseToken` included) is **not** a pause.

Depeg is one trigger among many; its detection and auto-halt path is [Depeg Halt](/docs/3-5-depeg-halt).

### 4.1. Escalation

| Step | Action |
|------|--------|
| 1 | Detect (monitor, keeper, [Bug Bounty](/docs/3-7-bug-bounty)) |
| 2 | Halt via Safety Control Center → `Admin.batchRiskOp` over enumerated (pool, asset) legs |
| 3 | Fix + review |
| 4 | Owner `unhaltAsset`, or timelocked upgrade if code/params change |

Confirm wallet chain before send: pool salt includes `block.chainid` (`PoolFactory.sol`).

### 4.2. Fleet sweep

| Mechanism | Behavior |
|-----------|----------|
| Discovery | `getOfficialPoolsForToken` → `getPoolTokens` |
| Registration | `initAsset` → `PoolFactory.registerTokens` |
| Missing leg | `Admin.haltAsset(pool, token, src)` |

### 4.3. Contact

`security@btr.markets` (reasonable-best-efforts). Status / Discord channels announced pre-mainnet.

---

## 5. Timelock parameters

Timelock durations SSoT. The whole fleet's schedule is **one deploy-time word**: up to 8 tiers x uint32 seconds, indexed by `ConstantsLib.Tier` (7 members), passed to the `AccessControl` constructor and exposed as the immutable `AccessControl.GOV_DELAYS()`. Every governed contract (`Admin`, `PoolFactory`, `ExternalOracle`) derives its own tier delays from that word at construction via `Constants.delayOf(schedule, tier)`. There is no setter and no proxy: the word cannot change after construction, owner included. Nothing branches on `block.chainid`.

Production passes `Constants.PROD_DELAYS`, the schedule below. **No zero-delay schedule exists**: the deploy path rejects any schedule whose smallest tier delay is below `Constants.MIN_ARMED_DELAY` (1 h), and `Constants.sol` marks `ZERO_DELAYS` explicitly as *not* a deployable schedule. Public testnets do not pass a throwaway word either; they pass `Constants.TESTNET_DELAYS`.

`LOW` is the one tier that does not scale with the deployment. Liquidity shape is a risk
instrument rather than a constitutional change: a curve or profile that cannot be refitted inside a
market session is wrong for most of that session, and the risk steward's continuous lane already
moves the fee floor, vega and haircut suppressor with no delay at all, so a day-long queue on the
shape those parameters price against was the slowest part of an otherwise live loop. One hour is
`MIN_ARMED_DELAY`, the floor the timelock will arm at, so this is the fastest a queued op can be
rather than a number someone picked. `ADD_ASSET`, `UPDATE_RISK` and `UPDATE_FEES` ride the same
tier and accelerate with it; all four are quorum-gated and bounded by `PoolConfig` constants at
execute, so the delay is a public review window and not the safety property.

Rows are ordered by duration for reading. The `Ordinal` column is the `ConstantsLib.Tier` enum index, which is what `delayOf` and every ABI-level `uint8` tier argument use.

| `Tier` | Ordinal | `PROD_DELAYS` | `TESTNET_DELAYS` | Notes |
|--------|---------|---------------|------------------|-------|
| `LOW` | 3 | 1 hour | 1 hour | Add asset, risk/profile/curve/fee. **Same on both**: see below |
| `BASE` | 2 | 2 days | 2 hours | Oracle config; signer-grant loosen |
| `HIGH` | 1 | 3 days | 3 hours | Custody pointers (treasury, hook) |
| `CRITICAL` | 0 | 7 days | 6 hours | Base-token migration, re-anchor |
| `UPGRADE` | 4 | 7 days | 6 hours | `Admin` / `Flash` UUPS swap + pool beacon swap |
| `ROTATION` | 5 | 7 days | 6 hours | AC role rotation |
| `FACTORY` | 6 | 14 days | 12 hours | AC factory rotation |
| `GRACE_PERIOD` | – | 7 days (both schedules) | 7 days (both schedules) | Execute window after eta; then expire. **Not a tier** and not tier-gated: a constant, never zeroable (0 would mean "never expires" in `Timelock.validate`) |

---

## 6. Owner-gated op classes

Every pool op queues through ONE generic entrypoint, `requestOp(pool, uint8 opType, bytes32 subject, bytes payload)`, and cancels through ONE, `cancelTimelock(pool, opType, subject)`. `opType` is the `IPool.OpType` **enum ordinal**, so the declaration order below is load-bearing for any caller encoding a `uint8`:

```solidity
enum OpType {
    NONE,                 // 0
    MIGRATE_BASE_TOKEN,   // 1
    UPDATE_ANCHOR,        // 2
    UPDATE_TREASURY,      // 3
    UPDATE_HOOK,          // 4
    UPDATE_ORACLE,        // 5
    ADD_ASSET,            // 6
    UPDATE_RISK,          // 7
    UPDATE_FEES,          // 8
    UPDATE_PROFILE,       // 9
    UPDATE_CURVE,         // 10
    UPDATE_ASSET_PARAMS   // 11
}
```

`requestOp` selects the tier from one exhaustive table and reverts `InvalidInput` on any value that table does not name; there is no default arm that would hand an unknown op the shortest delay. Execute is one named function per op. Entry points on `Admin` unless noted.

| `opType` | Execute | Tier |
|----------|---------|------|
| `ADD_ASSET`, `UPDATE_RISK`, `UPDATE_PROFILE`, `UPDATE_CURVE`, `UPDATE_FEES` | `executeAddAsset`, `executeUpdateRiskConfig`, `executeUpdateProfile`, `executeSetCurve`, `executeUpdateFeeParams` | `LOW` |
| `UPDATE_ORACLE` | `executeOracleUpdate`; signer grant / threshold decrease on `ExternalOracle` | `BASE` |
| `UPDATE_TREASURY`, `UPDATE_HOOK` (custody pointers) | `executeTreasuryUpdate`, `executeSetAssetHook` | `HIGH` |
| `MIGRATE_BASE_TOKEN`, `UPDATE_ANCHOR` | `executeBaseMigration`, `executeAnchorUpdate` | `CRITICAL` |

Three owner levers in the Immediate row bypass `Admin` and its timelock entirely, and they are worth
naming because none of them is a pool-param write:

- **`Pool.adminSetDeadSeedPow10(token, pow10)`** is gated directly on `AccessControl.owner()`, not on
  the `Admin` singleton. It sets the dead-seed exponent used to price the first depositor's index
  pin, is bounded at `decimals + 3`, and takes effect only while the leg is still unseeded.
- **`PoolFactory.deregisterPool(pool)`** evicts a pool from every discovery index. Pool deployment is
  permissionless, so this is the de-pollution lever for griefing clones; it does not touch the pool's
  funds or its ability to trade.
- **`PoolFactory.setProtocolDeployer(newDeployer)`** changes which address mints pools that count as
  *official*. Untimelocked, so a compromised owner key can point "official" at itself for as long as
  it holds the key.

`UPDATE_ASSET_PARAMS` (ordinal 11) is deliberately **absent** from that table and cannot be passed to `requestOp`; the call reverts. Whether a params write queues or applies instantly is the defensive-tighten policy, decided inside `setAssetParams`, which is the only entrypoint that may queue one: it writes immediately before `bootstrapSealed[pool]` or on a defensive tighten, and otherwise queues itself at `LOW` for `executeSetAssetParams`. Any `minLiquidity` change queues in either direction, because a raise halts outflow instantly while its reversal would wait a day, and `haltAsset` already covers that need on both edges.

| Class | Entry | Tier |
|-------|-------|------|
| Immediate | `haltAsset` / `unhaltAsset` / `batchRiskOp`; `collapseAnchor`; risk fences; steward-bounded params; `Pool.adminSetDeadSeedPow10`; `PoolFactory.deregisterPool` / `setProtocolDeployer` | none |
| Factory beacon | `requestReferenceUpgrade` / `executeReferenceUpgrade` | `UPGRADE` |
| Singleton UUPS | `requestUpgrade` / `executeUpgrade` on `Admin`, `Flash` | `UPGRADE` (`owner`) |
| AC rotations | treasury / treasuryOwner / factory | `ROTATION` / `FACTORY` |

---

## 7. Upgrade sequence

1. Queue: `PoolFactory.requestReferenceUpgrade`, or `requestUpgrade` on `Admin` / `Flash`
2. Wait the `UPGRADE` tier delay (7 d under `PROD_DELAYS`)
3. Execute inside `GRACE_PERIOD` or request expires
4. Cancel anytime before execute (`cancelReferenceUpgrade` / `cancelUpgrade`): owner or guardian

Beacon swap re-points every live pool. Procedure: [Deployment & Upgrades §4](/docs/3-2-deployment-upgrades#4-upgrade-mechanisms).

---

## 8. Oracle push roles

Signers are a granted set on the oracle and authorize by signature. The relayer that lands the batch
is **unpermissioned on the signed path** (`pushSignedV4`); V4's session path (`pushV4`) pins
`msg.sender` to the single relay named in a quorum-signed `SessionGrant`, capped at one hour and
revocable by any one signer. Either way the relay holds no price authority. Quorum loosening is
timelocked at `BASE`, tightening and revocation are immediate. Full ceremony, constants and
rationale:
[Oracle Price-Push Security §4](/docs/3-6-oracle-price-push-security#4-the-whitelisting-ceremony).

---

## 9. Related

| Page | Content |
|------|---------|
| [Deployment & Upgrades](/docs/3-2-deployment-upgrades) | Beacon + UUPS procedure |
| [Depeg Halt](/docs/3-5-depeg-halt) | Automated halt trigger |
| [Admin](/docs/1-2-3-admin) | Per-op Admin API |
| [Contract Addresses](/docs/2-1-contract-addresses) | Live `AccessControl` address |
