# 审批队列中的调用顺序能经受住竞态吗？

如果后发起的代理调用因为审批卡片恰好先被点击，就能绕过较早的调用，那么人工审批的意义就很有限。当两个写入操作争用同一份远程状态时，网关必须在用户接触任一审批卡片前确定它们的顺序，然后在调度过程中一直保留这个顺序，并写入审计记录。

这类问题很容易被忽略，因为正常流程通常只有一张卡片、一次点击和一个成功响应。缺陷会在两个代理进程几乎同时发送请求时出现：审核者以不利的顺序批准了可见卡片，而目标系统接受了先到达的请求。我见过团队把这种行为称为并发。其实这只是在人以为自己已经控制了顺序之后，系统才做出的未定义决定。

## 顺序从网关接收调用时开始

调用顺序，是操作网关把面向同一冲突资源的请求接收到某个队列中的先后次序。它不是卡片渲染顺序，不是用户点击顺序，不是网络连接建立顺序，也不是远程响应返回顺序。

为每个已接收的调用分配不可变的票据，例如 `42`、`43` 和 `44`。将票据与请求说明、调用进程身份、目标和队列标识一起保存。票据必须在网关请求审批前生成。否则，界面只能报告审核者碰巧先看到哪个请求，这不足以还原一次竞态。

队列是一个范围，在这个范围内改变顺序可能改变结果。两个都会覆盖同一部署环境的请求应放在同一个队列中。两个都会向同一远程事件记录追加内容的调用也应放在同一个队列中。获取软件包元数据的请求和更新另一测试服务的请求，可能不需要互相等待。把所有操作都设为全局有序看起来很安全，但这样会让一个无关的慢请求拖住所有代理，造成全面中断。

困难在于要诚实地选择队列。目标主机名通常过于宽泛，单独的端点路径又常常过于狭窄。`PATCH /documents/7` 和 `POST /documents/7/publish` 影响的是同一份文档，尽管路径不同。如果网关没有足够信息推导出这种关系，就把这些操作放入同一个配置队列。不要假设远程服务会弥补网关从未做出的顺序承诺。

接收记录可以采用接近下面的结构：

```text
ticket=42 lane=release-prod event=accepted action=write-release caller=agent-a
ticket=43 lane=release-prod event=accepted action=write-release caller=agent-b
```

这些记录之后可以准确回答一个问题：网关先对哪个调用承担了责任？它们并不表示票据 42 先完成。根据允许的执行模型，慢速目标可能让票据 43 更早或更晚完成。网关必须明确说明这个模型，不能让日志事后替它编造一个模型。

## 审批是决定，不是插队许可

审核者先决定请求 B，再决定请求 A，并不会让 B 变得更早。这只说明 B 已经满足了一个条件，接下来要等待轮到它。

审批界面通常把每张卡片当作独立提示，因此这种区别很容易模糊。对于没有顺序影响的操作，这样做没有问题。对于相互竞争的写入，这样做就会失败。如果界面允许两张卡片都保持可操作状态，点击 B 后，B 应进入已批准、等待执行的状态，而不是直接交给调度器。调度器要检查队列头部的票据，只调度最早且已获准的票据。

网关对队列头部票据有三种合理结果：

- 审批允许该票据执行。
- 拒绝会记录终态拒绝，然后释放下一个票据。
- 过期或明确取消会记录终态结果，然后释放下一个票据。

还有第四种行为会带来麻烦：后面的审批因为前一个请求还没有决定，就直接执行。它看起来很友好，因为审核者能快速得到结果，但也会让远程顺序取决于界面操作的瞬间。用户可能打开 A 查看参数，同时把 B 当作无害的日常操作先批准。于是系统会先发送 B，尽管它把这两项操作呈现成一个队列。

用户界面应把状态显示清楚。队列头部的卡片可以提供批准和拒绝操作。后面的卡片也可以接受决定，但状态应显示它们正在等待票据 42，或者界面可以在较早票据解决前暂时隐藏这些控件。这两种方式都能保留顺序。前一种给审核者更多控制，后一种更不容易造成误解。界面绝不能让人以为每次点击批准都会立即执行。

Sallyport 的按会话授权和按调用审批控件让请求边界清晰可见，但仍然需要在审批决定前分配票据。如果产品只记录一组没有结构的卡片，审核者就无法真正评估队列。

## 用会留下痕迹的写入复现竞态

一个有用的测试是，让两个独立的代理进程向同一个写入队列发起请求，并让目标记录请求到达的顺序。不要使用两次读取、两次幂等状态检查，或两次更新互不相关记录的请求。这些测试即使通过，也无法证明队列不允许后来者插队。

使用一个可丢弃的 HTTP 端点，让它接收 POST 请求体，并把收到的票据追加到文件或数据库表中。它还应返回自己收到的票据。如果这个演练只在本地测试地址运行，目标不需要身份验证。这里要测试的是网关的审批和调度路径，不是凭据注入。

这个小型 Node 服务器会生成一份简单的到达日志：

```js
const fs = require("node:fs");
const http = require("node:http");

http.createServer((request, response) => {
  let body = "";
  request.on("data", chunk => { body += chunk; });
  request.on("end", () => {
    const item = JSON.parse(body);
    fs.appendFileSync("arrival.log", `${item.ticket} ${item.value}\n`);
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ received: item.ticket }));
  });
}).listen(8787);
```

配置一个已获批的操作，向该端点发送 `POST /write`，并在 JSON 请求体中携带请求票据。启动两个全新的代理进程，不要让同一个进程串行发起两次调用。两个进程都提交同一个操作，但使用不同的值，例如 `A` 和 `B`。让两次调用都需要审批，使两张卡片同时保持待处理状态。

最初应该看到一些平淡但明确的证据：

```text
Sessions
42 accepted agent-a write A
43 accepted agent-b write B

Approval cards
42 write A
43 write B

arrival.log
(empty)
```

如果卡片以相反的票据顺序出现，就先停下来调查。视觉顺序相反并不能证明执行错误，但它会让审核者按照错误的故事采取行动。如果一次只出现一张卡片，要记录第二个请求是否已经有票据，以及日志是否说明它正在等待前一个请求。隐藏第二张卡片可以接受，隐藏它的存在不行。

多次运行这个演练。先启动 A，再启动 B，也交换顺序。尽量让两次启动接近到测试工具允许的程度。在一个进程进入网关后加入短暂延迟，减少调度偶然性的影响。测试需要反复制造重叠，因为一次成功运行往往只是反映了恰好出现的线程调度，而不是系统真正执行了某条规则。

## 卡片顺序、调度顺序和完成顺序不同

测试应收集三种顺序，因为它们回答的是不同问题，不应压缩成一列。

卡片顺序是审核者可以看到待处理决定的顺序。同一队列内，它应遵循票据顺序，即使应用在不同的事件循环轮次中绘制卡片。卡片可以显示到达时间，但票据才是决定性字段。

执行顺序是网关把已接受的操作释放到 HTTP 或 SSH 通道的顺序。对于严格串行的写入队列，目标应先观察到票据 42，再观察到票据 43。网关应在把请求交给通道前立即发出 `dispatched` 事件。不要从响应事件推断调度。目标可能已经收到写入并修改了状态，随后在发送响应前连接就断开了。

完成顺序是响应、超时或传输错误返回的顺序。如果系统允许不同队列中的调用重叠执行，它可能与调度顺序不同。即使在同一个队列中，异步实现也可能因为在网络清理后才刷新日志，而较晚记录完成事件。完成顺序是有用的运维信息，但不能覆盖因果顺序。

一份紧凑的测试记录可以让区别一目了然：

```text
42 accepted
43 accepted
42 card-shown
43 card-shown
43 approved
42 approved
42 dispatched
42 succeeded
43 dispatched
43 succeeded
```

本次运行中，`43 approved` 出现在 `42 approved` 之前是预期结果。`42 dispatched` 出现在 `43 dispatched` 之前才是契约要求。如果应用只记录最终成功，这两个事实都会消失，调查人员也就无法判断：是用户先批准了 B，是调度器让 B 绕过了 A，还是目标把两个已经释放的请求重新排序了。

RFC 9110 区分了安全的 HTTP 方法和请求改变状态的方法。这一点在这里很重要：网关可以对观察类操作采用更宽松的顺序，而写入操作需要明确的并发契约。RFC 9110 并没有为两个独立客户端连接之间提供一个有用的全局顺序。既然网关把审批放在调用之前，这个决定就由网关负责。

## 先批准第二张卡片，但让它等待

最有力的基础竞态测试，是故意先批准 B。它可以检验实现把审批当作状态转变，还是当作直接发送按钮。

先让票据 42 和 43 在同一个队列中处于待处理状态。批准票据 43。卡片或日志应变成类似 `approved, waiting for 42` 的状态。目标的 `arrival.log` 必须保持为空。然后批准票据 42。目标应先收到 `42 A`，再收到 `43 B`，活动记录也应按这个顺序显示两次调度。

使用拒绝票据 42 的方式重复测试。目标日志应只包含 B。日志中仍然要有两个票据：

```text
42 accepted
43 accepted
43 approved
42 rejected reason=user
43 dispatched
43 succeeded
```

拒绝是一种操作结果，而不是没有发生操作。如果日志省略了它，之后查看记录的人只能看到 B 执行，却不知道较早的请求为什么缺失。这会引出错误结论：也许攻击者绕过了审批，也许应用丢了数据，也许审核者批准了自己没有看到的内容。

接着测试超时。在 B 已经获批的情况下，让 A 的审批过期。应用应记录一次 A 过期，让 B 具备执行资格，然后调度 B。不能因为后台计时器和迟到的点击发生竞态，就同时生成 `expired` 和 `rejected`。应使用票据状态上的原子比较和交换来选择一个终态决定。落败的事件应发现票据已经解决，然后什么也不做。

最后，在 A 还没有解决前，先批准 B，再取消 B。B 不应执行。它的终态应为已取消，之后解决 A 也不能让它恢复。这可以抓住一种常见的队列缺陷：调度器保存了一份旧的已批准票据列表，取消处理程序虽然把某项从可见队列中移除，调度器却仍然把它发送出去。

好的测试会分别断言每个结果。不要只用一个断言确认目标最终收到了预期状态。B 覆盖 A、重试掩盖重复请求，或目标应用自己的冲突规则，都可能让最终状态看起来正确，但实际顺序已经错误。

## 在重试和慢速目标下保留票据

通道重试请求时，请求仍应保留原来的队列位置。连接失败后创建新票据，会改变审批的含义，并可能让后续请求绕过它。

假设票据 42 已经调度，但 TCP 连接在网关收到响应前关闭，目标可能已经应用写入，也可能没有。网关可以采用多种策略：报告结果未知并停止，目标支持时使用幂等令牌重试，或要求新的人工决定。它不能悄悄丢弃 42，再发送 43，仿佛 42 从未存在。

正确策略取决于远程操作。接受幂等标识符的端点，可以让重试继续复用原始票据，从而达到足够安全的程度。盲目通过 SSH 执行的命令通常做不到这一点。在这种情况下，应报告 42 的结果未知，让队列保持阻塞，或在人工介入后明确放弃该队列，并记录原因。因为 43 可能依赖 42 已失败的假设，自动释放 43 会扩大损害。

使用一个这样的目标来测试：它收到 A 后写入到达记录，然后在 HTTP 交换完成前关闭响应。网关应保留类似下面的轨迹：

```text
42 accepted
42 approved
42 dispatched attempt=1
42 outcome=unknown
43 accepted
43 approved waiting-for=42
```

票据 43 最终是否运行，是需要记录在文档中的运维选择。不可接受的行为是：在网关还没有证据时，日志就声称 42 已失败，随后 B 成功执行，而 B 又依赖了这个未经证实的结论。

慢速请求会暴露另一种实现错误。调度器如果在等待远程响应时持有互斥锁，可能会意外地把所有操作都串行化，包括不相关队列和用户界面工作。调度器如果过早释放顺序状态，又会让下一个票据抢先执行。应让队列的接收和调度状态保持精简，持久化调度事件，把请求交给通道，然后等待结果。在严格串行的情况下，不能允许同一队列中的另一个票据越过它。

## 审计日志必须保留因果关系

哈希链审计记录可以证明保留的记录没有被悄悄修改，但不能自动让混乱的事件顺序变得易懂。事件模型仍需要足够的信息，才能解释一次并发审批竞态。

在任何卡片出现前记录接收事件。记录带有票据、执行者或交互方式的审批决定。在 HTTP 请求或 SSH 命令进入通道前记录调度事件。记录通道的终态结果，不要用它替换此前的任何事件。除了墙上时钟时间，每个事件还需要自己的追加位置。

墙上时钟时间对调试有用，但不能单独定义顺序。两个事件可能具有相同的时间精度，时钟可能发生变化，应用也可能从不同线程排队写入。追加位置或序列号能确定日志接收各事件的顺序。每个请求的票据则确定队列中的预期顺序。两者都要保留。

对于两次写入演练，请比较以下事实：

- 接收位置显示 42 在 43 之前。
- 审批事件可能显示 43 在 42 之前。
- 当两次调用都成功时，调度位置显示 42 在 43 之前。
- 目标到达文件显示 42 在 43 之前。
- 终态结果挂在同一个票据上，而不是替换原有事件。

Sallyport 的 Sessions 和 Activity 视图都来自同一份加密哈希链审计日志，`sp audit verify` 可以离线对密文验证这条链。因此，选择记录哪些事件尤其重要：验证可以显示记录没有被修改，而票据和事件类型才能告诉人们究竟发生了什么。

不要按完成时间给可见日志排序，然后把它称为历史。这会把网络竞态呈现成决定顺序。主要时间线应按持久化的追加位置排序，同时在旁边显示票据编号和时间戳。针对某个代理进程的筛选视图也应保留原始位置，让审核者看到另一个请求曾在两个事件之间等待，而不需要自己猜测。

## FIFO 适用于相互冲突的影响，不是每个字节

FIFO 队列承诺的是相互冲突的影响按顺序发生。它不是要求所有网络操作都经过同一个线程，也不是让无害的读取操作在未解决的写入后面等待。

先对每个操作分类。会修改共享环境的部署命令需要一个队列。创建账单操作的 HTTP POST 需要一个队列，可能还要按账户划分。读取静态构件的请求通常可以独立运行。只报告磁盘空间的 SSH 命令可能属于观察类操作，但要小心：命令可能通过 shell 启动文件、临时文件或远程命令封装器产生隐藏影响。在能够描述其影响前，应把含义不明确的命令当作写入处理。

一种常见的做法是认为每次审批都彼此独立，因为审核者可以检查请求。这种做法很有吸引力，因为它省去了队列设计。但即使审核者分别理解每个请求，也可能不知道 B 默认 A 已经发生。人工审核无法弥补缺失的串行化上下文。

相反的错误是设置一个单一的全局队列。这样做让顺序变简单，却会在一台远程主机卡住时让整个应用像停止运行一样。不同队列需要清晰的名称、稳定的资源选择方式，以及能够标明队列的日志字段。如果你无法解释两个操作为什么共享一个队列，也就无法测试它们的顺序承诺。

不要用按调用审批代替队列。每次使用都要求点击，有助于让人评估每个操作，但它没有定义两个已批准操作是否可以相互越过。这是两种不同的控制方式，也有不同的失败模式。

## 把竞态纳入发布标准

队列缺陷很少表现为明显崩溃。它更可能在之后表现为无法解释的远程状态、看起来作用于错误操作的审批，或无法解决事件复盘的日志。对于任何可能改变共享状态的操作类型，都应把两次写入演练作为发布测试。

发布标准不应只检查成功响应。要生成重叠调用，反转审批顺序，拒绝第一个请求，让它过期，取消第二个请求，并在调度后强制产生未知结果。每种情况都保存票据顺序、卡片状态、调度事件、目标到达日志和日志条目。

当其中一份记录出现不一致时，不要急着把它称为显示问题。显示问题可能只是最先暴露出来的迹象，说明应用的不同部分采用了不同的顺序定义。在请求接收时修正契约，然后让卡片、调度器、目标测试和日志报告同一个契约。

第一项实际行动很简单：在一个存在冲突的写入队列中加入不可变票据，并让测试先批准后面的票据。如果这个请求比更早的请求先到达目标，说明审批队列还没有真正控制它声称要审批的操作。
