fix(automation): evaluateCondition decides the dialect from the source, not from the caller (#4336) - #4453
Merged
Conversation
…t string-compared (#4336) `evaluateCondition` picked its engine by asking whether an `{ dialect, source }` envelope was present. A condition authored as a plain string therefore never reached the CEL engine: it fell through to the legacy `{var}` template path, which substitutes brace holes and then compares the leftover text AS TEXT. Nothing errored and the run was recorded as `success`, with the failure direction depending on the predicate: 'existingTask == null' -> 'existingTask' === 'null' -> always false 'record.rating >= 4' -> 'record.rating' >= '4' -> always true One gate that never opens, one branch pinned open. Author-side discipline could not fix the second half of this: `FlowNodeSchema.config` is an open `z.record`, so no schema transform reaches a `decision` node's `conditions[].expression`, which is exactly where bare-string conditions still live after `registerFlow` parses edges into envelopes. The dialect is now decided by the SOURCE, not by the envelope: a condition is CEL unless it actually contains a `{var}` hole. The same predicate then evaluates the same way wherever it is authored -- edge, start-node gate, or decision node. The `{var}` dialect keeps working where it always did, and gains the two things it was missing: * a quoted literal compares as its contents. `{status} == 'active'` used to compare `active` against `'active'` -- quotes included -- and was false for every value. It is the spelling the flow docs show. * it no longer answers `false` when it could not resolve something. A hole naming no variable (`{lead_record.status}` -- `get_record` stores the whole row under one name, so that key never exists) and a substituted value that is neither boolean, numeric, nor part of a comparison are refused with the source and the offending reference attached, per ADR-0032 1c. Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap and still throw: stating the dialect is the author saying "this is CEL". The sniff reads the source outside string literals, so `record.label == '{pending}'` stays CEL and compares the field. Also updated: app-crm's convert-lead guard (the real instance of the unresolvable brace spelling -- its sibling EDGE already carried the bare-CEL form), the flow docs' three-dialect table and its now-inverted "braces missing in a decision expression" warning, and the `FlowNodeSchema` decision example. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5
#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
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
pushed a commit
that referenced
this pull request
Aug 1, 2026
#4453 landed the same class of fix from the other end: `evaluateCondition` now sniffs the dialect from the source instead of from the caller, so a bare string is CEL unless it holds a `{var}` hole. Three overlaps to reconcile: - The flows guide's "Expressions in flows" section was rewritten on both sides. Kept main's — it is more complete about what the `{var}` dialect now does — and folded in the one fact it cannot know: a decision's `conditions[].expression` is on the expression ledger as a predicate, so a braced spelling there is a build failure, not the `{var}` dialect. Main's "the `{var}` form still works" is true of a start node's plain-string condition and would have been wrong for a decision. - `FlowNodeSchema`'s `@example` likewise. Kept both branches from main and dropped its `// default` annotation on the `true` catch-all: since #4414 the default path is the `isDefault` out-edge, not a catch-all branch. - The decision executor's explicit `dialect: 'cel'` envelope is now redundant for the case it was added for, but it is what keeps run time agreeing with the validators: the sniff would route a braced predicate to the template dialect and run it, while `registerFlow` / `objectstack validate` reject exactly that spelling. Comment rewritten to say so — the old one described a path that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9
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 #4336。
AutomationEngine.evaluateCondition是按信封选引擎的:只有{ dialect, source }才走 CEL,裸字符串一律落到 legacy{var}模板路径,把两边当文本比。不报错,run 记success,而且失败方向取决于谓词本身:existingTask == null'existingTask' === 'null'record.rating >= 4'record.rating' >= '4'→'r' > '4'与 #4440 的分工
#4440 已经把唯一一个撞上这个求值器的内置调用点修了 ——
decision执行器现在自己包{ dialect: 'cel', source }再调。本 PR 修的是求值器本身,这样下一个调用点不必再记得包:方言改由 source 判定,条件里没有{var}洞就是 CEL。这不是重复劳动,理由是
evaluateCondition是AutomationEngine上的公开方法(在dist/index.d.ts里)。一个 plugin 注册的节点执行器写engine.evaluateCondition(cfg.when, vars),拿到的就是上面那张表,没有任何东西提醒它。#4440 之所以要在调用点手工包,正是因为求值器的契约本身是错的 —— 按 Prime Directive #12,该修的是那一份契约,而不是让每个调用点各修一遍。合并 #4440 之后,仓库内已经没有调用点会走到 legacy 路径(边条件、start 门、decision 三处都携带信封),所以运行时爆炸半径确实小 —— 价值在契约与公开 API 这一层,以及下面这三个仍然存在的求值缺陷。
legacy
{var}方言:保留,但补齐{amount} > 100、{status} == active、{a.b} == 7行为完全不变。补上它缺的两件事:{status} == 'active'之前是拿active去比'active'(连引号一起),对任何status取值都是 false。这正是 flow 文档给 decision 节点示范的写法,而且「字符串字面量要加引号」是平台上其他每一个谓词面都要求的。false。 两条出口按 ADR-0032 §1c 改成带 source 和肇事引用的拒绝:{…}洞匹配不到任何流程变量 —— 比如{lead_record.status}。get_record的outputVariable存的是整行,所以这个键根本不存在。(不对称之处也写进了报错:节点输出会以${node.id}.${key}扁平写进变量表,所以{get_lead.id}能替换成功,看起来像是这个写法能用。)这两条正是 @os-zhuang 在 issue 里实测列出的「还剩两条静默 false」。
dialect: 'cel'信封里的花括号仍是 #1491 陷阱、照 throw —— 显式写方言就是作者在说「这是 CEL」。方言嗅探跳过字符串字面量,所以record.label == '{pending}'还是 CEL,比的是字段。有意的收紧
裸字符串只要不是合法 CEL 就会抛错,而之前它会字符串比较出某个答案。安全用例(
process.exit(1)/require("fs").../ 箭头函数 IIFE)也归入这一类:两条路都从不执行宿主代码 —— CEL 里没有process、没有require、没有箭头函数语法 —— 但现在是一个被报告的 fault,不再是静默false。engine.test.ts的口径已跟着改。文档
'{order_amount} > 10000',而 decision 表达式现在走 CEL,那个写法正是 brace-trap,会抛错。未纳入范围
decision.conditions[].expression目前仍无构建期校验:它不在FLOW_NODE_EXPRESSION_PATHS账本里,而账本对账是双向的 —— 要加就得给decision描述符补configSchema,那会顺带触发validateNodeConfigKeys去拒绝 decision 上的config.condition。这正是 #4439 记录的那个结构性缺口(刻意 schemaless 的节点类型进不了账本)。另外它会让构建比运行时更严(构建拒{…},运行时仍支持),那是一次需要 conversion 配套的、有计划的方言下线。验证
nested-region-parity.test.ts里的 applyConversionsToFlow does not recurse into loop bodies — conditions inside aloopare never converted to CEL and the gate silently never opens #4347 块:裸的点号谓词现在正确求值而不是被拒(oppRecord.amount > 500000在 amount=10 时为 false);拒绝仍然覆盖「点号引用混进{…}条件里」的情形。pnpm test全绿(132/132 tasks);service-automation586 passed。tsc错误数与 main 基线一致(5,全在未触碰的行 —— 该包是 type-check 覆盖率账本里的 DEBT 条目)。check:doc-authoring、pnpm --filter @objectstack/spec check:docs通过。相关
loopare never converted to CEL and the gate silently never opens #4347 / fix(spec,service-automation,lint): flow metadata is canonicalized inside structured regions (#4347) #4381 —— 同一求值器上更早的一轮收紧(裸点号引用的拒绝)🤖 Generated with Claude Code
https://claude.ai/code/session_011n4UBkyRZsy6CJqmKg6oA5
Generated by Claude Code