8.3 KiB
ADR-0034:Approved Run 创建与原子恢复回执
- 状态:Proposed
- 日期:2026-07-19
- 关联:QL-RFC-0001、ADR-0031、ADR-0032、ADR-0033、ADR-0014
上下文
ADR-0032 已经建立 Approved Action 的 durable start barrier,ADR-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、opaqueactionRef、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_action、execution_origin=system、execution_owner=runtime;request_id=approvalRequestId;idempotency_key=dispatchId;accepted_at=execution.startedAtMs。
本 action 的成功只表示 Run 聚合与初始 Attempt 已原子创建、Run 已进入 queued,不表示任务最终执行成功。
2. 0023 receipt 与 Run 在同一事务提交
ApprovedRunActionReceipts 以 dispatch_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 仍为 executing,Project、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 collision:
conflict,不能把碰撞 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 adapter,cluster-control 不能共享本地数据库或本地 receipt;
- 仅证明 Run 创建成功,不证明 Run 执行结果或下游任务副作用。
未选择的方案
- 只查询
Runs.idempotency_key=dispatchId:没有 approval/action/fence 绑定,拒绝。 - Run 提交后另一个事务补 receipt:崩溃窗口会产生不可判定的合法成功,拒绝。
- receipt 缺失时补写或重新 create:无法证明原 Run 来源且可能重复动作,拒绝。
- receipt 绑定固定 start version 并要求永远相等:合法 renew 会制造假 conflict,拒绝。
- 根据 Run 最终 succeeded/failed 裁决创建动作:混淆“创建 Run”和“执行任务”两种 action 结果,拒绝。
- 立即接入 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 通过。