8.8 KiB
ADR-0031:ApprovalRequest 与 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与 opaqueactionRef; - canonical action payload 的 SHA-256
actionDigest; - 用户可见安全预览的 SHA-256
previewDigest; low|medium|high|criticalrisk;- 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
expired 是 now >= expiresAt 且持久状态仍为 pending|approved 时的有效状态,不需要后台 timer 写库。决定和消费都在精确边界前完成;now == expiresAt 已过期。首版审批寿命最大 24 小时,审批不会延长原 expiry。
rejected、consumed 为终态。未来若增加 cancel、supersede 或 re-open,必须新增显式 version/state 和 migration,不能覆盖历史决定。
3. 决定只能来自当前被授权的稳定 User
决定审批必须同时满足:
decidedBy是稳定userActorRef;- requester 与 decider 不同;
- 当前 Project Policy 对 decider 的
approval.decide返回allow; - 写事务内 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 的动作;deny 和 allow 都不创建冗余审批。消费前重新对原 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 不等于副作用已执行。一次性消费在同一数据库事务完成两件事:
ApprovalRequest approved@v2 -> consumed@v3;- 创建唯一
ApprovedActionDispatch,复制 Project、permission、action identity、requester、consumer 和时间。
若 dispatch 插入、状态更新或 fence 校验任一步失败,事务整体回滚,Approval 仍可安全重试。consumptionId 与 dispatchId 都是有界幂等身份;完全相同的重放返回同一 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/v3Approval 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_requiredresolver; - 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。
未选择的方案
- 直接保存
preview: unknown并批准该 JSON:可能泄漏 Secret、形状无界且难以 canonicalize,拒绝。 - 批准 Tool 名,不绑定参数 digest:审批后可替换目标和参数,拒绝。
- 审批后同步调用 Tool/Shell:数据库事务无法覆盖外部副作用,崩溃窗口不可恢复,拒绝。
- 先标记 consumed,再投递内存队列:进程崩溃会永久丢失动作,拒绝。
- 审批永久有效:Role/plan/风险会漂移,拒绝。
- Agent 或 System 自动决定审批:绕过人工边界并产生递归授权,首版拒绝。
- 每秒 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。