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

134 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 持久状态与有效过期分离
持久状态机为:
```text
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
决定审批必须同时满足:
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` 的动作;`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` 不等于副作用已执行。一次性消费在同一数据库事务完成两件事:
1. `ApprovalRequest approved@v2 -> consumed@v3`
2. 创建唯一 `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/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。