fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4414) - #4440
Merged
Merged
Conversation
… branch label (#4414) A `decision` node advertised three ways to split a path and only `edge.condition` did anything. `FlowEdgeSchema.isDefault` had zero readers outside its own declaration, and the `conditions[].label` → `branchLabel` route matched 0 out-edge labels across every example app before falling back to the full edge set in silence. Stacked, the two shipped a guard that does not guard: `crm_convert_lead_wizard` showed an already-converted lead the abort screen AND walked it into the conversion wizard behind it. The three mechanisms now compose as one model in `traverseNext`: `branchLabel` narrows the edge set, `condition` gates each edge, `isDefault` catches whatever is left. - `isDefault` is enforced as the BPMN default flow: traversed only when no conditional sibling matched, and kept out of the unconditional parallel fan-out. Passed over because a real branch won, its target records the same `skipped` step a closed gate does (#4354). - A branch label no out-edge carries is logged instead of swallowed. Traversal still falls back to the full edge set — a run mid-flight must not die on a metadata error — but says which branch was computed and which labels exist. - A decision that declares no `conditions` reports no branch. It used to report `'default'` regardless, a label no out-edge in the repo carried, which is why every decision node fell back to the full edge set. The sentinel survives for the case it describes (declared conditions, none matched) and is now claimed by the `isDefault` edge as well. - `conditions[].expression` is evaluated as the bare CEL it is declared to be. The raw string went to the legacy `{var}` template path, where a dotted reference cannot resolve and the branch is decided by string comparison; a brace-in-CEL predicate now fails loudly (ADR-0032 §1c). Caught at authoring time too, since a wrong route is silent at run time by nature (Prime Directive #12): `flow-branch-label-unmatched`, `flow-decision-unconditional-branch`, `flow-default-edge-with-condition` and `flow-multiple-default-edges`. The first two fire on the pre-fix `convert-lead.flow.ts` and are silent after it. app-crm's guard is now a plain exclusive gateway — the redundant `config.conditions` is gone and `e3b` carries `isDefault: true`, so exactly one branch runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…Default contract change `content/docs/references/` is generated from `packages/spec`; the #4414 `.describe()` rewrites left flow.mdx and schemaless-node-config.mdx stale, which is what `check:docs` caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9
os-zhuang
pushed a commit
that referenced
this pull request
Aug 1, 2026
#4440 landed the decision-node half from the other side: its executor now wraps `conditions[].expression` in a CEL envelope at the call site, and app-crm's guard became a plain exclusive gateway with no `config.conditions` at all. Resolved: * examples/app-crm/convert-lead.flow.ts — took main's version outright. The block this branch corrected no longer exists there. * engine.ts — both sides are additive at the top of the module (this branch's template-hole/quoted-literal helpers, main's DEFAULT_BRANCH_LABEL); kept both. * flows.mdx — kept both edits (main rewrote the edge table and added the branching section; this branch rewrote the expression-dialect table), and re-attributed the decision-expression note to #4414 + #4336 rather than to this branch alone. Also fixed the "Basic Structure" example, which #4440 left carrying `'{order_amount} > 10000'`: with decision expressions now routed as CEL that spelling is the brace-trap and throws. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5
os-zhuang
pushed a commit
that referenced
this pull request
Aug 1, 2026
…cision call site The decision executor wraps its expression in a CEL envelope as of #4440, so this branch is no longer what makes a decision predicate evaluate. Say what it actually does: fix the evaluator's own dialect decision (public API — a plugin-registered executor still hits the reported table), close the legacy path's two silent-`false` exits, and fix the quoted-literal comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5
This was referenced Aug 1, 2026
os-zhuang
added a commit
that referenced
this pull request
Aug 1, 2026
…e, not from the caller (#4453) `AutomationEngine.evaluateCondition` picked its engine by asking whether an `{ dialect, source }` envelope was present, so a condition handed to it as a plain string never reached CEL — it fell to the legacy `{var}` template path and both sides were compared as text. `existingTask == null` became `'existingTask' === 'null'` (always false); `record.rating >= 4` became `'r' > '4'` (always true). Both reported success. #4414/#4440 fixed the one built-in reaching this by wrapping at the call site. This fixes the evaluator: the dialect is read from the source, and a condition is CEL unless it actually contains a `{var}` hole. `evaluateCondition` is public API, so a plugin-registered executor was still getting the old behaviour. The `{var}` dialect keeps working and gains what it was missing: a quoted literal compares as its contents (`{status} == 'active'` was false for every value), and its two silent-`false` exits — an unresolvable `{…}` hole, and a substituted value that is neither boolean, numeric, nor part of a comparison — are refused with the source attached (ADR-0032 §1c). Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap. The sniff skips string literals, so `record.label == '{pending}'` stays CEL. Closes #4336. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4414.
一个
decision声明了三条分支路由,只有edge.condition真的工作。另外两条是 ADR-0049「declared ≠ enforced」的形状,叠加起来在 shipped 的 example app 里造成了可复现的错误行为。edge.conditionedge.isDefaulttraverseNext里按 BPMN default flow 执行conditions[].label→branchLabel断掉的链条
crm_convert_lead_wizard想表达「已转换 → 中止屏;否则 → 向导」。实跑是两条都走:弹出「This lead has already been converted」,然后照样进转换向导。四层静默叠在一起:conditions[0]写作{lead_record.status} == 'converted'—— 花括号写进了声明为 bare CEL 的槽位,于是走字符串比较,永不成立;conditions[1]('true'),返回branchLabel: 'No — proceed';'No — proceed'(它们是'Yes'/'No')→ 静默回退到全边集;e3b无条件 → 无论如何都执行。而作者要修第 4 步最自然的写法isDefault: true,那也是死键。现在的模型
branchLabel收窄边集 →condition逐边把关 →isDefault兜底。isDefault落地。 默认边只在同源的条件兄弟边全部不成立时才走,并且不再属于无条件并行扇出 —— 这个区分正是这个标记存在的意义。被真分支挤掉时,按 Surface flow run summaries (selected / acted / skipped) — a scheduled flow that does nothing is currently indistinguishable from one with nothing to do #4354 给目标节点记一条skipped步(带skippedBy),和关闭的 gate 同一套痕迹。conditions的 decision 不报分支。 以前一律报'default'—— 一个仓库里没有任何出边携带的 label —— 这就是每个 decision 节点都回退到全边集的原因。'default'哨兵保留给它真正描述的情形(声明了条件、一条都没中),并且现在也能被isDefault边认领,作者不必再额外把那条边 label 成'default'。conditions[].expression按它自己声明的 bare CEL 求值。 裸字符串原先被送进 legacy{var}模板路径,lead.status == 'converted'在那里根本解析不了,分支是靠字符串比较定的。与edge.condition不同,这个槽位没有ExpressionInput信封携带方言(decision descriptor 是刻意 schemaless 的),所以由执行器补上。带花括号的谓词现在按 ADR-0032 §1c 响亮失败,而不是静默判 false。也在 authoring 时拦
错误的路由在运行时天生是静默的,所以按 Prime Directive #12 在
os build/os validate加了四条 warning:flow-branch-label-unmatched—— 就是这次 shipped 的形状;flow-decision-unconditional-branch—— 有把关边的 decision 旁边还挂着无条件边,真正的漏洞;flow-default-edge-with-condition—— 同一条边既是 default 又带 condition,自相矛盾(BPMN 禁止带条件的 default flow);flow-multiple-default-edges—— 一个节点两条兜底边,那是并行扇出不是「否则」。前两条在修前的
convert-lead.flow.ts上都会亮,修后静默 —— 把 flow 文件 stash 掉重跑 app-crm 的 build 验证过。Example app
crm_convert_lead_wizard的守卫改成纯 exclusive gateway:多余的config.conditions去掉,e3b加isDefault: true。一个 decision 一种机制,恰好走一条分支。验证
@objectstack/service-automation577 /@objectstack/cli652 /@objectstack/spec7228 /@objectstack/dogfood432 全绿。pnpm build、pnpm typecheck(122 tasks)、pnpm lint干净;check:doc-authoring/check:i18n-coverage/check:nul-bytes/check:release-notes/check:type-check-coverage通过。相关
loopare never converted to CEL and the gate silently never opens #4347 / registerFlow's three validators still walk top-level nodes only — a region's malformed structure, unknown node type and undeclared config key all pass registration #4389(同族的遍历那半)configSchema,而decision/script/subflow这类刻意 schemaless,导致它们声明的 CEL 槽位结构上进不了账本、永远没有 build-time 校验器。本 PR 补上了运行时那半,但作者仍要跑起来才知道。)🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9
Generated by Claude Code