mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
134 lines
8.8 KiB
Markdown
134 lines
8.8 KiB
Markdown
# 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` 与 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。
|