Files
qinglong/docs/adr/ADR-0031-approval-request-and-durable-action-dispatch.md

8.8 KiB
Raw Permalink Blame History

ADR-0031ApprovalRequest 与 Durable Action Dispatch

  • 状态:Proposed
  • 日期:2026-07-19
  • 关联:QL-RFC-0001、ADR-0028、ADR-0030

上下文

ADR-0028 已规定 Agent 的写、Secret、管理和 Tool call 即使角色允许也只能得到 require_approval,但此前没有持久化审批状态机。若调用端把 require_approval 当成 allow,或只保存一个可修改的 prompt/preview,Agent 可以在人工确认后替换参数;若审批后直接执行外部副作用,进程在“标记已消费”和“真正执行”之间崩溃,又会在重复执行与永久丢失之间二选一。

审批还必须同时适配资源很小的路由设备和集群控制面。edge 不能依赖常驻 expiry timer、全表扫描或大型 JSON;cluster 则必须能够用事务、行锁、版本和 fencing 裁决多个控制面节点。审批后发生 Project archive、RoleBinding revoke 或角色变更时,旧授权也不能继续穿透当前 Policy。

决策

1. Approval 绑定不可变动作身份,不保存动作明文

ApprovalRequest 保存:

  • Project ID、request ID 和单调 version
  • 精确 ProjectPermission
  • 有界 actionType 与 opaque actionRef
  • canonical action payload 的 SHA-256 actionDigest
  • 用户可见安全预览的 SHA-256 previewDigest
  • low|medium|high|critical risk
  • requester ActorRef、请求时间和绝对过期时间。

数据库不保存 prompt、Tool arguments、Shell、Secret 值或任意 preview: unknown。产品层需要展示预览时,必须从独立、受授权的 Artifact/plan 读取安全预览,并验证 previewDigest;真正消费时由执行计划重新计算 actionDigest 并精确匹配。actionRef 只是定位 durable plan,不是授权本身。

request ID 同时是创建幂等身份。相同 ID 与相同不可变字段返回当前资源;ID 相同但 action/risk/requester/时间漂移返回 conflict。

2. 持久状态与有效过期分离

持久状态机为:

pending@v1 -> approved@v2 -> consumed@v3
           -> rejected@v2

expirednow >= expiresAt 且持久状态仍为 pending|approved 时的有效状态,不需要后台 timer 写库。决定和消费都在精确边界前完成;now == expiresAt 已过期。首版审批寿命最大 24 小时,审批不会延长原 expiry。

rejectedconsumed 为终态。未来若增加 cancel、supersede 或 re-open,必须新增显式 version/state 和 migration,不能覆盖历史决定。

3. 决定只能来自当前被授权的稳定 User

决定审批必须同时满足:

  1. decidedBy 是稳定 user ActorRef
  2. requester 与 decider 不同;
  3. 当前 Project Policy 对 decider 的 approval.decide 返回 allow
  4. 写事务内 Project version 和 decider RoleBinding version 仍等于 Policy 读取时的 fence。

Agent、API App、MCP Client、Worker 和 System 首版不能决定审批。Agent 即使拥有高角色也不能形成递归“审批自己的审批”。身份认证、MFA/rate limit 与 wire 层审计仍是生产接入门禁,本 ADR 不把一个裸 ActorRef 当成认证证据。

4. 创建和消费都重新验证 Policy fence

创建只接受 Policy effect 为 require_approval 的动作;denyallow 都不创建冗余审批。消费前重新对原 requester、Project 和原 permission 求值:只有 allow|require_approval 可以继续,deny 或 unavailable fail closed。

每次 mutation 都携带同一次 Policy resolve 返回的 {projectVersion,bindingVersion}。SQLite adapter 在 BEGIN IMMEDIATE 事务内重新读取当前 Project/RoleBinding version;不一致返回 fence conflict。这样审批与 Role revoke/archive 竞争时,只有一个顺序能提交。所有受支持的 Project/Role mutation 都必须递增或 append version,否则属于存储损坏。

PostgreSQL adapter 必须保持相同 contract,可用行锁或等价条件更新实现;不得只在 application 层先查后写。

5. 消费审批必须同事务创建 durable dispatch

approved 不等于副作用已执行。一次性消费在同一数据库事务完成两件事:

  1. ApprovalRequest approved@v2 -> consumed@v3
  2. 创建唯一 ApprovedActionDispatch,复制 Project、permission、action identity、requester、consumer 和时间。

若 dispatch 插入、状态更新或 fence 校验任一步失败,事务整体回滚,Approval 仍可安全重试。consumptionIddispatchId 都是有界幂等身份;完全相同的重放返回同一 dispatch,任一字段漂移返回 conflict。每个 Approval 只能关联一个 dispatch。

这提供的是“一次性授权消费 + durable handoff”,不是外部副作用的 exactly-once 保证。后续 Dispatcher/Executor 必须从 durable dispatch claim,使用 lease、owner/token/version fencing,并要求下游 action identity 幂等;不得在 Approval transaction 内直接调用网络、Shell 或 Tool。

6. Edge 与 Cluster 使用同一语义、不同 adapter

edge/standalone 使用 SQLite 点查、唯一索引和短 IMMEDIATE 事务;过期按读时计算,空闲时零 timer、零周期写。pending/actor/dispatch 索引为后续 UI 与 dispatcher 提供有界 keyset 查询,不能做无界 offset 扫描。

cluster-control 使用 PostgreSQL adapter、同一状态/version/幂等 contract 和适合多节点 claim 的行锁;ApprovedActionDispatch 的后续 claim schema 由 ADR-0032/0021 以独立 execution control 增量演进。edge 不因此依赖 PostgreSQL、消息队列或外部缓存。

7. 当前切片保持 production unreachable

本切片实现 0020、domain/port、Policy fenced SQLite repository、application service 和 contract tests,但没有:

  • /api/v3 Approval route、UI、SSE/Event 或审计 exporter
  • legacy session/identity 到 AuthenticatedPrincipal 的生产装配;
  • owner bootstrap 的可信 issuer,因此默认 Project 仍没有 owner
  • preview Artifact/plan resolver 和 canonical action digest builder
  • 真实 ApprovedAction handler、下游 idempotency receipt 和 recovery_required resolver
  • PostgreSQL adapter、跨方言 contract suite、rate limit 和 MFA 产品策略;
  • Run waiting_approval 的事务性暂停/恢复装配。

因此生产代码不得导入或调用该 service,也不得把 pending dispatch 直接交给现有 ScheduleService、Shell 或 Tool。完成 wire、审计、digest builder、dispatch executor 与部署门禁前,现有 2.x 行为保持不变。

影响

正面影响:

  • 人工审批精确绑定不可变动作,参数替换会被 digest 拒绝;
  • Role revoke、Project archive 与审批 mutation 有事务 fence
  • 消费后崩溃不会丢失已授权动作,durable dispatch 可恢复;
  • edge 无常驻 expiry timer,表和索引均有界;
  • 创建、决定和消费均支持精确幂等重放与并发裁决。

代价与风险:

  • Approval 与 dispatch 各增加一张表和短写事务;
  • 产品层必须建立 canonical action/preview 生成规范,digest 不能由 Agent 自报后直接信任;
  • durable dispatch 已由 ADR-0032 增加独立 claim/lease/result 状态机,但仍缺真实 handler、recovery resolver 和生产 lifecycle,当前不能执行副作用;
  • Policy version discipline 成为安全不变量,绕过 Repository 直接改表会破坏 fence。

未选择的方案

  1. 直接保存 preview: unknown 并批准该 JSON:可能泄漏 Secret、形状无界且难以 canonicalize,拒绝。
  2. 批准 Tool 名,不绑定参数 digest:审批后可替换目标和参数,拒绝。
  3. 审批后同步调用 Tool/Shell:数据库事务无法覆盖外部副作用,崩溃窗口不可恢复,拒绝。
  4. 先标记 consumed,再投递内存队列:进程崩溃会永久丢失动作,拒绝。
  5. 审批永久有效:Role/plan/风险会漂移,拒绝。
  6. Agent 或 System 自动决定审批:绕过人工边界并产生递归授权,首版拒绝。
  7. 每秒 timer 把过期行改成 expired:edge 空闲写放大且没有必要,拒绝。

验证要求

  • migration/schema ownership 覆盖两张表、唯一索引和 tuple/state constraint
  • 明文 preview、参数和 Secret 不进入 Approval/dispatch 行;
  • 创建只接受 require_approval,决定只接受有 approval.decide 的 User
  • request/decision/consumption ID 的精确重放成功,漂移冲突;
  • now == expiresAt 的决定和消费被拒绝;
  • Policy version 变化使同事务 fence 失败;
  • 双 SQLite 连接竞争决定或消费时只有一个提交;
  • dispatch 冲突会回滚 Approval consumption
  • consume 后恰好存在一个匹配 digest 的 pending durable dispatch
  • Node 22/24 全量测试、类型检查、完整 migration chain schema audit 与 GitNexus reachability 通过;
  • app/loaders/api/services/shared/data 不得导入 Approval core。