Files
qinglong/docs/adr/ADR-0034-approved-run-action-atomic-receipt.md
T

8.3 KiB
Raw Blame History

ADR-0034Approved Run 创建与原子恢复回执

  • 状态:Proposed
  • 日期:2026-07-19
  • 关联:QL-RFC-0001、ADR-0031、ADR-0032、ADR-0033、ADR-0014

上下文

ADR-0032 已经建立 Approved Action 的 durable start barrierADR-0033 也规定恢复只能读取可信证据,不能再次调用 execute。但在没有真实 handler 和 evidence provider 时,这套协议只能证明何时不得重放,不能证明任何一种业务动作如何安全收敛。

Run 创建适合作为第一个内部数据库动作:它有稳定的 Project/Task/revision 身份、数据库唯一幂等键和明确的持久化成功边界。不过,仅把 dispatchId 写入 Runs.idempotency_key 仍不足以成为恢复证据。其他写入者可能抢占同一 key,Run 也没有保存 approval/action digest/execution fence;根据一条碰巧存在的 Run 推导审批动作成功会造成身份混淆。

因此需要一个与 Run 聚合同事务提交、完整绑定审批和 execution identity 的专用 receipt,并让恢复 provider 同时验证 receipt 与 Run 事实。

决策

1. 首个真实 action 类型是 run.create

ApprovedRunCreationPlan 是版本化、exact-shape、无 Secret 的不可变计划,首版只包含:

  • schemaVersion=1、opaque actionRef、Project ID
  • Task ID、固定 Task revision
  • Executor type、priority
  • 可选低敏 task name、task snapshot ref 和 input ref。

canonical digest 使用固定字段顺序和显式 null 表示缺失可选字段,并把 actionType=run.create 纳入 SHA-256。inspect 只能解析计划、校验 Project/actionRef 并返回 digest;它不能创建 Run。execute 会再次解析同一 immutable plan,防止 inspect 与 start barrier 之间发生内容漂移。

创建出的 Run 使用:

  • trigger_type=approved_actionexecution_origin=systemexecution_owner=runtime
  • request_id=approvalRequestId
  • idempotency_key=dispatchId
  • accepted_at=execution.startedAtMs

本 action 的成功只表示 Run 聚合与初始 Attempt 已原子创建、Run 已进入 queued,不表示任务最终执行成功。

2. 0023 receipt 与 Run 在同一事务提交

ApprovedRunActionReceiptsdispatch_id 为主键,并通过唯一 (resource_type,resource_id) 防止一条 Run 被多个 dispatch 声明。receipt 固定绑定:

  • schema version、dispatch/approval/Project
  • action type 和 canonical action digest
  • execution attempt、实际提交时看到的 execution version、start time
  • idempotency_key=dispatchId
  • outcome=succeeded、bounded result code
  • resource_type=run、Run ID、finish/create time
  • 对以上全部 canonical 字段计算的 evidence SHA-256。

SQLite adapter 使用一个 BEGIN IMMEDIATE:先在事务内复验当前 execution 仍为 executingProject、attempt、startedAt、owner/token 均匹配,再创建 Run/Attempt/Event,最后插入 receipt。任一 Run、Event、receipt、约束或 commit 失败都会整体回滚,不允许留下“Run 已存在但可信 receipt 缺失”的合法成功路径。

3. renew 可以前进 version,但不能改变 execution identity

handler 获得的 context 可能在执行期间发生同 owner/token renew。事务内复验允许数据库 execution version 大于 handler context version,但必须保持:

  • 同一 dispatch、Project 和 attempt
  • 同一 startedAtMs
  • 同一 owner/token
  • 状态仍是 executing

receipt 保存事务实际读取到的当前 version。若 recovery/manual resolution 或 normal completion 已先推进终态,状态复验会在任何 Run 写入前拒绝旧 handler。这样 renew 不会把真实 receipt 误判为旧 attempt,终态也不会被陈旧 execute 绕过。

4. recovery provider 只读 receipt 和 Run

LegacySequelizeApprovedRunRecoveryEvidenceProvider 没有 execute 能力,只执行有界主键/唯一键读取:

  • receipt 缺失且同 idempotency key 的 Run 也不存在:missing
  • receipt 缺失但存在 key collisionconflict,不能把碰撞 Run 当成功;
  • receipt exact-shape、digest、approval/action/attempt/start/version 边界任一不匹配:conflict
  • receipt 匹配但 Run/Attempt、Project、request、origin/owner/trigger 不匹配:conflict
  • 全部匹配:verified_succeeded 并返回 receipt evidence digest
  • 数据库不可用或查询失败:抛出,由 reconciler 转成 unavailable

provider 允许 receipt version 小于当前 recovery snapshot version,因为同一 execution identity 可以在 receipt 提交后续租;receipt version 大于当前 snapshot 则视为 conflict。它不从 Run 最终状态推导本 action 的结果,因为本 action 只负责创建 Run。

5. 重放只接受完整 receipt

相同 dispatch 再次进入 repository 时,只有完整 receipt 和绑定 Run/Attempt 全部匹配才返回原 Run 引用。只存在 Runs(project_id,idempotency_key) 而没有 receipt 时稳定冲突,不补写 receipt、不重新解释历史来源,也不创建第二条 Run。

6. 当前仍保持 production unreachable

本切片新增了真实 run.create handler、SQLite 原子 repository 和 automatic evidence provider,但没有把它们注册到 app、loader、service、API 或任何 lifecycle。生产启用仍至少需要:

  • immutable plan 的真实持久化 resolver、preview builder 和管理入口;
  • dispatcher/reconciler 的 profile-aware lifecycle、指标、告警和积压门禁;
  • 真实强认证 adapter、人工恢复 API/UI、rate limit 和审计产品入口;
  • PostgreSQL action/receipt/provider adapter 与多副本 contract
  • SQLite 断电、磁盘满、WAL、损坏与固定 edge 设备资源门禁;
  • Run admission、Secret/Artifact policy 与实际 Executor 产品入口的联合验证。

run.create 的内部数据库原子性不能推广为任意 Tool、Shell 或外部 API 的 exactly-once;其他 action 仍必须各自提供可认证 receipt/query contract,无法提供时保持 manual_only

影响

正面影响:

  • 首次以真实业务 mutation 验证了 start barrier、atomic receipt 和 evidence-only recovery 的完整闭环;
  • receipt 缺失不会被误判为失败或成功,幂等 key collision 明确 fail closed
  • 同事务写入适合 edge/standalone,无 watcher、每动作 timer 或全表扫描;
  • renew 和终态并发具备清晰 fence,不依赖 wall clock 猜测 winner。

代价与风险:

  • 每次 Approved Run 创建增加一行小型 receipt 和一次同事务 insert
  • receipt 表与 Run 生命周期形成保留关系,后续 retention 必须按 dispatch/audit 规则设计;
  • 当前只有 SQLite adaptercluster-control 不能共享本地数据库或本地 receipt;
  • 仅证明 Run 创建成功,不证明 Run 执行结果或下游任务副作用。

未选择的方案

  1. 只查询 Runs.idempotency_key=dispatchId:没有 approval/action/fence 绑定,拒绝。
  2. Run 提交后另一个事务补 receipt:崩溃窗口会产生不可判定的合法成功,拒绝。
  3. receipt 缺失时补写或重新 create:无法证明原 Run 来源且可能重复动作,拒绝。
  4. receipt 绑定固定 start version 并要求永远相等:合法 renew 会制造假 conflict,拒绝。
  5. 根据 Run 最终 succeeded/failed 裁决创建动作:混淆“创建 Run”和“执行任务”两种 action 结果,拒绝。
  6. 立即接入 production dispatcher:缺 lifecycle、强认证、PostgreSQL 和设备门禁,拒绝。

验证要求

  • canonical plan 对字段顺序稳定,未知字段、非法版本和越界内容 fail closed
  • 0023 被 migration/schema ownership 覆盖,索引和 tuple constraints 可审计;
  • Run/Attempt/Event/receipt 同事务,receipt 插入故障时 Run 全部回滚;
  • 相同 dispatch 精确重放只返回一条 Run 和一条 receipt
  • receipt/action/resource/digest 任一篡改返回 conflict
  • receipt 缺失返回 missing,幂等碰撞返回 conflict
  • 同 owner/token renew 后写入实际 execution version,并可被 recovery snapshot 验证;
  • execution 已终结时,陈旧 handler 在 Run 写入前被 fence
  • production reachability 搜索证明 app/loaders/api/services/shared/data 未导入 handler、repository 或 provider
  • Node 22/24 全量测试、build、fresh migration 和 schema audit 通过。