Skip to main content

P4.7 P2 write 审批载体选型 —— 决策前的完整权衡(2026-07-09)

P4.7 P1(read-class dispatch)已完成 + 验收。P2 做 write-class tool 审批半场。核实(只读,file:line 亲验)发现 spike 锁定的 ExpertQueueItem 与 gateway 现有实现正面冲突,且两个载体各有真实代价。本文档把选型权衡摊开,供决策,动工前不写代码。用户回来读完拍板载体 + P2 范围。

已锁 / 已知(不复议的部分)

  • 方向 A + 甲(复用 gateway,凭证留控制平面)—— P1 已落地内部信任认证,P2 延续。
  • write tool 流程:write-class → gateway 判 accessClass=write → 不执行真副作用 → 建审批载体 → Expert 批准后 server-side 执行。PRD R6.4 验收:A write-class tool call creates an Expert approval item; nothing executes before approval.
  • messageId 技术门槛已被 P1 意外解决:AgentRun 有 messageId 字段(entities.ts:1846),P1 createRun 时填了 inbound 用户消息 id(conversations.service.ts:3236),gateway execute 已加载 run 对象(tool-gateway.service.ts:85)→ run.messageId 立即可用。所以"建审批项需要非空真实 messageId"能满足。

核实出的真实冲突(三方不自洽)

来源审批载体返回语义证据
gateway 现状ApprovalRequest(表 approval_requests,有 payload jsonb + runId FK,runtime 原生,无 Expert UIwaiting_approval挂起——Hermes 会阻塞)tool-gateway.service.ts:211-242 require_approval 分支已建 ApprovalRequest + AgentRun.status→waiting_approval + pendingApprovalCount++approval-request.service.ts:83-85
spike 结论(用户曾锁)ExpertQueueItemqueued_for_approval正常成功——Hermes 不阻塞)docs/superpowers/specs/2026-07-09-p4.7-hermes-runtime-spike-results.md:49-52
PRD R6.4措辞"Expert queue approval item"(偏 ExpertQueueItem)但点名 ActionPolicyService(那是 ApprovalRequest 路径)未明说docs/specs/prd-specialist-value-output.md:202

关键:spike 锁 ExpertQueueItem 时,可能未充分意识到 gateway 现有 require_approval 已经走 ApprovalRequest。核实后 ApprovalRequest 在 tool 审批场景语义反而更贴。这个锁定值得复议。

两个载体的完整权衡

选项 A:ApprovalRequest(复议 spike,推荐)

  • 优点:①天生有 payload jsonb 承载 write 动作(tool/action/params)②有 runId FK,runtime 原生 ③gateway 现状已建它,改动最小 ④与"甲=复用 gateway/runtime 栈"一致 ⑤Hermes 那侧看到"挂起"直到批准,是 tool-call 的自然语义。
  • 代价:①没有任何 Expert workspace UI——ApprovalRequest 从没在前端露过(ops/agent-runs|approvals|machines 是 SA 只读运维面板,非 Expert 审批面)。要新建 Expert 审批界面(dev 演示期前端难端到端验)②返回语义 waiting_approval(挂起)vs spike 要的 queued_for_approval(正常成功不阻塞 Hermes)——需定:让 Hermes 挂起等批准(同步阻塞,但 /chat 有超时)还是返回成功让 Hermes 继续(异步,真副作用后补)。这点本身是子决策
  • 偏离:违背 spike 明写"审批载体=ExpertQueueItem(用户已锁)"——需用户确认复议。

选项 B:ExpertQueueItem(守 spike)

  • 优点:①对齐 PRD"Expert queue approval item"措辞 ②复用 Expert workspace 现有 HITL 队列(有 UI)③Expert 已习惯在那审草稿。
  • 代价:①messageId 语义不对——现有语义是"待审 AI 草稿"(P3.5/escalation 全传 agentMsg.id),tool 审批场景传 run.messageId(inbound 用户提问),现有 Expert UI 渲染逻辑(如 expert-queue.service.ts:1168item.messageId 当草稿比对)会拿到语义错误对象 ②无 payload 字段——write 动作只能塞 metadata jsonb(entities.ts:921),Expert UI 需专门识别"tool 审批 item"而非草稿 item ③可能需 migration 让 messageId 可空(有 @Index + expert-queue.service.ts 十余处 item.messageId 读点,blast radius 大)④释放机制(F3 atomic release)假定背后是草稿发送,tool 审批的"释放=执行副作用"是不同动作。
  • 偏离:无(守 spike),但代价是硬塞一个不匹配的数据模型。

可自主的后端部分(载体定后)

无论选哪个,这些是纯后端、已明确、可自主:

  1. accessClass 管道getPublishedToolBindings 已返 content.accessClassconfig-assets.service.ts:636)→ 让它流到 gateway。现在 intersectReadToolstool-resolution.util.ts:24,42)在 NestJS 侧就过滤掉 write(accessClass !== "read" continue)→ P2 要放宽让 write 进 manifest(但带 accessClass 标记)。
  2. gateway accessClass 判定分支ActionPolicyService.decideaction-policy.service.ts:60-122)现在无 accessClass 概念(只按 actionType+riskLevel)→ 加 accessClass=write → require_approval 分支(PRD R6.4)。需把 accessClass 从 manifest/请求传到 gateway(ExecuteToolGatewayInput 加字段)。
  3. 释放后 server-side 执行:Expert 批准 → server-side 调 RuntimeToolExecutor.execute 执行真副作用(凭证留控制平面,绝不进 agent)。
  4. 审计:ToolCall 按 osaId 审计(R6.5,现有)。

但这些都挂在载体决策之下——载体没定,gateway write 分支的落地形态定不下来。

审批 UI(PRD T6.4 标 [BE/FE])

  • 选项 A:ApprovalRequest 无 UI → 新建 Expert 审批界面(较大前端工作)。
  • 选项 B:ExpertQueueItem 有队列 UI → 改现有渲染识别 tool 审批 item(中等前端工作)。
  • dev 演示期前端难端到端验——参考 P4.2b(审批 UI)当时纯前端小改 + final review 但没跑 e2e。

✅ 决策已定(2026-07-09,用户拍板):复用 ApprovalRequest + 拓展

用户拍板 = 复用 ApprovalRequest 作 write 审批载体,在其上拓展。推翻 spike 当时锁的 ExpertQueueItem。

推翻 spike 的依据(核实出的新事实,非随意改)——第二轮核实("ApprovalRequest 在 chat 用没用/能否直接用")发现:

  1. ApprovalRequest 的 Expert 审批 UI 已存在且接线frontend/src/app/workspace/approvals/page.tsxRuntimeRequestCards audience="expert"runtime-requests.controller.ts(挂平台用户 JWT)有 list/get/approve/reject/cancel;role/tenant 作用域(Expert 只看 assigned + ExpertAccess 双域)、ledger 审计、run blocker 状态机全活。"ApprovalRequest 无 UI 要从零建"这个原劣势不成立——spike 锁 ExpertQueueItem 时未意识到这点。
  2. 两个载体的"批准后执行副作用"闭环都不存在(grep 无 approval.resolved/waiting_approval 消费者,approve 只改状态不重跑 tool)→ 这块不再是区分因素,两个都要从零建。
  3. 其他维度 ApprovalRequest 全胜:payload jsonb 原生装 write 动作、runtime 原生、无 messageId 语义包袱(ExpertQueueItem 的 messageId 是"待审草稿"语义,tool 审批场景不成立)。
  4. /chat P1 read tool(medium risk)走 gateway 判 allow 直接执行,从不建 ApprovalRequest;runner/legacy agentic 的 create 分支代码活但无已知活业务流程触发。即 ApprovalRequest 现状 = "读/批准 UI+API 活,但没有活的 create caller 喂它"——P2 正好成为它第一个活 caller。

不需要"结合"两个载体:ApprovalRequest 单独几乎够(payload + UI + 作用域 + 审计 + 状态机),唯一缺的"批准后执行"是 ExpertQueueItem 同样缺的。结合 = 双写一致性复杂度,无收益,否掉。

✅ P2 范围已定(全后端闭环,用户暂离期按最佳判断,可推翻)

纯后端全闭环,UI 复用现有 workspace/approvals(不够再小改):

  1. write 触发点:ActionPolicy 加 accessClass=write → require_expert_approval 分支 + 让 write tool 进 manifest(放宽 intersectReadTools 的 read-only gate,但带 write 标记)。
  2. 批准后执行闭环(主工作量):approve → 读 ApprovalRequest.payload(toolName/action/params)→ server-side 调 RuntimeToolExecutor 跑真副作用 → 回写结果/审计。
  3. accessClass gateway server-side 查(不信 agent 传——授权/凭证留控制平面,ADR-036)。
  4. 返回语义:write tool → gateway 返 "queued_for_approval" 让 MCP handler 视为普通成功(Hermes 不阻塞,spike 结论 #4)——真副作用等 Expert 批准后 server-side 执行。

待核实后在实施计划定的:批准后执行用 approve service 方法同步调 executor(最简)vs BullMQ worker 消费 approval.resolved 事件(write tool 是外部 API 可能慢,异步更稳)——倾向先同步,慢了再异步化。

✅ 拓展接线点核实完成(2026-07-09,Explore file:line 亲验)

可自主的拓展点(清楚)

  • approve 后执行:ApprovalRequestService.resolveapproval-request.service.ts:145-189)是同步事务,commit 后触发执行;gateway 执行段(tool-gateway.service.ts:244-297)抽成 executeApprovedToolCall(toolCallId) 复用;执行放审批事务,失败降级记 ToolCall failed + ledger 不回滚审批(ADR-019)。
  • accessClass = gateway server-side 查(不信 agent 传,授权留控制平面):RuntimeControlPlaneModule import ConfigAssetsModule + gateway 经 getPublishedToolBindings 按 toolSlug 查 content.accessClass(需 run→specialistId 解析:AgentRun 有 osaId → specialistId 一跳)。
  • ActionPolicy:EvaluateActionPolicyInputaction-policy.service.ts:11-19)+ ExecuteToolGatewayInputtool-gateway.service.ts:46-59)加 accessClass?decide(:60)在 riskLevel 分支(:109)前加 accessClass==="write" → require_expert_approval
  • write 进 manifest:放宽 intersectReadToolstool-resolution.util.ts:42accessClass !== "read" continue)让 write 进 mcpTools/allowedTools,否则 NOT_IN_MANIFEST 403 走不到 approval。

文件清单(9 处,详见 Explore 报告):tool-resolution.util.ts / conversations.service.ts / runtime-control-plane.module.ts / tool-gateway.service.ts / action-policy.service.ts / approval-request.service.ts / entities.ts(ToolCall 加密列) / 文档 / spec。不改 agent tool_gateway_client.py(accessClass 不由 agent 传)+ 不改 runtime-requests.controller.ts(approve/reject 入口 + Expert 权限已就位)。

🚧 两个实施前决策点(暂离期按最佳判断定的默认,安全敏感,等用户确认可推翻)

决策 A:write tool 原始 params 存储 = 加密存(默认)。 核实揪出的最大风险——原始 params 全链路只存了 redact 后版本(tool-gateway.service.ts:222 payload.params 是 this.redact()entities.ts:2162 ToolCall.inputRedacted 也 redact),批准后重跑没有真实入参。write tool 的 params 必然可能含敏感数据(这是 write 的本质)。默认 = ToolCall 新增一列存 AES-256-GCM 加密的原始 params(复用 MASTER_ENCRYPTION_KEY + integration_credentials 的加密先例),批准时解密重跑。否掉明文存(违反 redact 初衷)+ 否掉"不存重新发起"(Hermes 短命子进程已结束,路可能不通需另 spike)。expand-contract:nullable 加列。

决策 B:批准后执行 = 先同步(默认)。 approve commit 后同一请求内同步调 executor,不引 BullMQ。贴现有代码(approve 已同步事务),执行放事务外失败降级(ADR-019)。write tool 慢了(executor 有 30s 外部 fetch)再异步化——YAGNI。

⏸️ 状态:实施计划待写,代码未开跑

决策 A(params 加密存储,安全敏感 + schema 改动)+ 让 write tool 真执行外部副作用 = P2 最高风险部分,值得用户过目决策再落地(同 P1 认证的处理)。用户连续几轮在关键决策点亲自介入(载体/结合/复用 ApprovalRequest),P2 应 stay-in-loop 而非全自动跑完。故:固化默认 + 写实施计划,但不开跑代码,等用户拍决策 A/B(或推翻)。

不做(P2 之外)

  • P4.7 P1 的 I-1(org-claim 交叉校验,防御纵深)——独立 Phase-2 硬化。
  • regen 自己的 AgentRun(让 regen 也能 tool)。