Skip to content

A decision node has three declared ways to route a branch and two of them do nothing — app-crm's convert-lead guard runs both branches #4414

Description

@os-zhuang

在为 #4336 量化剩余静默 false 时发现的。#4336 的「Related」一节提了一句 branchLabel 回退,但实际情况比那句描述更糟,而且已经在 shipped 的 example app 里造成了可复现的错误行为。

一个 decision 想分支,有三条声明出来的路

机制声明处实际
decision.config.conditions[].labelbranchLabellogic-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:2520signal?.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」中止屏,然后照样进入转换向导。

链条是这样断的:

  1. 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(永远不成立);
  2. 于是落到 conditions[1]('true'),返回 branchLabel: 'No — proceed';
  3. 没有出边 label 叫 'No — proceed'(它们是 'Yes' / 'No')→ 静默回退到全边集;
  4. e3a 带 CEL 条件(裸 lead_record.status == 'converted'),这条是对的,已转换时成立 → 中止屏执行;
  5. e3b无条件 → 无论如何都执行 → 向导也走。

第 1、3 步各自静默,第 5 步是真正的漏洞。而作者要修第 5 步,最自然的写法是给 e3bisDefault: true —— 那也是死键

建议

拆成三件事,严重级别不同:

  1. isDefault:enforce-or-remove(ADR-0049)。 要么在 traverseNext 实现 BPMN default-flow 语义(所有 conditional 兄弟边都不成立时才走),要么按 spec-property-retirement 流程摘掉。现状是它向作者承诺了一个不存在的机制。
  2. branchLabel 对不上时不要静默。traverseNext 已经文档化了「无匹配则回退全边集」这个行为,但一个 decision 明明算出了分支、却没有任何边认领它,这是元数据错误,不是正常路径。至少 warn,附上算出的 label 和实际的出边 label 集合。
  3. 修 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(同族的遍历那半)。

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions