> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Runbook v3

# Runbook (v3 / qtg)

## Address Allowlist Management

Fund-moving executors are protected by a **DB-level address allowlist**
(`allowed_addresses`). This covers the on-chain executors (Gateway, CCTP, CCIP,
Stargate, USDT0, plain ERC-20 transfer) **and CEX withdrawals**.

### What the allowlist does and does not scope

A row is `(chain_family, address, chain_id?)` — there is **no token column, no
direction column, and no amount column**. Consequences worth internalizing
before you register anything:

* An allowlisted address accepts **any token**. Registering an address "for
  USDC" does not restrict it to USDC.
* Omitting `--chain-id` stores `chain_id = NULL`, which matches **every** chain.
  Only `cctp_burn` and the CCIP lane check demand an exact `chain_id` match and
  reject `NULL` rows. Both enforce it at dispatcher preflight, by separate paths
  (`ccip_send` takes an explicit branch in the dispatch address guard;
  `cctp_burn` goes through `ADDRESS_GUARD_POSTURE`). The CCTP strict *creation*
  check is best-effort and is skipped when the static chain IDs are missing or
  malformed, so dispatch is the gate that always holds.
* Amount caps live in the auto-approve policies, not here.

Register with `--chain-id` unless you deliberately want an any-chain row.

### Why there is no token axis

The allowlist answers "may funds leave to this address", not "which token". Token
scoping is not missing from the system — it lives one layer up, in the template
and in the per-lane registries. Which of those applies depends on the lane:

| Token comes from                             | Lanes                                      | What constrains it                                                                                                                     |
| -------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| A literal in the template's node config      | `evm_erc20_transfer`, Gateway, `cctp_burn` | the template alone — no registry behind it                                                                                             |
| The caller, into the node's executor binding | `ccip_send`                                | the CCIP token registry: creation rejects the movement unless `(source_chain, dest_chain, token)` is a `trusted` **and** `active` lane |
| The server, from a funding envelope          | Agent Wallet Top-Up                        | the envelope row — this family is rejected by `/v3/movements` and only reachable through the top-up route                              |

The Stargate (Pro) and USDT0 lanes are not in this table: their templates are
materialized from a deployment/pool registry rather than hand-authored, so the
token question there is a registry question. Check the registry entry, not the
node config.

So a caller *can* choose the token on some lanes. What a caller cannot do is
displace a token the template already pinned: runtime `input_params.node_config`
is merged **under** the template's config — `supplement_node_config_from_input_params`
returns `{**caller_supplied, **template_config}` — so caller input fills only the
keys a template left out.

That is the operationally important qualifier: **nothing forces a template to pin
its token.** A node authored without its token field accepts a caller-supplied one
(the QA multi-hop drill seed's `cctp_burn` node is shaped exactly that way — it
carries `asset` and chain IDs but no `burn_token_address`). A token column on
`allowed_addresses` would not close that; the fix belongs at template-authoring
time; that is tracked as separate work.

Practical reading: registering an address "for USDC" does not restrict it to
USDC, and the allowlist is not where you should look for that restriction. Review
the template's token fields — and, for CCIP/Stargate/USDT0, the registry entry —
with the same care you give the allowlist row.

### Register an address

Use the admin CLI (audited, sets the cooling period, records actor attribution).
Do **not** `INSERT` directly — a raw SQL row bypasses the audit log, actor
attribution, and the `usable_after` cooling-period stamp.

```bash theme={null}
export MG_ADMIN_CLI_KEY_ID=...       # secret goes in MG_ADMIN_CLI_KEY_SECRET (env-only)
export MG_ADMIN_CLI_KEY_SECRET=...

python -m qtg.interfaces.tools.seed_allowed_address \
  --chain-family evm \
  --chain-id 8453 \
  --address 0x... \
  --label staging_kms_wallet \
  --reason "base treasury signer"
```

* `--chain-family`: `evm` or `cex`
* `--address`: EVM addresses are normalized to lowercase automatically
* `--chain-id`: omit only for a deliberate any-chain row (see above)
* `--label`: operator reference (for example, `my_base_wallet`, `treasury`)
* The unique constraint is on `(chain_family, address, chain_id)` — the same
  address may be registered once per chain
* Re-running against an inactive row reactivates it

The dashboard offers the same mutations behind the writer HMAC boundary:
`POST /dashboard/allowed-addresses`, `.../{id}/revoke`, `.../{id}/reactivate`
(operator UI: `/governance/allowlist`).

### Cooling period

When `MG_ALLOWLIST_COOLING_PERIOD_ENABLED=true` (default: `false`), a newly
registered, reactivated, or chain-migrated address is unusable for fund movement
until `MG_ALLOWLIST_COOLING_PERIOD_HOURS` (default 24) have elapsed. Fund-moving
paths then fail with `ADDRESS_IN_COOLING_PERIOD`; read-only paths (policy
preview, status) are not blocked.

### Disable an address

```bash theme={null}
python -m qtg.interfaces.tools.revoke_allowed_address \
  --allowed-address-id 42 \
  --reason "rotated out of the treasury"
```

Soft-delete approach. History is preserved.

### Inspect what is registered

```bash theme={null}
qtg allowlist list --chain-family evm --chain-id 8453
qtg allowlist list --include-inactive          # includes revoked rows
```

Read-only, goes through `GET /dashboard/allowed-addresses`, and needs the
normal `QTG_*` CLI env (see [CLI reference](/reference/cli)). The direct-DB
equivalent — usable without a running server — is
`python -m qtg.interfaces.tools.list_allowed_addresses --include-inactive`,
which additionally offers `--null-chain-id` to isolate any-chain rows.

There is no `qtg allowlist add` / `revoke`. Mutations stay on the audited
surfaces above; whether a convenience CLI mutation should exist is an open
security decision.

### Validation points

1. **At movement creation**: on-chain intent addresses are checked; a
   non-allowlisted address returns HTTP 422. A CEX-only template has **no**
   allowlist gate at this point.
2. **Dispatcher preflight**: re-checked immediately before execution, for
   on-chain nodes **and** `cex_withdrawal`, so an allowlist change between
   creation and execution is honored. This is where CEX withdrawal destinations
   are enforced.
3. **CEX withdrawal, exchange-side**: the executor additionally compares the
   destination against the exchange's own address book and fails with
   `ADDRESS_NOT_WHITELISTED` on a mismatch.

### Opening a route — registration checklist

A destination is only usable once it is registered in **every** place that
gates it. Missing one is the usual cause of a route that passes creation and
then fails at dispatch.

| # | Where                                             | Who registers                                                     | Verify                                                                                    |
| - | ------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 1 | **Exchange address book** (CEX destinations only) | a human, on the exchange site — QTG has no write surface for this | adapter read: `get_whitelisted_addresses`; otherwise the exchange UI                      |
| 2 | **QTG `allowed_addresses`**                       | `seed_allowed_address` or the dashboard writer route              | `qtg allowlist list --chain-family <f> --chain-id <id>`                                   |
| 3 | **Template / route** referencing the destination  | template seed or proposal promotion                               | `GET /v3/plan-templates/{template_id}` — read the destination out of each node's `config` |

Step 3 has no shortcut. `qtg template promote` is a mutation, not a check, and
the route catalog only carries venue-level metadata (`destination_venue`), not
destination addresses — neither one can tell you the template points at the
address you just allowlisted. Read the template detail, or the proposal payload
before promoting.

Order matters only for step 1 — exchange whitelists often carry their own
waiting period, so start there. Then:

4. If `MG_ALLOWLIST_COOLING_PERIOD_ENABLED=true`, wait out `usable_after`
   (visible in `qtg allowlist list`) before the first fund-moving attempt.
5. Confirm the registered `chain_id` matches the lane — `cctp_burn` and CCIP
   reject any-chain (`NULL`) rows at dispatch.

Note that step 1 does not apply to every venue: adapters with
`supports_address_book_lookup=False` (Coinbase, OKX, Backpack) skip the
exchange-whitelist preflight entirely, so QTG never checks their address book.

Before the first route opens against real capital, read
[Allowlist Operator QA](/reference/allowlist-operator-qa) — it maps where each
gate is and is not enforced, and walks the registration semantics safely.

### Notes

* A CEX withdrawal therefore needs the destination registered in **two** places:
  the exchange's own whitelist (registered by a human on the exchange site) and
  QTG's `allowed_addresses`. QTG has no surface for the former.
* CEX withdrawal nodes match against the node's `chain_family`, which defaults
  to `evm` — a withdrawal to an EVM address matches `chain_family = 'evm'` rows.
* The zero address (`0x0000...`) is naturally blocked unless it is registered
* For self-transfer-only operation, register only your own wallet addresses

***

## Callback

### Callback receiver failure response

* Callbacks are outbox-based and retry with exponential backoff (`2^min(attempts, 6)` seconds)
* When `max_attempts` is exceeded, the callback moves to the DLQ (Dead Letter Queue)
* If the callback URL host is not in the allowlist, it goes directly to the DLQ
* On retry, only the header timestamp is refreshed; the payload and nonce remain unchanged

### Callback checks

* Whether the callback receiver secret (`MG_CALLBACK_HMAC_SECRET`) matches
* Verify the `MG_CALLBACK_ALLOWED_HOSTS_CSV` configuration
* On the receiver side, verify the signature against the raw body (do not re-serialize after JSON parsing)
* Signature schema: see `docs/callback-verification-contract.md`

***

## Response by state

| request\_state          | Meaning                         | Operator action                                        |
| ----------------------- | ------------------------------- | ------------------------------------------------------ |
| `PENDING_APPROVAL`      | Waiting for approval            | approve or reject                                      |
| `APPROVED`              | Approved, waiting for execution | Wait for automatic execution to start                  |
| `EXECUTING`             | In progress                     | Monitor                                                |
| `COMPLETED`             | Fully completed                 | Check callback / downstream propagation                |
| `FAILED`                | Node failed                     | Review `error_code` and timeline, then decide on retry |
| `WAITING_MANUAL_ACTION` | Manual intervention required    | Investigate the cause, then resume                     |
| `MANUAL_INTERVENTION`   | Automatic recovery failed       | Apply manual action, then resume                       |
| `REJECTED`              | Rejected                        | —                                                      |
| `EXPIRED`               | Approval TTL exceeded           | Create a new movement if needed                        |
| `CANCELLED`             | Cancelled                       | —                                                      |

### Operator actions

* **retry**: `POST /v3/movements/{id}/actions/retry` — returns FAILED/UNKNOWN nodes to READY
* **resume**: `POST /v3/movements/{id}/actions/resume` — resumes after manual intervention
* **cancel**: `POST /v3/movements/{id}/actions/cancel` — immediate for PENDING\_APPROVAL/APPROVED; in-progress execution requires an on\_cancel edge

***

## Security key response

1. Immediately disable the key suspected of exposure (`api_client_keys.status='revoked'`)
2. Issue and roll out a new key
3. old key revoke
4. Consider rotating the callback secret as well

***

## Checklist before switching to live

1. Validate API/approval/callback wiring with `MG_WORKERS_ENABLED=false` (dry\_run)
2. Verify that the on-chain template intent addresses are registered in `allowed_addresses`
3. Verify that `MG_CALLBACK_ALLOWED_HOSTS_CSV` and the callback receiver secret match
4. When `MG_AUTH_ENABLED=true`, verify that every caller has the correct HMAC key
5. Rehearse the first transfer with a small amount
