首页/数据能力/行动与审批

行动与审批

「问数 · 分析 · 决策 · 行动」的最后一腿。一条判定触发之后, 系统不会自己去改任何东西——它把「该做什么」冻结成一份提案: 载荷、依据、通道、风险都在里面,人批准了才外送,外送了还要回执。 而系统自身没有写回业务库的通道,这不是承诺,是结构。 送出去也不是终点:按约定窗口回来重新测量,行动前后的块序列与当初判定用的是 同一套噪声带——「超出噪声带 / 无可辨识变化」都被如实记下, 后者不是失败,是最有价值的诚实结论。

为什么行动停在一份提案

判定可以自动,行动不行。两者出错的代价不是一个量级:一条判错的阈值, 最坏是多发一条通知;一个自动执行错的行动,改的是你的真实系统。 所以这里把自动化的边界画在「提案」上—— 机器负责发现和起草,人负责点头。

另一半是只读姿态。整个行动包(trove/services/action/)不导入数据源模块: ActionService 和 ActionDispatcher 的构造参数里没有连接器注册表, 数据库 SQL 守卫也不在它们的依赖里。ActionDispatcher 的全部输入是一个 通道名加一个载荷字典——它在物理上就无法写业务库,无论模板写了什么。 这条边界不靠约定维持:一个守卫测试直接对着源码断言「这个包没有碰连接器的路径」, 将来哪次编辑伸手去拿连接器,会在测试里响亮地失败,而不是在评审里被悄悄放过。

models.py:110 ActionProposal service.py:155 ActionService dispatcher.py:130 ActionDispatcher

一条提案的生命周期

八个状态,一条单向街。能「往回走」的只有失败后的显式重试与取消, 其余每一步都是一次留痕的状态迁移:

flowchart LR
    T["判定触发
autonomy: propose"]:::entry --> P["pending
待批准 · 载荷已冻结"]:::act P -- "人工批准" --> A["approved
已批准"]:::act P -- "人工驳回" --> R["rejected"]:::entry P -- "超时清收" --> E["expired"]:::entry P -- "撤销" --> C1["cancelled"]:::entry A -- "外送" --> D["dispatched
通道已收下"]:::act A -- "撤销" --> C2["cancelled"]:::entry A -- "MCP 拉取并签收" --> V["delivered
已回执"]:::act D -- "签收" --> V D -- "重试" --> F["failed
外送失败 · 原样留痕"]:::err F -- "显式 retry" --> D D -- "通道失败" --> F classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px; classDef act fill:#8b5cf614,stroke:#8b5cf699,stroke-width:1.5px; classDef err fill:#dc262614,stroke:#dc262699,stroke-width:1.5px; linkStyle 10 stroke:#8b5cf6,stroke-width:1.5px
图 1 · 提案状态机。只有 pending 能被批准;只有 approved(或失败后重试)能被外送;delivered 由签收到达。每个箭头都写一行审计记录。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。
状态含义还能做什么
pending待批准;载荷此刻已定稿批准 / 驳回 / 撤销;到点后过期
approved人工批准,尚未外送外送 / 撤销;MCP 客户端可拉取并签收
dispatched通道已接受(HTTP 2xx),尚无回执签收;不再可撤(已出楼)
delivered回执到达——对方确实拿到了终态
failed外送失败,原因在原行上显式 retry(有次数上限)
rejected / cancelled / expired人工驳回 / 撤销 / 超时清收终态

models.py:38 PROPOSAL_STATUSES service.py:460 dispatch service.py:759 ack

载荷创建即定稿

提案一出生,出站消息的字节就确定了——渲染发生在创建时, 之后不再重渲染。批准的是哪段文字,送出去的就是哪段文字;如果外送时才渲染, 一条被编辑过的规则或模板就能改掉一份已经批准过的消息, 而那正是审批这一步存在的意义。

发出的信封里还带一个 _trove 块:提案 id、幂等键、数据源、规则、模板、 风险、批准人、外送时刻。收件方据此在自己的侧边去重与归因——重发同一份不会变成两条行动。

service.py:790 _envelope propose.py:213 build_proposal

同一触发,只提案一次

判定任务会重跑——手动重跑、调度推进两次、崩溃后重试——但同一场触发不该 产出第二份提案。幂等键是 sha256(rule_id | rule_digest | anchor_date | template | params): 同一条规则、同一版本、同一个锚定日、同一个模板 → 折叠成一份;规则改过(摘要变了) 或换了日子,才算新的触发。刻意不把数字算进去—— 重跑时数据略有变化仍然是同一场触发,按数值做键会让每个 tick 都喷一份提案。 去重检查发生在渲染之前:重复的那次是廉价空操作,而不是一次「模板坏了」的假故障。

propose.py:38 idempotency_key service.py:255 propose_from_verdict

提案的名字取得很具体:一条按维度触发的规则可能同时命中好几组,但一条出站消息只能 指一个——载荷里的数字来自贡献绝对值最大的那一组(与判定引擎 排序用的同一个口径),其余留在证据里。

propose.py:85 primary_group

模板:组织自己的响应资产

规则说「当这个触发时,组织的响应是模板 X」。模板是一份 .trove/actions/<name>/action.yml——和 Skills 同一套 草稿 → 管理员确认门禁:未确认的模板不能被规则引用, 规则保存时的资产 lint 会直接拒绝。

模板是数据,不是代码,也不是提示词。它点名一个通道 (如 ops-alerts,绝不是裸 URL——URL 与密钥住在部署配置 agent.action.channels 里,模板可以被人审阅而不携带凭证), 并给出一段带 {{变量}} 洞的 JSON 载荷。创建时先按样例变量试渲染一遍: 写错的变量名或 JSON 语法错误是写给作者本人的一个 400, 而不是规则第一次触发那个晚上的惊喜。

字段说明
action_typenotify(告警形状)/ webhook(载荷原样)
target.channel点名通道;resource 是可选的自由标签(如房间名),只给人看,不参与路由
risklow / medium / high
approvals_requiredv1 只支持 1;其他值在创建/读取时被响亮拒绝(一个「2」表现得像「1」是最糟的组合)
payload_template带闭集 {{变量}} 的 JSON 文档

templates.py:60 ActionTemplateService rules.py:734 lint_rule_assets

渲染是闭集的:没有表达式,没有 eval

占位符只能写 template_render.py:44 TEMPLATE_VARIABLES 里那十九个名字——规则身份、判定值、主组数字、时间、提案与运行身份。 没有 Jinja,没有表达式语言(与判定条件的表达式语言同一款纪律, 理由相同:填的内容最终会变成一条出站消息)。三条硬规则:

写法结局为什么
未知变量({{mesage}} 打错字) 硬错误,错误文本里附全部合法名字 渲染成空串的字段,与「本来就该是空的」长得一模一样,等发现时消息已经进了错误的收件箱
闭集内但这条规则给不出值(聚合规则没有 dim) 硬错误,提案不创建 规则与模板是两份各自人工编辑的资产,对不上就该在提案创建时暴露——宁可不发,不发半成品
值里含引号、换行或注入形状的字符串 JSON 转义后再替换,结果按解析验证 值逃不出它所在的字符串字面量;渲染结果必须能被 json.loads 解回一个对象,否则拒绝

template_render.py:150 render_payload template_render.py:195 validate_template

注入扫描:报告,不阻断

确认一份模板时,载荷文本与批准人看到的标签会过一遍注入扫描, 命中项返回给人看,但不拦截——与 Skills 同一款纪律。 拦截会毁掉作者本来就要的内容;沉默则会让管理员确认了自己没读过的东西。

两道门,加一个总闸

从「规则触发」到「消息出楼」之间站着两个互相独立的门,加一个部署层的总闸:

门在哪拦什么
模板门 模板服务 + 规则资产 lint 未确认的模板不能被规则引用;引用一个不存在或未确认的模板,规则保存不下来
审批门 提案状态机 提案以 pending 出生,只有人的 approve 能把它变成 approved——而 approved 是 dispatch 唯一可用的起点
总闸 agent.action.enabled 部署配置,默认 false 关着时 propose 与 dispatch 两个往外走的方向都被拒;批准 / 驳回 / 撤销 / 过期 / 回执与模板管理照常可用——可以先备好模板、清掉积压,再决定开不开外送

行为开关里最要防的是「关着,但看起来像开着」。所以关闸时的 dispatch 是抛错拒绝,不是静默跳过——这是全系统唯一一处「什么都没发生」与 「消息已送出」绝不能混淆的调用。

rules.py:57 AUTONOMIES service.py:359 approve config.py:347 enabled

规则侧的对应字段是 action.autonomy——只有两个取值,且刻意没有 「auto」:notify_only(响应就是那条通知本身,永不离开系统) 与 propose(可以生成提案,但仍要人批准)。规则还可以写 recommendation(人写的「该怎么办」,随提案进入批准人的视野)与 driver_dimension(分析桥沿哪个维度找主因)。lint 会提醒: 声明了 action 却没写 recommendation,批准人看到的就是一段没有理由的模板。

rules.py:88 ActionRef rules.py:202 recommendation rules.py:210 driver_dimension

批量审批:只批不外送

积压多了可以在提案列表里勾选、批量批准 / 驳回——但批量动词是闭集: 只有 approve 与 reject。dispatch 是唯一有 外部副作用的动词,批量外送等于一键群发,被路由层整批 400 拒绝; cancel / retry 是纠错动作,也不成批。

批量不是「循环调单条」:每条的结果显式落在返回的 results 里 (一条失败不影响其余,失败行带原因、可单独重试);条数超限(200,与列表页上限同源) 是整批拒绝而不是截断——截断后前 N 条已改状态、调用方却以为全处理了, 比整批拒绝难查得多。并发也收了口:两个管理员同时批准同一条,只有一条落, 输家响亮失败而不是静默覆盖,也不会留下幽灵决定行。 逐条审计的明细带 batch: True,与单条动作的痕迹区分开。

service.py:119 BATCH_DECISIONS actions.py:221 batch

失败是响亮的

外送失败在这里不吞——与定时任务的告警路径刻意相反。 告警路径失败可以静默:告警会重复,下一个 tick 会再投一次。 行动不会重复:它对着一场具体的触发被批准了一次。所以一次失败的 dispatch 会 把提案置为 failed、写下一行带错误的投递记录、并出现在管理端的待办列表里; 再试一次是一次显式的 retry,且受 max_attempts(默认 3)约束; 到期未决的可重试失败也会被退避扫描自动重试(见下)。

通道本身也是「失败即结果」:未配置的通道名不是异常,而是一次失败的投递—— 通道名打错、URL 不合法、网络超时,都是落在提案与投递记录上的可读事实。 每次投递记录带 HTTP 状态与一段有上限的响应摘录(512 字符): 足够调试一次拒绝,小到恶意或话痨的端点点不满存储。

dispatcher.py:198 send service.py:540 retry

外送不是一次 POST 赌一把:护栏与重试

外送是外部系统调用,会超时、会被限流、会打到配错的地址。这些现实被内建进流程, 而不是留给使用者自己兜:

护栏行为
dry-run 预演 dry_run 动词走完前置判定后直接返回:不发 POST、不动提案状态,只写一条 status="dry_run" 的投递记录——预演过什么,审计轨迹里看得到
风险档拒发 风险高于配置的 max_risk 直接拒发:写 failed + 错误原因,可审计,不静默
通道限速 每条通道 60 秒窗口内的发送次数有上限——一条吵闹的规则不会把某个 IM 通道打成刷屏源
错误分类重试 5xx / 429 / 超时标 retryable;4xx 是配置错误,重试只会重复失败——不重试,把原文摆给你看
退避与到期扫描 下次可试时间由 (attempts, deliveries.attempted_at) 纯派生(零新增列);retry_due_proposals 自动扫到期的失败提案——重试是自动的,但只重试分类为可重试的

guard.py:70 risk_allowed guard.py:85 channel_rate_ok guard.py:115 backoff_delay guard.py:137 next_attempt_at dispatcher.py:123 retryable service.py:460 dispatch service.py:551 retry_due_proposals

拉通道:MCP 与 webhook 并列

外送不是只有「推」一种姿势。已批准且未过期的提案同时出现在 MCP 侧的 trove://proposals 资源里,客户端可以list_proposals 看清单、fetch_proposal 取那份被人批准过的载荷、做完之后 ack_proposal 签收(幂等——已签收的再签是空操作)。 签收即 delivered:一个拉取了已批准提案并回执的客户端, 就已经收到它了,与 webhook 是否推过无关。

这条通道有一条刻意的边界:MCP 只能看和签收,不能批准。 批准 / 驳回 / 外送都是人在管理台做的决定——机器通道不投票。 提案清单也按数据源授权过滤,「取不到」与「没授权」返回逐字相同的一句话: 拒绝不能变成一件存在性预言机。

server.py:303 proposals_resource server.py:446 fetch_proposal server.py:482 ack_proposal

审计轨迹:谁批的、何时发的、对方收没收到

每一次状态迁移都写一行 approvals(追加式,含操作人、动作、备注、时间), 每一次外送与签收都写一行 deliveries(通道、HTTP 状态、响应摘录)。 签收也写进同一条投递轨迹,而不是在别处立一个旗标—— 回执与投递是同一件事的两半。再加管理端 API 的 action.* 审计行, 「谁在什么时候批准了哪一份、发了几次」在提案存储之外也有答案。

models.py:151 Approval models.py:163 Delivery actions.py:276 decide_action_proposal

发出去了,然后呢:闭环效果验收

一个行动发出之后,它对指标到底有没有用?这里不猜,也不靠手动前后对比: 到期由后台按与判定同一口径回来测量——重新解析、编译、执行(只读), 把行动前的块序列与行动后的块序列放进同一套噪声带, 给出三态结论:超出噪声带 / 无可辨识变化 / 判不了。 「无可辨识变化」不是失败,它是最有价值的诚实结论——它告诉你这个行动没起作用。

测量闸口语义
观察窗未满距外送不足约定天数 → 跳过,且不算「已测量」——还没到时候
测量器缺席没有可用的测量器 → 跳过,同样不算已测。「没测」与「测了没结果」从账本上就是两回事
测量器出错写一行 error 结局——响亮记下,不重试、不吞
行动层开关不是闸口:关闭只是不再外送新行动;对已有行动的测量是读取,照常进行

测量的诚实藏在数字形态里:行动前后块序列的对比(delta、pct、z)、 同一套噪声带的 outside_band 三态、样本不足时的 low_n 标与具名降级原因。 若规则声明了因果梯并能给出 ATT,结果同时携带它(method: "its+did"); 给不了就标 causal: "unavailable" 保留 ITS 本身——能算什么说什么。 测量器复用判定路径的解析、编译与执行 helper,测量与判定同口径; 它的物理位置在判定包(如传输器的姿态:包外持有连接器),行动包自己不获得查询能力。

结局落在提案自己的存储里:GET /v1/admin/actions/proposals/{id}/outcomes 一次读全「什么出去了、之后发生了什么」;这些结局还会被 质量回评按规则版本聚合——闭环的账本就是它。

effect.py:131 measure_effect outcome.py:98 make_verifier service.py:605 measure_due actions.py:197 outcomes 端点

在管理台里

管理台的「行动」页有两个页签:模板(草稿确认门禁、通道名、 风险级别、命中注入扫描的报告、试渲染预览)与提案 (按状态过滤的列表、待办计数、以及逐条的批准 / 拒绝 / 外送 / 重试 / 预演(dry-run)/ 确认回执 / 详情)。 详情抽屉里是提案全文加它的完整审计轨迹。行动层未启用时,页面顶部有醒目的 「已停用」横幅——列表照常可读,外送按钮背后的那一步会被服务拒绝。

管理台「行动」页的提案页签:待办计数(未闭环 1 · 待批准 1)、状态过滤、一条待批准提案(loan-high → loan-baseline-alert,风险中)与行尾的批准 / 拒绝 / 详情 1 2 3 4
图 2 · 提案页签。一条 loan-high 触发生成的提案在等拍板——批准之前,任何东西都不会离开系统。
  1. 1两个页签——模板(响应资产)与提案(待处置队列)。
  2. 2待办计数——「未闭环」是待批准 / 已批准 / 失败三态的并集;其余状态各自有计数时才多一枚(图中即「未闭环 1 · 待批准 1」,没有已驳回就看不到它)。
  3. 3状态过滤与冻结提示——按阶段筛选;旁注重申「载荷在创建时冻结」:批准外送的就是抽屉里看到的那一份。
  4. 4一条提案——规则 → 模板 → 风险 → 数据源 → 创建时间(更右侧还有有效期至 / 尝试 / 错误,表格放不下时横向滚动可见),行尾是本阶段可用的动作(批准 / 拒绝 / 详情);批准之后才会出现「外送」等后续动作。

提案表支持批量处置:勾选(只有 pending 的行可勾)→ 批量批准 / 驳回对话框, 对话框逐条给出载荷、证据与原问题预览;高风险提案必须逐条勾选确认才能提交。 提交后若有失败,面板逐条给原因、失败行可单独重试——批量不把任何一条的失败藏起来。

提案的来路在「判定规则」页里:一条规则声明了 action 之后, 它的每次触发都可以在这里看到「提案了没有、批没批、发没发出去」。

管理端接口

方法路径作用
GET/v1/admin/actions/templates模板列表 + 行动层状态(是否启用、已配置通道名)
POST/v1/admin/actions/templates创建模板草稿(返回注入扫描命中项)
POST/v1/admin/actions/templates/{name}/confirm · reject确认 / 驳回草稿
GET/v1/admin/actions/proposals提案列表(按状态 / 数据源过滤)+ 各状态计数
GET/v1/admin/actions/proposals/{id}单条提案 + 完整审计轨迹(批准、投递)
POST/v1/admin/actions/proposals/{id}/{decision}状态迁移,闭集动词:approve / reject / cancel / dispatch / retry / dry_run / ack
POST/v1/admin/actions/proposals/batch批量批准 / 驳回({ids, decision, comment?}):逐条结果不连坐、超限整批拒绝、逐条审计(details.batch=True)
GET/v1/admin/actions/proposals/{id}/outcomes提案的效果验收记录(测量窗、三态结论、方法、降级原因)

actions.py:38 _DECISIONS actions.py:76 list_action_templates

动词表是闭集并在路由层先校验的,路由表长不出服务没实现的动词; 失败以 400 回服务自己的错误文本——那些文本是写给人读的 (「提案 p-… 是 'dispatched'——只有已批准的(或失败待重试的)提案可以被外送」)。

继续读

想了解去哪
触发提案的判定从哪来判定规则
只读姿态、模板门与重试策略的完整推导深潜 · 行动安全架构、闭环与契约
触发通知里的「主因」行是谁算的判定规则 · 分析→决策桥
效果验收的结局怎么回评判定规则 · 质量回评
判定凭什么说自己「确定」判定规则 · 噪声带与显著性
指标、维度与主题域语义层
订阅投递与定时任务自动化与治理
只读边界全清单安全边界
本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页