行动与审批
「问数 · 分析 · 决策 · 行动」的最后一腿。一条判定触发之后, 系统不会自己去改任何东西——它把「该做什么」冻结成一份提案: 载荷、依据、通道、风险都在里面,人批准了才外送,外送了还要回执。 而系统自身没有写回业务库的通道,这不是承诺,是结构。 送出去也不是终点:按约定窗口回来重新测量,行动前后的块序列与当初判定用的是 同一套噪声带——「超出噪声带 / 无可辨识变化」都被如实记下, 后者不是失败,是最有价值的诚实结论。
为什么行动停在一份提案
判定可以自动,行动不行。两者出错的代价不是一个量级:一条判错的阈值, 最坏是多发一条通知;一个自动执行错的行动,改的是你的真实系统。 所以这里把自动化的边界画在「提案」上—— 机器负责发现和起草,人负责点头。
另一半是只读姿态。整个行动包(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
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
提案的名字取得很具体:一条按维度触发的规则可能同时命中好几组,但一条出站消息只能 指一个——载荷里的数字来自贡献绝对值最大的那一组(与判定引擎 排序用的同一个口径),其余留在证据里。
模板:组织自己的响应资产
规则说「当这个触发时,组织的响应是模板 X」。模板是一份
.trove/actions/<name>/action.yml——和 Skills 同一套
草稿 → 管理员确认门禁:未确认的模板不能被规则引用,
规则保存时的资产 lint 会直接拒绝。
模板是数据,不是代码,也不是提示词。它点名一个通道
(如 ops-alerts,绝不是裸 URL——URL 与密钥住在部署配置
agent.action.channels 里,模板可以被人审阅而不携带凭证),
并给出一段带 {{变量}} 洞的 JSON 载荷。创建时先按样例变量试渲染一遍:
写错的变量名或 JSON 语法错误是写给作者本人的一个 400,
而不是规则第一次触发那个晚上的惊喜。
| 字段 | 说明 |
|---|---|
| action_type | notify(告警形状)/ webhook(载荷原样) |
| target.channel | 点名通道;resource 是可选的自由标签(如房间名),只给人看,不参与路由 |
| risk | low / medium / high |
| approvals_required | v1 只支持 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
2
3
4
loan-high 触发生成的提案在等拍板——批准之前,任何东西都不会离开系统。- 1两个页签——模板(响应资产)与提案(待处置队列)。
- 2待办计数——「未闭环」是待批准 / 已批准 / 失败三态的并集;其余状态各自有计数时才多一枚(图中即「未闭环 1 · 待批准 1」,没有已驳回就看不到它)。
- 3状态过滤与冻结提示——按阶段筛选;旁注重申「载荷在创建时冻结」:批准外送的就是抽屉里看到的那一份。
- 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'——只有已批准的(或失败待重试的)提案可以被外送」)。
继续读
| 想了解 | 去哪 |
|---|---|
| 触发提案的判定从哪来 | 判定规则 |
| 只读姿态、模板门与重试策略的完整推导 | 深潜 · 行动安全架构、闭环与契约 |
| 触发通知里的「主因」行是谁算的 | 判定规则 · 分析→决策桥 |
| 效果验收的结局怎么回评 | 判定规则 · 质量回评 |
| 判定凭什么说自己「确定」 | 判定规则 · 噪声带与显著性 |
| 指标、维度与主题域 | 语义层 |
| 订阅投递与定时任务 | 自动化与治理 |
| 只读边界全清单 | 安全边界 |