# FTO 运营手册（G11 · 监控 / 告警 / 值班 / 事故预案 / 恢复演练）

> 目标：让「运营」可以**交给不写代码的人**：告警自动触发、值班自动升级、事故按预案处置、恢复按演练验证。
> 纯逻辑在 [`fto/src/ops.js`](../src/ops.js)；边端探针 + 去重 + 推送在 [`fto/tools/ops_watch.mjs`](../tools/ops_watch.mjs)；恢复演练在 [`fto/tools/restore_drill.mjs`](../tools/restore_drill.mjs)。
> 本文件的每个告警 `kind` 与 `ops.js` 里的 `kind` **逐个对应** —— 机器告警与人的预案共用同一个键。

---

## 0. 一分钟上手

```powershell
# 值班配置（真人名）——提交到仓库的只有 example
copy fto\ops\ops.example.json fto\ops\ops.json

# 每 5 分钟跑一次（cron / 计划任务）；命中告警时退出码 1，并把新告警推到 webhook
node fto\tools\ops_watch.mjs --url=https://fractaltapeout.pages.dev `
  --webhook=<slack/discord/自建> --webhook-kind=slack

# 人工看一眼（不改状态、不推送）
node fto\tools\ops_watch.mjs --json --no-state
```

- `ops_watch` **只读**边端，唯一的输出是 webhook POST。退出码：`0` 无新告警 · `1` 有（≥ `--page-from`）· `2` 探针本身失败（边端没应答）。
- 告警**去重**：同一个 `kind` 持续存在只在**首次**（和超过 `cooldownMs` 的提醒）推送；消失时推一条 `resolved`。状态在 `fto/data/ops/state.json`（已 gitignore）——丢了只会多推一次，不会漏。
- 监控本身也会坏：`drift_check` 把结果写 `fto/data/drift/status.json`，`ops_watch` 折叠它；若该文件缺失/超时，报 **`monitor.stale`**。

---

## 1. 值班（on-call）与升级

值班表在 `ops.json` 的 `oncall`：`rotation` 是 `{name, from, to}`（ISO 或毫秒，`to` **不含**），**最后匹配的一段**生效；没有匹配就用 `primary`。升级链固定为 **primary → secondary → manager**（`manager` 可信）。

| 级别 | 响应时间（建议） | 动作 |
| --- | --- | --- |
| critical | **立即**（5 分钟内确认） | 值班 primary 直接处置；15 分钟未缓解 → 升级到 secondary；30 分钟未缓解 → manager + 冻结写路径 |
| warning | **当班内**（4 小时内） | 值班处理或排期；持续 > 24h 升级为 critical |
| info | 记录 | 下次排期处理 |

处置完在事故记录里写：`告警 kind`、开始/结束时间、根因、动作、后续项。**没有记录不算关闭。**

---

## 2. 告警 → 事故预案（runbook）

按 `ops.js` 的 `kind` 排列。「含义」= 为什么报；「立刻」= 首 15 分钟；「随后」= 根治。

### keeper（时序 covenant，cron 自动推进）

| kind | 级别 | 含义 | 立刻 | 随后 |
| --- | --- | --- | --- | --- |
| `keeper.uninitialised` | critical | keeper 未播种（`keeper:state` 缺失） | `POST /api/keeper/init {seed}` | 确认 `keeper:state` 已入 KV，`GET /api/keeper → health.healthy` |
| `keeper.unreachable` | warning | `/api/keeper` 读不到（边端路由/代理问题，非 keeper 本身停） | 直接 `GET /api/keeper` 看 HTTP 状态；区分边端整体故障还是该路由 | 修路由/部署；与 `read.failed` 同源时按 P0 处理 |
| `keeper.starving` | critical | `value ≤ perStep`，**无法再付 caboose+fee**，链上时序要停 | `POST /api/keeper/restock`（见下）| 把 `KEEPER_MIN_STEPS` 的自动补币打开（`KEEPER_AUTORESTOCK=1`），并留足热钱包余额 |
| `keeper.low` | warning | 续航 < `KEEPER_MIN_STEPS`，还够跑但接近停 | 计划一次 `restock` | 调整 `KEEPER_MIN_STEPS` / 步频 `KEEPER_STEP_EVERY_MIN` 与热钱包余额 |
| `keeper.stalled` | critical | 头**超过 `KEEPER_STALL_MIN` 未推进**（cron 停了/一直抛/被暂停） | 看 `GET /api/keeper → beat/error/log`；手动 `POST /api/keeper/step {dry:false}` 试推一步 | 修 cron（`*/5`）、检查 `KEEPER_STALL_MIN > KEEPER_STEP_EVERY_MIN` |
| `keeper.staleBeat` | critical | **cron 心跳缺失**（`keeper:beat` 太旧）——cron 根本没在跑 | 检查 Workers cron / 账号计费；跑一次 `POST /api/keeper/step` 验证 | 恢复 cron 触发；给 cron 加独立监控 |
| `keeper.error` | warning | 上次 cron 报错（保留在 `keeper:error`） | 看错误串；常见：starving / 广播被拒 / UTXO 未知 | 按错误处置；成功一步后错误自清 |

**补币（restock）注意**：covenant 固定 `nOut=2` 且只认自己的头 outpoint，**单独转账会搁浅**；必须走 `POST /api/keeper/restock`（把热钱包 UTXO 折进下一步的输出）。见 [`cf/DEPLOY.md`](../cf/DEPLOY.md) 的补币小节。

### 读模型 / 状态

| kind | 级别 | 含义 | 立刻 | 随后 |
| --- | --- | --- | --- | --- |
| `read.failed` | critical | 边端 `/api/digest` 不可用（`readEvents` 重试后仍读不齐 → 边端回退静态快照或拒绝） | 查链 API（`FRACTAL_API`）可达性；看是否瞬时；确认边端返回的是**降级**还是**快照** | 若持续：切到独立索引器/备用边端；`drift_check` 对账 |
| `state.errors` | warning | canonical 状态含无效事件（G4/G9 规则拒绝） | 看 `/api/state.errors` 的类型分布 | 若是新规则误伤 → 走 SPEC §11 治理流程；若是真实滥发 → 记录 |
| `root.commitment` | critical | `/api/root` 的**链上承诺校验失败**（root/事件数/setHash 任一不符） | **当作数据完整性事故**：先停写，跑 `fto/tools/state_root.mjs --verify=<txid>` 独立验证 | 定位是边端事件集漂移还是承诺本身；`drift_check --from=` 深扫补洞 |

### 独立对账（drift monitor）

`drift_check` **独立按区块扫链**，与边端事件集对比，结果写 `status.json`。

| kind | 级别 | 含义 | 立刻 | 随后 |
| --- | --- | --- | --- | --- |
| `drift.missing` | critical | **链上有、边端没有** —— 边端少算余额（危险方向） | `POST /api/restore` 把这批 txid 补进 KV `events`（先 dry 看 diff） | 查为何带外广播没回填；确认 #O3 回填游标在推进 |
| `drift.extra` | warning | 边端有、链上没有（reorg 残留 / 幻影） | 确认对应高度是否 reorg；必要时从 `events` 移除 | 依赖规范序 + 回填自愈 |
| `drift.mismatched` | warning | 同 txid 但**解码类型不同**（边端/独立实现分歧） | 用三实现对账（`live_reconcile.mjs`） | 修实现分歧；这通常意味着有版本没同步 |
| `monitor.stale` | warning | **监控本身**超时未更新（`status.json` 缺失/太旧） | 手动跑一次 `node fto/tools/drift_check.mjs` | 修监控 cron；监控不跑 = 其它告警都不可信 |

### 费用 / 重 org（G12）

费率策略在 [`fto/src/fees.js`](../src/fees.js)（`/api/fee` 与本地服务共用一份 → 目标确认数选 mempool 字段、夹在 `[1, 1000]` sat/vB、接口挂了回退）；UTXO/找零在 [`fto/src/utxo.js`](../src/utxo.js)（确认门槛 + 最少币 + 找零不产尘埃、次尘埃折进手续费）；确认/reorg 策略在 [`fto/src/confirm.js`](../src/confirm.js)（`minConf`、可承诺前缀、按高度对齐找分叉）；`reorg_watch` 是链头监控。

| kind | 级别 | 含义 | 立刻 | 随后 |
| --- | --- | --- | --- | --- |
| `fee.congested` | warning | 实时费率接近上限（`≥ 80% of 1000` sat/vB），确认会慢 | 正常；**不要**手动改费率绕过策略 | 让 `targetBlocks` 更慢一档；确认写路径不需要更快的确认 |
| `fee.elevated` | info | 费率偏高（`≥ 20` sat/vB） | 记录 | 复盘是否有批量操作撞在一起 |
| `fee.fallback` | info | 费率不是 mempool 实时值（链接口不可达，用了回退/默认） | 查 `FRACTAL_API` 费率端点 | 修探针；回退值要偏保守 |
| `reorg.detected` | warning | 检测到浅 reorg（深度 < `deepReorg`，默认 6） | 确认相关 tape-out/结算是否需要重放；边端会按规范序**自愈**（丢掉的块不再确认） | 若刚承诺过状态根，核对 `/api/root` |
| `reorg.deep` | critical | 深 reorg（深度 ≥ 6）——确认策略在真干活 | 按 **P0 数据完整性**：暂停写、核对 `/api/root` 与最近承诺、必要时 `drift_check --from=` 深扫 | 复核 `minConf`；把深 reorg 写进事故记录 |
| `reorg.stale` | warning | `reorg_watch` 没在跑/超时 | 手动 `node fto/tools/reorg_watch.mjs` | 修监控 cron；监控不跑 = reorg 无声 |

### 其它

| kind | 级别 | 含义 | 立刻 | 随后 |
| --- | --- | --- | --- | --- |
| `backfill.error` | warning | #O3 带外回填上次失败（`events:backfillError`） | 看错误；`POST /api/events/backfill` 手动补 | 修链 API / 限流；游标是单调的，重试即可 |
| `limiter.degraded` | warning | 全局限流 DO 绑不上，退回单 isolate 内存计数（**防护变弱**） | 看 `GET /api/auth → limiter.bound/why`；确认 `fto-limiter` Worker 在线 | 修 service binding；重新部署 Pages（binding 烘进每次部署） |
| `backup.missing` | warning | 从来没有 KV 快照（命名空间被清 = 不可从链重建） | `node fto/tools/kv_backup.mjs`（用 `FTO_WRITE_TOKEN`） | 把备份做成定时任务，异地存放 |
| `backup.stale` | warning | 最近备份超过阈值（默认 24h） | 立刻补一次备份 | 修备份 cron；定期做恢复演练（见 §3） |

---

## 3. 恢复演练（backup / restore drill）

**为什么**：订单簿、结算日志、DA（`circuit:<id>`）、keeper 位置、事件索引**只存在于 KV**，链上没有被清就重建不出来。

**离线演练（CI 每次都跑）** —— 用**生产同一份 `restorePlan`**：

```powershell
node fto\tools\restore_drill.mjs
```
它证明：① 清空命名空间后从快照恢复**逐字节一致**（⇒ digest/root 也一致）；② 静默损坏只恢复被改的键；③ `--prune` 精确删除游离键；④ **畸形快照在任何写入前被拒**；⑤ 重放新鲜快照是 no-op。

**线上演练（只读，安全）**：

```powershell
$env:FTO_WRITE_TOKEN="<token>"
node fto\tools\restore_drill.mjs --url=https://fractaltapeout.pages.dev
# 真实 /api/backup -> DRY /api/restore：证明路径通、且 DRY 不写
```
默认**不写**；要真写回**同一份**快照加 `--apply`（幂等，仅覆盖同值）。

**日常备份**：

```powershell
$env:FTO_WRITE_TOKEN="<token>"
node fto\tools\kv_backup.mjs                 # -> fto/data/backups/kv-<时间>.json（gitignore）
node fto\tools\kv_backup.mjs --file=<快照> --restore          # 先看 diff（DRY）
node fto\tools\kv_backup.mjs --file=<快照> --restore --apply  # 确认后再写
```

**演练节奏（建议）**：离线演练随 CI 每次；线上 DRY 演练**每周**一次；完整 `--apply` 演练**每月**一次并记录；每次改动恢复逻辑后立即重演。

---

## 4. 事件等级与冻结

- **P0 数据完整性**：`root.commitment`、`drift.missing`、`read.failed` 持续 → **立即停写**（`/api/tapeout`、`/api/settle*`、`/api/issue*`、keeper 步进），只保留读；先证实「链上真相」再恢复写。
- **P1 服务可用**：`keeper.stalled`、`keeper.staleBeat`、`monitor.stale` → 按预案恢复自动化。
- **P2 防护/资源**：`keeper.starving`、`keeper.low`、`limiter.degraded`、`backup.*` → 当班处置。
- **冻结/回滚**：任何「规则要不要改」的判断都走 [`fto/SPEC.md`](../SPEC.md) §11 的版本前缀 + 24 冻结契约流程；运营**不得**单方面改规则。

---

## 5. 关联

[`fto/SPEC.md`](../SPEC.md)（协议/治理）· [`G9-ECONOMICS.md`](../G9-ECONOMICS.md) · [`MAINNET-PUBLIC-READINESS.md`](../MAINNET-PUBLIC-READINESS.md)（G11）· [`FEATURE-MATRIX.md`](../FEATURE-MATRIX.md)（运营层）· [`cf/DEPLOY.md`](../cf/DEPLOY.md)（部署/令牌/限流/keeper）。
