Skip to content

fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4414) - #4440

Merged
os-zhuang merged 3 commits into
mainfrom
claude/app-crm-decision-routing-bug-aufqaj
Aug 1, 2026
Merged

fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4414)#4440
os-zhuang merged 3 commits into
mainfrom
claude/app-crm-decision-routing-bug-aufqaj

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4414.

一个 decision 声明了三条分支路由,只有 edge.condition 真的工作。另外两条是 ADR-0049「declared ≠ enforced」的形状,叠加起来在 shipped 的 example app 里造成了可复现的错误行为。

机制 修前 修后
edge.condition ✅ 唯一工作的 不变
edge.isDefault schema 声明之外零读者 traverseNext 里按 BPMN default flow 执行
conditions[].labelbranchLabel 三个 example app 匹配数 0,静默回退到全边集 能路由;认领不到的 label 记 warn

断掉的链条

crm_convert_lead_wizard 想表达「已转换 → 中止屏;否则 → 向导」。实跑是两条都走:弹出「This lead has already been converted」,然后照样进转换向导。四层静默叠在一起:

  1. decision 的 conditions[0] 写作 {lead_record.status} == 'converted' —— 花括号写进了声明为 bare CEL 的槽位,于是走字符串比较,永不成立;
  2. 落到 conditions[1]('true'),返回 branchLabel: 'No — proceed';
  3. 没有出边 label 叫 'No — proceed'(它们是 'Yes' / 'No')→ 静默回退到全边集;
  4. 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 同一套痕迹。
  • 认领不到的 branchLabel 记 warn,不再吞掉。 遍历仍然回退到全边集(跑到一半的 run 不该死在元数据错误上),但会说清楚:算出的是哪个分支,实际出边 label 有哪些。
  • 没声明 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 #12os 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 去掉,e3bisDefault: true。一个 decision 一种机制,恰好走一条分支。

验证

  • 11 个新的引擎/执行器测试(含 issue 里的 repro,坏形状和修好后两个方向都断言)、12 个新 linter 测试。
  • @objectstack/service-automation 577 / @objectstack/cli 652 / @objectstack/spec 7228 / @objectstack/dogfood 432 全绿。
  • pnpm buildpnpm typecheck(122 tasks)、pnpm lint 干净;check:doc-authoring / check:i18n-coverage / check:nul-bytes / check:release-notes / check:type-check-coverage 通过。
  • 三个 example app build 均无新 finding。

相关

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9


Generated by Claude Code

claude added 2 commits August 1, 2026 07:24
… 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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 7:33am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/service-automation, @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/service-automation, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/service-automation, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
os-zhuang merged commit 5293114 into main Aug 1, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/app-crm-decision-routing-bug-aufqaj branch August 1, 2026 07:46
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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants