Skip to content

FlowVariableSchema has no defaultValue, so a flow variable cannot be declared bound — the obvious remedy for an unbound-variable condition does not exist #651

Closed

Description

@os-zhuang

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.params value, because an assignment node is unconditional. Harmless here — the console's flow-action trigger sends only { recordId, objectName }, so createOpportunity cannot 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

  1. Is defaultValue on 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 now flow.variables reads exactly like a declaration that binds, which is what made this a defect rather than a known limitation.
  2. If it should exist: it belongs upstream in packages/spec (FlowVariableSchema) and in service-automation's execute/executeWithoutRetry seeding, 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 A decision node's singular config.condition is never evaluated — 24 flows author inert metadata #650.
  3. Decide whether a screen field's defaultValue should 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

  1. added
    metadataDeclarative metadata — schema, security posture, UI surfaces
    upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream
    and removed on Aug 2, 2026
  2. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    已就同一处缺口向平台提了 objectstack-ai/objectstack#4697 —— 本件是 HotCRM 侧的追踪点(lead_conversion 里那个 assignment 变通有地方可指,平台补上之后可以照着删),#4697 是要求平台改的那一半。两边互链。

    #4697 里还多带了一张实测的中止表,正好补上本件"守卫救不了未绑定变量"那句话的证据:

    表达式 X 未绑定 X = null X = 稀疏行
    has(X.f) ABORT Unknown variable: X false false
    has(vars.X) false true true

    也就是说:唯一能在"变量可能压根没绑定"这种局面下有结论的写法是 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

  3. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    Collaborator

    [17.0-rc2验收复核] 结论:仍复现(rc.2 上 schema 与引擎两半的缺口原样存在)

    证据:

    • schema 半:@objectstack/spec@17.0.0-rc.2 的 FlowVariableSchema 仍是 name/type/isInput/isOutput 四字段 $strict(dist/flow.zod-koL_4VyN.d.ts:41)。empirical parse:
    FlowVariableSchema.safeParse({name:'x', type:'boolean'})                      → success: true
    FlowVariableSchema.safeParse({name:'x', type:'boolean', defaultValue:false})  → success: false
      {"code":"unrecognized_keys","keys":["defaultValue"],"message":"Unrecognized key(s) on this flow variable: `defaultValue`. …"}
    

    环境: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

  4. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    Collaborator

    Blocked-by: objectstack-ai/objectstack#4697

    分诊(PM 循环,消费上方 [17.0-rc2验收复核] 结论,会话 session_01VHrPAGEgFDoHjphqYG4BMa):复核判定「仍复现、hotcrm 侧无可修项」采纳 —— schema 与引擎两半的实测证据充分($strict 拒绝 defaultValue、绑定仅在 caller 提供时发生),workaround(init_defaults assignment)与守卫(flow-variable-conditions.test.ts)保持现状。打 pm:blocked,镜像 objectstack#4697(open,平台队列中);平台落地后按正文回收 workaround。


    Generated by Claude Code

  5. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    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. FlowVariableSchema is no longer four strict fields:

    declare const FlowVariableSchema: z.ZodObject<{
        name: z.ZodString;
        type: z.ZodString;
        isInput: z.ZodDefault<z.ZodBoolean>;
        isOutput: z.ZodDefault<z.ZodBoolean>;
        defaultValue: z.ZodOptional<z.ZodUnknown>;   // ← new
    }, z.core.$strict>;

    2. The engine applies it. @objectstack/service-automation's variable seeding now falls through to the declaration:

    } else if (v.defaultValue !== void 0) {
      variables.set(v.name, v.defaultValue);
    }

    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: defaultValue is honoured for non-input variables too, and a caller-supplied param still wins over the default, so it does not have the assignment node's clobbering behavior.

    That means the #643 workaround in lead_conversion — the init_defaults assignment node seeding createOpportunity: false — is now removable, along with the duplication it created between the screen field's defaultValue and the node.

    Not done here: this PR is the dependency bump only, and rewiring lead_conversion is a metadata change with its own review surface (and test/flow-variable-conditions.test.ts pins 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

  6. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    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/spec type declaration and the @objectstack/service-automation variable-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:

    1. The schema key. FlowVariableSchema is no longer four strict fields — it now carries defaultValue as an optional unknown, alongside name / type / isInput / isOutput.
    2. The engine applies it. Variable seeding now falls through to the declaration:
    } else if (v.defaultValue !== void 0) {
      variables.set(v.name, v.defaultValue);
    }

    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 — defaultValue is honoured for non-input variables, and a caller-supplied param still wins over the default, so it does not carry the assignment node's clobbering behaviour.

    That is the exact defect this card names, measured on the exact artifacts the card measured to file it. pm:blocked is 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_conversion cleanup this card was holding has been given its own home: #1155. This card explicitly filed itself "so the workaround in lead_conversion has 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 the init_defaults assignment 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

  7. added and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    metadataDeclarative metadata — schema, security posture, UI surfacespriority:p2Medium: important, M3upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions