# 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`、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_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 执行结果或下游任务副作用。 ## 未选择的方案 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 通过。