Repository navigation
FlowVariableSchema has no defaultValue, so a flow variable cannot be declared bound — the obvious remedy for an unbound-variable condition does not exist #651
Description
Found while implementing #643, which prescribed a remedy that turned out not to be expressible.
The gap
#643 asked that an unbound flow variable be fixed by "declaring the variable with a default so it is always bound", rather than by a has() guard. Measured against @objectstack/spec@17.0.0-rc.1, neither half of that is available:
1. The schema has no such key. FlowVariableSchema is z.core.$strict over exactly four fields:
declare const FlowVariableSchema: z.ZodObject< {
name: z.ZodString;
type: z.ZodString;
isInput: z.ZodDefault< z.ZodBoolean >;
isOutput: z.ZodDefault< z.ZodBoolean >;
}, z.core.$strict >;Being strict, adding defaultValue to a flow variable is a parse error, not an ignored key.
2. The engine has no default-application step. AutomationEngine.execute binds a declared variable only when the caller supplied it:
if (flow.variables) {
for (const v of flow.variables) {
if (v.isInput && context?.params?.[v.name] !== void 0) {
variables.set(v.name, context.params[v.name]);
}
}
}So declaring a variable in flow.variables binds nothing at runtime. A declared input is unbound on every run whose caller did not pass it, and a strict-CEL condition reading it then aborts with No such key: <name> (Unknown variable: <name> for the bare spelling), the run is recorded failed, and the automation does not happen. That is the exact live defect #643 fixed in lead_conversion.
What #643 did instead, and why it is a workaround
lead_conversion now seeds the variable with an assignment node placed ahead of the screen that collects it:
{
id: 'init_defaults', type: 'assignment', label: 'Default Conversion Options',
config: { assignments: { createOpportunity: false } },
}That achieves the right semantics — bound on every path, guaranteed by the graph rather than by the client — using the mechanism the platform actually has. But it is a workaround in three ways:
- The default now lives twice: once on the screen field's
defaultValue, once on the assignment node. Nothing keeps them in step. - It costs a node and an edge per defaulted variable, in a graph where nodes are the unit of review.
- It would clobber a genuinely-supplied
context.paramsvalue, because anassignmentnode is unconditional. Harmless here — the console's flow-action trigger sends only{ recordId, objectName }, socreateOpportunitycannot arrive as a param — but it is not the general shape.
test/flow-variable-conditions.test.ts pins the structural property (every variable a condition reads is bound on every path to it) so the gap cannot silently reappear, and its house-rule block records "declaring ≠ binding" as the trap it is.
What to establish
- Is
defaultValueon a flow variable an intended-but-missing spec feature, or is the graph-binds-it model deliberate? If deliberate, the authoring story needs to say so — right nowflow.variablesreads exactly like a declaration that binds, which is what made this a defect rather than a known limitation. - If it should exist: it belongs upstream in
packages/spec(FlowVariableSchema) and inservice-automation'sexecute/executeWithoutRetryseeding, applied before the start condition is evaluated so a start condition can read a defaulted variable too. Both halves are needed — a schema key with no engine support would be the inert-metadata failure of Adecisionnode's singularconfig.conditionis never evaluated — 24 flows author inert metadata #650. - Decide whether a screen field's
defaultValueshould seed the flow variable of the same name at suspend time, which would remove the duplication above without any new spec key.
This is a platform-side gap; HotCRM can only work around it. Filing here so the workaround in lead_conversion has something to point at and can be removed when the platform closes it.
Filed unassigned.
Activity
os-zhuang commentedon Aug 2, 2026
已就同一处缺口向平台提了 objectstack-ai/objectstack#4697 —— 本件是 HotCRM 侧的追踪点(
lead_conversion里那个assignment变通有地方可指,平台补上之后可以照着删),#4697 是要求平台改的那一半。两边互链。#4697 里还多带了一张实测的中止表,正好补上本件"守卫救不了未绑定变量"那句话的证据:
has(X.f)Unknown variable: Xfalsefalsehas(vars.X)falsetruetrue也就是说:唯一能在"变量可能压根没绑定"这种局面下有结论的写法是
has(vars.X),而最顺手、最像自保的裸写法has(X.f)照样把 run 打成 failed。这一条值得单独记住——它意味着"加个has()就安全了"这个直觉在变量作用域上是错的,也是本件第 1 条那个问题(flow.variables读起来像"声明即绑定")的第二重代价:作者以为自己有两条退路,其实一条都不成立。本件按
upstream:objectstack归类,prio:p2—— 结构性断言已经由test/flow-variable-conditions.test.ts钉住(每个条件读到的变量必须在到达它的每条路径上被绑定),所以这个缺口今天不会悄悄回来,等平台。另:原先打的是裸
upstream标签(灰色、无描述,不在.github/labels.yml里),已换成治理版upstream:objectstack。Generated by Claude Code
yinlianghui commentedon Aug 5, 2026
[17.0-rc2验收复核] 结论:仍复现(rc.2 上 schema 与引擎两半的缺口原样存在)
证据:
@objectstack/spec@17.0.0-rc.2的FlowVariableSchema仍是 name/type/isInput/isOutput 四字段$strict(dist/flow.zod-koL_4VyN.d.ts:41)。empirical parse:@objectstack/service-automation@17.0.0-rc.2dist 中变量绑定仍只在 caller 提供时 set(if (v.isInput && context?.params?.[v.name] !== void 0),index.js:1266/3095)——无任何默认值应用步骤,「declaring ≠ binding」不变。lead_conversion(trigger → resume),run summary 中init_defaultsassignment 节点每次执行 —— 即 Flow conditions overvars.*/get_recordoutputs have the same abort exposure #633 fixed forrecord.*— unmeasured #643 的图内播种仍是唯一让条件可总的手段。环境:hotcrm@0899b4f + @objectstack 17.0.0-rc.2
建议:仍有效 —— 等上游 objectstack#4697;HotCRM 侧无可修项,
lead_conversion的 assignment workaround 与test/flow-variable-conditions.test.ts守卫保持现状,平台落地后按本单正文回收。Generated by Claude Code
yinlianghui commentedon Aug 5, 2026
Blocked-by: objectstack-ai/objectstack#4697
分诊(PM 循环,消费上方 [17.0-rc2验收复核] 结论,会话
session_01VHrPAGEgFDoHjphqYG4BMa):复核判定「仍复现、hotcrm 侧无可修项」采纳 —— schema 与引擎两半的实测证据充分($strict拒绝defaultValue、绑定仅在 caller 提供时发生),workaround(init_defaultsassignment)与守卫(flow-variable-conditions.test.ts)保持现状。打pm:blocked,镜像 objectstack#4697(open,平台队列中);平台落地后按正文回收 workaround。Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
Re-measured on
@objectstack/*17.0.0-rc.6 during the rc.5 → rc.6 upgrade (#1059, PR #1066). Both halves this card asked for now exist.1. The schema key.
FlowVariableSchemais no longer four strict fields:2. The engine applies it.
@objectstack/service-automation's variable seeding now falls through to the declaration:The shipped commentary also answers question 1 in this card's "what to establish" — the graph-binds-it model was not deliberate — and resolves the two follow-ons:
defaultValueis honoured for non-input variables too, and a caller-supplied param still wins over the default, so it does not have theassignmentnode's clobbering behavior.That means the #643 workaround in
lead_conversion— theinit_defaultsassignment node seedingcreateOpportunity: false— is now removable, along with the duplication it created between the screen field'sdefaultValueand the node.Not done here: this PR is the dependency bump only, and rewiring
lead_conversionis a metadata change with its own review surface (andtest/flow-variable-conditions.test.tspins the structural property that would need to keep holding). Worth its own card.Scope note: static read of the shipped schema and engine — no flow was run to confirm a defaulted variable reaches a start condition bound.
Generated by Claude Code
hotlong commentedon Aug 14, 2026
Closing — provenance
Closed as part of the GA close-out bookkeeping sweep (#1151, group 3), on the recorded on-card evidence below. No new probe was run; this is a bookkeeping close of a reading already taken.
(a) Version.
@objectstack/*17.0.0-rc.6, read during the rc.5 → rc.6 upgrade (#1059, PR #1066). Upstream: objectstack#4697 (the mirror opened from this card).(b) Probe. Static read of the shipped
@objectstack/spectype declaration and the@objectstack/service-automationvariable-seeding code — the same two artifacts this card measured at rc.2 to establish the gap.(c) Result. Both halves this card required now exist:
FlowVariableSchemais no longer four strict fields — it now carriesdefaultValueas an optional unknown, alongsidename/type/isInput/isOutput.The shipped commentary also answers this card's "what to establish" item 1 directly: the graph-binds-it model was not deliberate. It resolves both follow-ons too —
defaultValueis honoured for non-input variables, and a caller-supplied param still wins over the default, so it does not carry theassignmentnode's clobbering behaviour.That is the exact defect this card names, measured on the exact artifacts the card measured to file it.
pm:blockedis removed in the same write as this close, since objectstack#4697's remedy has shipped.(d) Source comment. #651 (comment)
(e) Authority. #1150, on maintainer ruling 2026-08-14 (verbatim 「同意」); dispatched as #1151 group 3.
What this reading does not establish, and where it went
The rc.6 comment carries an explicit scope note: static read only — no flow was run to confirm a defaulted variable reaches a start condition bound. This card's "what to establish" item 2 specifically required the seeding be applied before the start condition is evaluated; the engine branch was read in the variable-seeding block, but that ordering was not measured. The close rests on the schema key and the engine branch both being present in the shipped artifacts, which is what the card's defect statement is about — not on a runtime demonstration.
The
lead_conversioncleanup this card was holding has been given its own home: #1155. This card explicitly filed itself "so the workaround inlead_conversionhas something to point at and can be removed when the platform closes it", and the rc.6 reading deferred that rewiring as "worth its own card" — which nobody had filed. Closing this without filing it would have dropped the cleanup silently, along with the duplication hazard it carries (the default lives both on the screen field and on theinit_defaultsassignment node, with nothing keeping them in step). #1155 also carries forward the two unmeasured claims above, to be taken on 17.0.0 GA rather than rc.6.Generated by Claude Code
Generated by Claude Code