# 审计中的代理操作断电后，结果变得未知

审计中的代理操作发生断电，并不会只产生一种失败。它会造成证据问题。代理、本地网关、操作系统、网络和远程服务，都可能在不同位置停止。如果因为代理没有收到响应，就把这一团乱麻简单归为“失败”，迟早会重试一个其实已经执行过的操作。

这个错误很容易发生，因为日志读起来像一个故事。调查人员看到一项已获批准的操作和一个出站请求，然后出现空白。这段空白很像结论，但它不是结论。它表示有一段时间，几种实质上不同的结果都仍然可能发生。正确的处理方式，取决于哪个系统掌握你需要的事实。

这正是审计轨迹发挥作用的地方。它应保留本地系统已知的内容，证明保留的记录没有被悄悄修改，并让不确定性清晰可见，而不是用一条结论掩盖它。它无法把丢失的响应变成证据，证明数据库写入、付款、部署或远程命令没有发生。

## 超时是证据缺口，不是已确认的失败

超时说明某个参与者在截止时间前没有看到可用响应。它不能说明目标是否收到请求、是否开始工作、是否提交了工作，或者响应是否在返回途中消失。

在事件记录和报告代理操作的界面中，应区分以下结果：

- **确认失败：**目标系统或本地执行器返回了持久证据，表明它拒绝或回滚了请求的操作。
- **确认成功：**负责保存变化状态的系统返回了回执，后续读取也验证了预期状态。
- **结果未知：**操作可能已经发生，但现有证据无法证明它发生或没有发生。
- **未尝试：**网关在把操作交给执行器或传输通道之前就拒绝了它。

“未知”不是“可能失败”的委婉说法。它是一个有独立处理流程的运行状态。你应暂停自动重试，保留证据，查询权威系统，然后再决定补偿操作或重试是否安全。

人们常常过早地加入“部分成功”这一标签。只有当你能明确说出已完成的子操作和未完成的子操作时，才使用它。例如，部署流水线创建了构件但没有推广构件，如果这两个事实都已验证，就可以说结果是部分完成。请求在客户端向套接字写入字节后消失，即使请求看起来很简单，结果仍然是未知。

RFC 9110 也从协议角度作出了同样区分。通信失败后，它允许自动重放幂等方法，因为重复预期操作会产生相同的预期效果。它同时警告，不应自动重试非幂等请求，除非客户端能够确定原始请求没有生效，或者确认应用本身能安全处理重复请求。这是语义规则，不是传输技巧。

代理操作网关应准确报告本地事实。“请求已发送，尚未观察到完成结果”很有用。“操作失败”则是一项本地进程可能无权作出的判断。

## 断电会把一次操作分成多个持久化边界

一次操作在看到最终结果前，可能会跨越多个边界。在测试前先把它们写下来，因为只让一个进程崩溃的测试，很容易带来误导性的信心。

一次典型的 HTTP 操作至少有以下边界：

1. 网关接受已授权的意图，并在本地记录它。
2. 网关构造经过身份验证的请求，并开始传输。
3. 远程服务收到足够的请求内容，开始处理。
4. 远程服务提交副作用，并创建回执。
5. 网关收到响应，并记录观察到的结果。

SSH 操作的路径类似，但远程主机可能在本地客户端得知退出状态前就启动命令。主机还可能派生出在会话结束后继续运行的工作进程。TCP 或 SSH 连接成功，并不能说明命令在哪一步停止。

本地写入也有自己的边界。进程可以追加审计事件，操作系统可以把追加内容接收到缓存中，文件系统可以安排元数据和数据的写入顺序，存储设备也可以让字节在断电后保留下来。这些不是同一个事件。

POSIX 的 `fsync()` 规范规定，该调用会请求把打开文件排队的数据传输到存储设备，并在操作完成或报告错误前不返回。规范的说明还提醒，实际保证取决于实现和存储配置。这一点很重要：应用不能仅仅因为写调用成功返回，就推断写入具备断电安全性。

调查时，应为每条记录分配证据类别，不要把每个时间戳都视为同样持久：

| 证据类别 | 它能支持的结论 | 它不能支持的结论 |
| --- | --- | --- |
| 意图已接受 | 网关同意尝试一项具体操作 | 请求已经离开机器 |
| 已开始派发 | 网关开始了本地执行或传输工作 | 目标收到了完整输入 |
| 远程回执 | 目标声称接受或提交了一项操作 | 本地系统在崩溃前保存了该回执 |
| 本地完成记录 | 网关观察到并记录了一个结果 | 远程状态在后续工作后仍未变化 |
| 对账读取 | 后续查询观察到了目标状态 | 状态改变的确切时间，除非目标系统记录了它 |

这张表有意保持严格。进程日志写着“正在发送请求”，只能作为派发证据，不能作为交付证据。内存中的响应正文属于观察证据。只有当你的持久化路径能经受声称要测试的故障模型时，它才算本地持久证据。

## 在测试故障前，为每个副作用分配操作身份

如果远程系统没有稳定方式识别一次操作，调查人员就无法对账一个中断的操作。应在编写混沌测试前加入操作身份，而不是等生产环境出现第一个重复操作后再补。

条件允许时，使用两个标识符：

- 本地生成的操作 ID，用来标识网关的一次尝试。
- 目标系统认可的操作 ID、幂等键、请求令牌、部署 ID 或交易引用。

它们可以包含相同的随机值，但不要假定两者含义相同。操作 ID 标识本地审计记录。远程标识只有在目标系统把它与副作用一起保存，并提供查询最终状态的方式时才有用。

对于支持幂等键的 API，应在请求中明确传递该身份，并记录有效载荷的摘要。摘要可以帮助你发现操作人员误用旧键提交了内容不同的新请求。

```http
POST /v1/releases HTTP/1.1
Host: deploy.example.internal
Idempotency-Key: 8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1
Content-Type: application/json

{
  "operation_id": "8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1",
  "service": "catalog",
  "artifact": "sha256:3c1f...",
  "environment": "production"
}
```

不要记录授权头、持有者令牌或可能包含秘密的完整正文。记录方法、目标身份、安全的请求元数据、操作 ID、操作标识和有效载荷摘要。你需要足够的证据来比较多次尝试，但不要在审计系统中制造第二个凭据泄露点。

一条有用的意图记录可能如下：

```json
{
  "event": "intent_accepted",
  "action_id": "act_01JX8F3Z6Z",
  "operation_id": "8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1",
  "channel": "http",
  "destination": "deploy.example.internal",
  "method": "POST",
  "path": "/v1/releases",
  "payload_sha256": "3c1f...",
  "authorization": "approved_for_session"
}
```

这条记录可以避免一个常见的调查错误：有人只按时间戳和端点比较后续重试与第一次操作，忽略了构件或账户发生变化，然后把两个不同操作认定为等价。

对于 SSH，应把操作 ID 放在远程主机可以保留的位置。Shell 命令可以把它写入结构化日志，部署脚本可以把它写入发布记录，或者远程包装器可以拒绝重复 ID。不要把本地 SSH 客户端记录当作唯一证据。

```sh
ssh deploy@host.example.internal \
  '/usr/local/bin/release --operation-id 8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1 --artifact sha256:3c1f...'
```

如果命令会启动后台工作进程，应让工作进程在改变任何内容前持久化操作 ID。否则，连接断开后你可能面对一台仍在工作的主机，却没有可靠方式找到这项工作。

## 在会改变决策的边界模拟中断

有用的故障测试会在能导致不同调查决策的节点终止进程。在循环中随机终止客户端可以发现错误，但不会告诉任何人日志意味着什么。

构建一个测试目标，让它可以在受控阶段暂停，并能按操作 ID 回答对账查询。它不需要复杂，但必须暴露请求已收到、​​副作用已提交和响应已发送之间的差异。

先从五种情况开始：

1. **意图持久化前崩溃。** 操作应同时缺少于本地持久审计轨迹和目标系统。如果目标发生了变化，说明操作顺序错误，或者另一个组件发出了操作。
2. **意图持久化后、派发前崩溃。** 审计轨迹应显示操作已接受，但没有派发记录。只有当网关能够证明自己从未把操作交给传输通道或执行器时，才能将其归类为未尝试。
3. **请求传输过程中崩溃。** 目标可能完全看不到请求，也可能看到部分请求或完整请求。除非目标给出明确拒绝或查询结果，否则应归类为未知。
4. **远程提交后、本地完成记录持久化前崩溃。** 目标应显示操作已完成，而本地轨迹没有完成事件。这项测试会暴露危险的重试逻辑。
5. **本地完成记录持久化后、代理收到响应前崩溃。** 本地审计轨迹已经有答案，即使代理认为发生了超时。新的代理会话应查询操作记录，而不是盲目发起第二次操作。

本地测试工具可以通过命名的暂停文件协调网关和测试服务。具体实现会不同，但测试契约不应改变。测试工具必须在中断前告诉你已经到达哪个边界，并保留目标状态供后续对账。

```sh
# Terminal 1: start the test destination with controlled pauses.
./test-api --pause-after=commit --state-file ./tmp/remote-state.json

# Terminal 2: run one authorized action with a known operation ID.
./gateway-test invoke \
  --operation-id 8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1 \
  --pause-file ./tmp/client-dispatch.pause

# When the destination reports "committed", terminate the local process.
kill -9 "$(pgrep -f 'gateway-test invoke')"

# Terminal 3: inspect the destination without issuing another mutation.
./test-api lookup 8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1
```

`kill -9` 测试的是进程崩溃。它不测试硬断电时的存储行为，也不会取消远程服务已经接受的操作。当你学习如何分类结果时，这个限制反而有帮助，因为它迫使团队停止把调用方死亡当作被调用方状态的证据。

若要测试真实断电，请使用一次性机器或虚拟化环境，以安全地重现突然关机。不要拔掉一台工作站的电源，而这台工作站里恰好存有生产保险库、审计材料或工作树的唯一副本。为每次运行保存准确的构建版本、存储模式、测试输入和时间源。没有这些背景的测试结果，只是一段轶闻。

## 审计轨迹应说明已知内容，并保留未知内容

好的审计记录像一组范围明确的声明。它不会假装掌握无法观察的系统中的全部真相。

对于中断的操作，应记录独立事件，不要覆盖一个可变的状态字段。追加式序列可以在不编造最终答案的情况下表达事实：

```text
intent_accepted     action=act_01JX8F3Z6Z op=8b4d... principal=agent-process
transport_started   action=act_01JX8F3Z6Z channel=http
outcome_unobserved  action=act_01JX8F3Z6Z reason=local-process-terminated
reconciled_success  action=act_01JX8F3Z6Z receipt=rel_4921 source=remote-api
```

第三行不应写成 `remote_failed`。它只表示网关没有观察到终止结果。第四行则加入了一个来自发布状态负责方的后续声明。

这种区分也能让篡改证据发挥更大作用。哈希链可以暴露保留的审计材料被修改，但无法重建从未进入持久存储的事件。如果机器在出站调用和日志追加之间断电，完整的哈希链可能会在操作结果之前正常结束。这不一定说明链已损坏，而是留下了一个需要对账的缺口。

Sallyport 从一份不可读取的加密、哈希链式审计日志中生成 Sessions 和 Activity 日志，`sp audit verify` 可以离线检查密文上的这条链。这让调查人员能够有力地验证保留的本地历史是否被改动，同时在需要时把远程结果交给远程证据确认。

应将授权证据与结果证据分开。审批只说明人或配置好的控制机制允许操作继续，不说明操作已经完成。混淆这两种声明，就会把一项已获批准但被中断的生产变更错误地报告为已完成的变更。

时间戳也一样。墙上时钟时间戳有助于调查人员关联不同系统，但当机器时间不一致或缓冲区延迟写入时，它们不能建立全局顺序。如果顺序很重要，应在每个日志中记录序列号，保留远程回执 ID，并在对账时记录服务自己的时间戳。

## 网络调用需要远程对账，不能靠乐观判断

HTTP 调用丢失响应后，目标系统通常才是判断请求状态是否发生变化的权威来源。重试前先查询它，并让查询足够具体，以便把这次操作与相似工作区分开。

最安全的对账顺序是：

1. 在目标系统中查询操作 ID 或幂等键。
2. 如果目标返回已完成操作，就将资源 ID 和有效载荷摘要与原始意图进行比较。
3. 如果目标返回已记录的拒绝，就保留这份响应作为失败证据。
4. 如果目标没有记录，应先确认 API 是否说明了延迟处理、异步队列或记录最终创建，再决定是否重试。
5. 只有当端点语义和现有证据都表明重复安全时，才进行重试。

不要因为 `GET` 是安全方法，就把它误认为无害的验证。有些 API 会在读取端点背后隐藏工作，有些响应缓存也会落后于写入路径。应确认服务提供方的契约，并尽可能获取具体创建资源或操作记录，而不是按时间在宽泛列表中搜索。

HTTP 方法标签有帮助，但不能决定应用行为。`PUT` 在 HTTP 层面可以是幂等的，但服务器仍可能每次收到请求都发送重复邮件、计入使用量或触发部署钩子。RFC 9110 明确指出，幂等性适用于请求的效果，而服务器仍可以保留独立日志或产生其他副作用。因此，API 所有者定义的操作身份，比客户端库中的动词更重要。

“每次超时都重试两次”是一个流行但糟糕的建议。它听起来很实用，因为很多超时确实是暂时性的。但如果端点不能安全重复，它会把暂时的传输问题变成重复付款、重复创建账户，或两次生产发布。重试策略必须说明操作类型、幂等机制、最大延迟，以及允许重试的证据。

如果远程服务不提供查询或幂等支持，诚实的答案可能是：你无法自动解决结果。应围绕这个限制建立补偿流程，例如设置人工审核队列，附带准确的请求指纹，并提供一个只能读取的账户来检查受影响的状态。

## SSH 命令需要远程主机提供证据

SSH 会话中断后，歧义通常比许多 API 调用更大，因为客户端消失后，命令可能仍在远端执行。丢失退出状态不等于命令失败，而是缺少观察结果。

不要用一条没有检查点的大型 Shell 命令完成多个变更。应把远程工作拆成拥有独立 ID 和持久状态的操作。例如，部署包装器可以针对一个操作 ID 记录 `received`、`validated`、`applied` 和 `completed`，然后提供只读状态命令。

```sh
/usr/local/bin/release-status \
  --operation-id 8b4dbdb6-61e1-49ea-a5b5-9ae225eb2af1
```

包装器应在开始不可重复操作前写入状态，而不是完成后才写。如果它要分配云资源、发布软件包或切换流量，就应使用相同的操作 ID 保存服务提供方回执。如果做不到，应把操作放到能够做到这一点的远程队列之后。

不要把 Shell 清理陷阱当作恢复证据。本地陷阱在突然断电后不会运行。远程陷阱可能在强制终止后不运行，也可能在子进程继续运行时执行。清理逻辑可以减少混乱，但不能证明最终状态。

只有当标记与副作用之间存在有意义的关系时，才使用远程标记。追加到 `/tmp/action-done` 的一行文字不能证明数据库迁移已经提交。同一事务中写入迁移表会是更好的证据。由部署控制器创建的部署记录则更可靠。

Sallyport 会通过内置的无状态 `sp-ssh` 辅助程序路由 SSH，但规则相同：本地操作记录可以说明网关尝试了什么以及观察到了什么。只有远程主机或被命令改变的系统，才能确定一个未被观察到的远程结果。

## 带调查人员走过一次中断的发布

假设代理通过 HTTP 端点请求生产发布。网关记录了一个已授权意图，包含操作 ID `act_01JX8F3Z6Z`、操作标识 `8b4d...`、构件摘要 `3c1f...` 和会话审批。它开始发送请求。发布服务提交了发布，并分配回执 `rel_4921`。响应到达网关前，笔记本电脑断电。

重启后，代理的记录显示调用超时。本地审计轨迹停在 `transport_started`。如果有人把这一行当作失败证据，就会使用新的操作 ID 再次提交相同发布。服务于是创建第二个发布。如果发布端点会立即激活，第二次请求可能只是造成噪声。如果它会触发不可逆迁移，代价可能很高。

正确的调查应先暂停 `act_01JX8F3Z6Z` 的自动重试。验证本地审计链。记录最后一条保留事件、本地进程终止证据、目标、有效载荷摘要和操作 ID。然后向发布服务查询 `8b4d...`。

有三种有用的结果：

- 服务返回 `rel_4921`，构件摘要为 `3c1f...`。对账后，将原始操作归类为确认成功。本地完成记录缺失仍是审计缺口，不是重放发布的理由。
- 服务返回与 `8b4d...` 关联的持久拒绝。将其归类为确认失败。在决定是否提交修正请求前，保留拒绝原因。
- 服务没有返回记录。确认它是否会在创建操作记录前排队，以及查询路径是否存在延迟。如果它无法排除延迟工作，就保留结果未知并升级处理，不要盲目重试。

注意，以下事实都不能决定结果：代理超时、网关进程退出，或人已经批准了操作。这些事实都很重要，但它们都不负责保存发布状态。

这个过程还暴露出一个设计要求。如果目标无法按操作 ID 搜索，网关就必须避免对该目标执行无人值守的不可重复操作，或者要求人工对账。审计质量无法弥补一个没有持久方式识别自身副作用的 API。

## 重试规则必须足够严格，才能应对糟糕的一天

重试策略不能只写“网络错误时重试”。应把它写成一张决策表，让实现人员和事故响应人员都能照着执行。

| 操作类型 | 中断后的证据 | 是否自动重试 | 必要保障 |
| --- | --- | --- | --- |
| 只读查询 | 没有响应 | 通常可以 | 有界重试和目标超时限制 |
| 幂等更新 | 没有响应 | 可以，但前提是目标系统支持稳定身份 | 使用相同资源身份和相同请求内容 |
| 创建或触发操作 | 没有响应 | 只有完成对账后，或在有明确幂等机制时才可以 | 按操作 ID 查询目标 |
| 资金转移或破坏性变更 | 没有响应 | 不可以 | 人工审核和权威状态检查 |
| 具有副作用的 SSH 命令 | 会话丢失 | 不可以 | 远程操作状态和重复拒绝机制 |

不要让代理在恢复时自行生成新的操作 ID。这是一个隐蔽的重复操作来源。如果重试有效，应重新使用目标系统认可的相同身份，并证明重试有效载荷与原始意图一致。如果期望的变更已经改变，那就是一次新操作，需要新的授权决定。

为无法解决的操作设置终止状态。“未知，等待对账”比一个因为状态机不允许不确定性而永远重试的任务更好。为这个状态指定负责人、截止时间和升级路径。否则，一个旧的模糊操作会在几周后被人重新查看时，因为远程日志不完整而突然成为问题。

## 在系统失去答案前保留证据

中断后的第一个小时里，远程服务通常仍保留请求追踪、队列条目和新鲜的操作人员上下文。应在日志保留期结束、缓存被清除或另一次部署删除这些信息前，捕获事实。

以原始形式保留本地审计材料，并在把摘录复制到工单前验证它。记录准确的操作 ID、操作标识、目标、有效载荷摘要、授权记录、最后一条本地事件和本地重启时间。然后收集远程操作记录、资源修订号、服务提供方请求 ID，以及用于作答的服务时间源。

不要通过添加虚假的完成事件来修复历史。应添加一条后续对账事件，并注明它的来源和证据。调查人员需要看到原始边界，因为边界告诉他们系统当时知道什么。

最重要的经验令人不太舒服，但很简单：一次经过审计的操作，即使获得了正确授权并被仔细记录，断电后仍可能得到未知的远程结果。在让自主代理执行不能安全重复的工作前，先建立操作身份、远程回执和对账路径。当灯光在错误的时刻熄灭时，这些选择决定了团队是在调查一个缺口，还是制造第二起事故。
