在为 #4336 量化剩余静默 false 时发现的。#4336 的「Related」一节提了一句 branchLabel 回退,但实际情况比那句描述更糟,而且已经在 shipped 的 example app 里造成了可复现的错误行为。
一个 decision 想分支,有三条声明出来的路 机制 声明处 实际 decision.config.conditions[].label → branchLabellogic-nodes.ts 执行器三个 example app 里与出边 label 匹配数 0 ,一律静默回退到全边集 FlowEdgeSchema.isDefaultflow.zod.ts:321,.describe('Marks this edge as the default path when no other conditions match')零读者 。全仓库除了 schema 声明和 FLOW_EDGE_KEYS 之外没有任何代码读它edge.conditionFlowEdgeSchema.condition✅ 唯一真正工作的
前两条都是 ADR-0049 「declared ≠ enforced」/ PD #10 的形状:作者按文档写了,运行时什么也没发生,而且不报错。
实测数据 三个 example app 的全部 decision 节点:
app-crm/crm_convert_lead_wizard · check_converted
decision labels: ["Yes — already converted","No — proceed"]
out-edge labels: ["Yes","No"] → matched: 0
app-showcase/showcase_inquiry_purge · any_found
decision labels: [] out-edge labels: ["yes","no"] → matched: 0
app-showcase/showcase_budget_approval · needs_exec
decision labels: [] out-edge labels: ["true","false"] → matched: 0
app-showcase/showcase_project_escalation · triage
decision labels: [] out-edge labels: ["critical","normal"] → matched: 0
app-todo/task_completion · check_recurring
decision labels: [] out-edge labels: ["Yes","No"] → matched: 0
四个连 conditions 都没声明(执行器返回 branchLabel: 'default'),唯一认真写了的那个 label 对不上。decision 的 branchLabel 路由在整个仓库里从未真正生效过。
(branchLabel 机制本身不是死的 —— resume/approval 路径确实在用,engine.ts:2520 的 signal?.branchLabel。死的是 decision 节点对它的使用。)
后果:app-crm 的守卫不守 examples/app-crm/src/flows/convert-lead.flow.ts 想表达「已转换 → 中止屏;否则 → 进入向导」。实跑(把该 flow 的分支骨架接上真引擎):
lead status 'converted' → visited: ["screen_already_converted","screen_account"]
lead status 'open' → visited: ["screen_account"]
已转换的 lead 两条路都走 —— 弹出「This lead has already been converted」中止屏,然后照样进入转换向导。
链条是这样断的:
decision 的 conditions[0] 用了 {lead_record.status} == 'converted',那是 Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no error #4336 那条静默 false(永远不成立); 于是落到 conditions[1]('true'),返回 branchLabel: 'No — proceed'; 没有出边 label 叫 'No — proceed'(它们是 'Yes' / 'No')→ 静默回退到全边集; e3a 带 CEL 条件(裸 lead_record.status == 'converted'),这条是对的 ,已转换时成立 → 中止屏执行;e3b无条件 → 无论如何都执行 → 向导也走。第 1、3 步各自静默,第 5 步是真正的漏洞。而作者要修第 5 步,最自然的写法是给 e3b 加 isDefault: true —— 那也是死键 。
建议 拆成三件事,严重级别不同:
isDefault:enforce-or-remove(ADR-0049)。 要么在 traverseNext 实现 BPMN default-flow 语义(所有 conditional 兄弟边都不成立时才走),要么按 spec-property-retirement 流程摘掉。现状是它向作者承诺了一个不存在的机制。branchLabel 对不上时不要静默。traverseNext 已经文档化了「无匹配则回退全边集」这个行为,但一个 decision 明明算出了分支、却没有任何边认领它,这是元数据错误,不是正常路径。至少 warn,附上算出的 label 和实际的出边 label 集合。修 app-crm 的 flow。 但这个得等 1 有结论 —— 在 isDefault 落地前,唯一能表达「否则」的写法是给 e3b 写一条反条件。第 2 条基本零风险(warn),可以先做。
复现 // decision 的 conditions 与出边 label 对不上 → 路由静默失效; // 无条件边无论如何都执行。 nodes : [ { id : 'check' , type : 'decision' , label : 'Check' , config : { conditions : [ { label : 'Yes — already converted' , expression : "{rec.status} == 'converted'" } , { label : 'No — proceed' , expression : 'true' } , ] } } , { id : 'abort' , type : 'mark' , label : 'Abort' } , { id : 'proceed' , type : 'mark' , label : 'Proceed' } , ] , edges : [ { id : 'a' , source : 'check' , target : 'abort' , condition : "rec.status == 'converted'" , label : 'Yes' } , { id : 'b' , source : 'check' , target : 'proceed' , label : 'No' } , // 无条件 → 永远执行 ] , 相关:#4336 (静默 false 的那半)、#4347 / #4389 (同族的遍历那半)。
在为 #4336 量化剩余静默 false 时发现的。#4336 的「Related」一节提了一句
branchLabel回退,但实际情况比那句描述更糟,而且已经在 shipped 的 example app 里造成了可复现的错误行为。一个 decision 想分支,有三条声明出来的路
decision.config.conditions[].label→branchLabellogic-nodes.ts执行器FlowEdgeSchema.isDefaultflow.zod.ts:321,.describe('Marks this edge as the default path when no other conditions match')FLOW_EDGE_KEYS之外没有任何代码读它edge.conditionFlowEdgeSchema.condition前两条都是 ADR-0049 「declared ≠ enforced」/ PD #10 的形状:作者按文档写了,运行时什么也没发生,而且不报错。
实测数据
三个 example app 的全部 decision 节点:
四个连
conditions都没声明(执行器返回branchLabel: 'default'),唯一认真写了的那个 label 对不上。decision 的 branchLabel 路由在整个仓库里从未真正生效过。(
branchLabel机制本身不是死的 —— resume/approval 路径确实在用,engine.ts:2520的signal?.branchLabel。死的是decision节点对它的使用。)后果:app-crm 的守卫不守
examples/app-crm/src/flows/convert-lead.flow.ts想表达「已转换 → 中止屏;否则 → 进入向导」。实跑(把该 flow 的分支骨架接上真引擎):已转换的 lead 两条路都走 —— 弹出「This lead has already been converted」中止屏,然后照样进入转换向导。
链条是这样断的:
conditions[0]用了{lead_record.status} == 'converted',那是 Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no error #4336 那条静默 false(永远不成立);conditions[1]('true'),返回branchLabel: 'No — proceed';'No — proceed'(它们是'Yes'/'No')→ 静默回退到全边集;e3a带 CEL 条件(裸lead_record.status == 'converted'),这条是对的,已转换时成立 → 中止屏执行;e3b无条件 → 无论如何都执行 → 向导也走。第 1、3 步各自静默,第 5 步是真正的漏洞。而作者要修第 5 步,最自然的写法是给
e3b加isDefault: true—— 那也是死键。建议
拆成三件事,严重级别不同:
isDefault:enforce-or-remove(ADR-0049)。 要么在traverseNext实现 BPMN default-flow 语义(所有 conditional 兄弟边都不成立时才走),要么按spec-property-retirement流程摘掉。现状是它向作者承诺了一个不存在的机制。branchLabel对不上时不要静默。traverseNext已经文档化了「无匹配则回退全边集」这个行为,但一个 decision 明明算出了分支、却没有任何边认领它,这是元数据错误,不是正常路径。至少 warn,附上算出的 label 和实际的出边 label 集合。isDefault落地前,唯一能表达「否则」的写法是给e3b写一条反条件。第 2 条基本零风险(warn),可以先做。
复现
相关:#4336(静默 false 的那半)、#4347 / #4389(同族的遍历那半)。