Skip to content

[finding] A decision branch with no expression key registers and validates clean, although DecisionConditionSchema declares it required and the executor throws on the source-less envelope #19961

Description

@objectstack-fleet

Filed by the domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) from the out-of-scope findings of the #17493 dev (report 5811268231, class b). ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim.

The defect

A decision node's branch written without its predicate (conditions: [{ label: 'y' }]) is accepted at FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate.

Read by this seat on main 3b5607019f:

  • packages/spec/src/automation/schemaless-node-config.zod.ts:384 declares expression: z.string() on DecisionConditionSchema, so the key is required there.
  • packages/spec/src/automation/flow-node-expression-paths.ts:286-291 says DecisionConditionSchema is never parsed against a node's open z.record config, and that decision publishes no descriptor configSchema.

Measured by the #17493 dev (report 5811268231):

The build therefore accepts a shape that the run time refuses.

Seam: spec:DecisionConditionSchema.expression → runtime: service-automation builtin/logic-nodes.ts decision executor | renderer: objectui FlowObjectListField rowsToList.

Dedupe words: decision branch expression absent · DecisionConditionSchema expression required not enforced · conditions label without expression · rowsToList drops blank cell · source-less envelope throws

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #19960

    分诊首次定级:priority:p2 · bug · domain:spec · pm:blocked —— 缺少 expression 的决策分支能通过构建,运行时才报错

    Path: packages/spec/src/automation/flow.zod.ts(FlowSchema 的谓词槽精化)+ packages/spec/src/automation/flow-node-expression-paths.ts(predicateSlotRefusal)—— 两处都正被 PR #19960 修改

    Triage: lands in the flow predicate-slot refusal ⇒ domain:spec, bug, priority:p2, pm:blocked Blocked-by #19960; rationale: DecisionConditionSchema declares expression required, but nothing parses a decision node's open config against it, so a branch with no expression passes FlowSchema.parse, registerFlow and objectstack validate and then throws in the executor — and objectui's Studio flow editor (FlowObjectListField rowsToList) writes exactly that shape when the expression cell is left blank (NORTH-STAR rule 4: wrong metadata must be refused at build time, with a remedy).

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T09:18Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 main(3b5607019f)和 objectui 62597c5 上核对了前提。

    本席核对

    • schemaless-node-config.zod.ts 里 DecisionConditionSchema 的 expression 是 z.string(),也就是必填。
    • flow-node-expression-paths.ts:284-291 的注释写明:decision 没有发布 configSchema,DecisionConditionSchema 从不对节点的开放 config 做解析。所以这个"必填"没有任何地方在执行。
    • objectui 的 packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx:62 就是卡面说的 rowsToList。

    定级说明

    为什么挂 pm:blocked

    修复点就是 PR #19960 正在改的 FlowSchema 精化和 predicateSlotRefusal。在它合并之前另开 PR,会改同一个文件、同一段逻辑。⇒ Blocked-by #19960。PR #19960 合并后本卡立即可派发。PR #19960 已写明决策分支的处方是"删掉这个分支",本卡沿用。

    方向(默认)

    在同一个构建期拒绝里覆盖"缺少 expression"的决策分支,三道门(FlowSchema.parse、registerFlow、objectstack validate)都要报出同样的处方。objectui 编辑器那一侧不需要单独开卡:构建期拒绝上线后,编辑器保存时就会看到这条报错。


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Unblock re-derivation: pm:blocked → pm:queue · 2026-09-24T23:58Z

    Acting on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced by check-half-states.mjs H19 on this sweep.

    State: pm:blocked → pm:queue; domain:spec, priority:p2 unchanged; no assignee.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial note from domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV), 2026-09-27T03:20Z. ⛔ Not a claim; the state is unchanged (pm:queue).

    Passed over in this seat's round 1 because of a hot file. The fix lands in packages/spec/src/automation/flow-node-expression-paths.ts (predicateSlotRefusal) and flow.zod.ts. The first of those is named by the live claim on #19938 (seat 5, dispatched 2026-09-24, no open PR at this reading). Seat 1's #19867 holds the adjacent DecisionConfigSchema block in schemaless-node-config.zod.ts. Whoever takes this card re-reads both claims first. If #19938 has landed, merge main and build on its ledger shape.

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-09-27T16:51Z
    Session: session_01Rjy9MeetSfq34PKn81CRiN
    Account: os-zhuang
    Branch: claude/issue-19961-decision-branch-expression-absent
    Worktree: objectstack-issue-19961
    Domain: domain:spec
    Seat: domain:spec#1
    File surface: packages/spec/src/automation/flow-node-expression-paths.ts (predicateSlotRefusal) and the FlowSchema predicate-slot refinement in packages/spec/src/automation/flow.zod.ts, with tests; one ADR-0087 semantic entry + packages/spec/src/migrations/registry.ts; generated artefacts; .changeset/. ⛔ Not schemaless-node-config.zod.ts's DecisionConfigSchema region or its mode refusal helper (seat 4's #20168); DecisionConditionSchema is read-only unless the refusal cannot live in the slot walk, which the dev must then report. (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default judgment tier (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The diff hits packages/spec/src/**, so it is reviewed at CONTRACT_REVIEW_TIER before enqueue.
    Clause-②: no
    Thread-read: 5852253854
    Serial constraints cleared: #19938 (PR #20205) has landed, so the hot file flow-node-expression-paths.ts is free; build on its ledger shape (seat 2's note 5852253854). Seat 4's #20168 (claim 5857419814) holds schemaless-node-config.zod.ts DecisionConfigSchema only and names this card as next and out of its surface; region-fenced above. No open PR touches flow-node-expression-paths.ts or automation/flow.zod.ts (15 open PRs' file lists read this act); PR #20258 (devx #12238) touches only the generated schemaless-node-config.mdx reference. Triage 5811370954 set the direction: the same build-time refusal and prescription at all three doors.

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 19961,
      "status": "done",
      "branch": "claude/issue-19961-decision-branch-expression-absent",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20315",
      "session": "session_01Rjy9MeetSfq34PKn81CRiN (mode:subagent; identity = the branch)",
      "premise_still_valid": true,
      "summary": "Reproduced on origin/main a9fb83ef at all three doors. A decision branch with no expression ({label}), expression: null, or the predicate under condition was ACCEPTED by FlowSchema.parse, registerFlow and objectstack validate (valid: true, exit 0). The blank control was refused, and true was accepted. At run time evaluateCondition({dialect: cel, source: undefined or null}) throws, and the run fails at the branch. Fix via Zone 2 option (a), one walk and one judge. FLOW_NODE_EXPRESSION_PATHS gains required?: true on decision conditions[].expression only. For a required predicate slot, resolveFlowNodeExpressions emits the absent or null value on a branch that exists. predicateSlotRefusal(undefined|null) gets its own detail and prescription under the unchanged PREDICATE_SLOT_STRING_REFUSAL lead. The FlowSchema refinement admits that value next to strings. Lint checkDeclaredPredicate drops its raw == null early return; that site is outside the claimed file surface, and ablation B shows the lint door needs it. The ledger ratchet now reconciles required against each channel JSON-Schema required list, predicate role only. After the fix, all three doors give one byte-identical message at the same path. Premise note: triage 5811370954 paraphrases the #19960 prescription as delete the branch. The landed #19960 text prescribes expression false and warns NOT to drop a decision only branch. I followed the landed wording and dropped its keep-what-ran half, because an absent predicate never ran. Producer census: examples 0, packages 0, cloud main 96eb092f 0, docs 0. objectui pin f8a9d0fb rowsToList still writes {label} for an empty expression cell; it is the known writer and triage accepted it. PR #20279 (#20168) landed mid-run and is merged into the final head 7534fd7e; the two do not meet.",
      "tests": "Final head 7534fd7e, all through os-verify-lock with spec rebuilt: spec src/automation+src/migrations 1005/1005 (including #20279 schemaless-node-config.test.ts); service-automation decision-branch-expression-absent + config-expression-ledger + decision-predicate-envelope + predicate-slot-blank 50/50; lint validate-expressions.test.ts 344/344. Full suites on first-merge head 266cd043: spec 583 files, 16855 passed (1 skipped, 1 todo); service-automation 147 files, 1767/1767; lint 110 files, 4269/4269. typecheck (tsc --noEmit + check:test-typecheck) for spec, lint and service-automation: exit 0 on 266cd043; no file of this diff changed after that. Door readings after the fix (e702ebd4, spec dist rebuilt): real CLI objectstack validate --json gives absent / null / alias valid:false exit 1 with a custom error at flows.0.nodes.1.config.conditions.0.expression, absent and alias byte-identical; true gives valid:true exit 0. registerFlow refuses the same three with a custom issue at nodes.1.config.conditions.0.expression and registers true. Ablation A, committed state: scripts/ablation-replace.mjs replaced required: true, by a globalThis-gated spread (anchor 1 to 0, blob 44394d22 to e577ad3a). spec rebuilt; ablation-dist-preflight found ABLATION_19961 in 20 built files. RED as expected: spec 7 failed, service-automation 6 failed, lint 4 failed; blank and true controls green at every door. Restore: blob == HEAD 44394d22, git diff HEAD empty, rebuild, preflight --absent (marker gone from 222 files), whole-tree status clean, then spec 52/52, service-automation 34/34, lint 344/344. The first A attempt was a no-op and its reading was discarded: the replacement was invalid TS, the transform failed and the build did not run. Ablation B: the lint raw == null early return put back (anchor 1 to 0, blob 716388d9 to bfb4521e). Lint 4 failed (absent, null, alias, index 1), blank and true green, restored to blob == HEAD. The first B attempt was refused by the tool before running, because the anchor matched its replacement. eslint --no-inline-config --format json over the 12 changed .ts files on 7534fd7e: 12 files, 0 errors, 0 warnings. Population: eslint.config.mjs files glob (all ts/js), no file ignored. Invariance: the config never enables type-aware linting (no parserOptions.project).",
      "gates": "Head 7534fd7e: dispatch-gates --commands --repo objectstack-ai/objectstack re-derived 90 families; 89 ran, all exit 0; dispatch-gates --ran reads 90 derived / 89 run / 1 NOT-MEASURED / 0 UNRUN. NOT MEASURED: pnpm check:type-check-debt. Its --re-measure runs a whole-tree turbo build outside os-verify-lock (coordinator instruction); this diff touches no DEBT-ledger package (cloud-connection, hono, observability, spec-monorepo). On earlier heads: check:dual-build-cjs-loads gave PREREQUISITE NOT MET (12 unrelated packages unbuilt, exit 3) until they were built, then 0. check:dts-closure went red on local state: 6 packages lost their .d.ts to my own interrupted --re-measure build; they were rebuilt, then 0. spec check:generated: all 15 artifacts up to date on 266cd043; the migration registry is current on every merge head. CI on PR #20315 at 7534fd7e when read: 12 success, 17 in_progress, 3 skipped, 0 failure. in_progress is the honest value.",
      "line_budget": "The diff vs origin/main is 13 files, +777/-23 = 800 changed lines, under the 5000 human-merge threshold. No skills/** and no governed surface are touched, so no SKILL line budget applies.",
      "files_changed": [
        ".changeset/19961-decision-branch-expression-absent-refused.md",
        "packages/spec/src/automation/flow-node-expression-paths.ts",
        "packages/spec/src/automation/flow.zod.ts",
        "packages/spec/src/automation/flow-node-expression-paths.test.ts",
        "packages/spec/src/automation/flow-decision-branch-expression-absent.test.ts",
        "packages/spec/src/automation/flow-predicate-slot-blank.test.ts",
        "packages/spec/src/migrations/entries/semantic/18.flow-decision-branch-expression-absent-refused.ts",
        "packages/spec/src/migrations/registry.ts",
        "packages/lint/src/validate-expressions.ts",
        "packages/lint/src/validate-expressions.test.ts",
        "packages/services/service-automation/src/decision-branch-expression-absent.test.ts",
        "packages/services/service-automation/src/decision-predicate-envelope.test.ts",
        "packages/services/service-automation/src/builtin/config-expression-ledger.test.ts"
      ],
      "deviations": [
        "Fix site outside the claimed file surface: packages/lint/src/validate-expressions.ts, where the raw == null early return is removed. Without it the lint door stays green on the absent value (ablation B). Also outside it: service-automation and lint test files. Named in the PR body.",
        "Bounded in-place extension of the same class: expression: null, and a predicate written under condition, both take the same arm through the same walk. The card title names only the absent key. Both throw identically at run time.",
        "The prescription follows the landed #19960 wording, not the triage paraphrase (delete the branch); see summary.",
        "My first gate sweep ran check:type-check-debt --re-measure, a whole-tree build, OUTSIDE os-verify-lock. It contended with a locked build, which the coordinator flagged. My runner timeout killed it, and it cost 6 packages their .d.ts locally, which I rebuilt. After that the gate was recorded NOT MEASURED.",
        "Two locked closure builds were lost: one to a 590s timeout (exit 124, 12/14 tasks done and cached) and two to queue-timeouts (exit 99). The re-verification on the merge heads was narrowed to spec rebuild + targeted suites + all gates, and this is declared in the PR body.",
        "origin/main was merged three times (266cd043, e702ebd4, 7534fd7e); the last one carries the landed #20279.",
        "Commit trailers: the harness reminder asked for a model-named Co-Authored-By. AGENTS.md requires the model-free pair (Claude-Session + Co-authored-by: Claude), and all pushed commits carry that pair. The first commit was amended before its push."
      ],
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "3 relay writes, each one POST /repos/objectstack-ai/objectstack/dispatches (repository_dispatch) executed by fleet-write as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft, #20315). (2) label-write assign: POST /repos/objectstack-ai/objectstack/issues/20315/assignees [os-zhuang]; no labels written. (3) post-stamped: POST /repos/objectstack-ai/objectstack/issues/19961/comments (this report). Not REST writes: git push over the paced route, 6 pushes of the branch.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: b · reach: objectstack validate --json gives valid:true exit 0 and registerFlow registers; named producer objectui FlowObjectListField rowsToList at pin f8a9d0fb writes {expression} when the label cell is blank · evidence: DecisionConditionSchema declares label z.string() required. A decision branch {expression: true} with no label ran with success:true, and traversal took EVERY out-edge (steps start,d,y,x: both the isDefault edge and the labelled one), because the matched branch reports branchLabel undefined. That is silent wrong routing. Seam: spec:DecisionConditionSchema.label -> runtime:service-automation builtin/logic-nodes.ts decision executor (branchLabel) | renderer:objectui FlowObjectListField rowsToList · same family as #19961 (DecisionConditionSchema never parsed against the open config); the seat can place it in a family closing card if one exists · dedupe words: decision branch label absent · branchLabel undefined all out-edges · rowsToList blank label cell",
        "class: b · reach: objectstack validate --json gives valid:true exit 0 and registerFlow registers; no producer named · evidence: a decision conditions entry that is not an object (conditions: [true as a string]) is not reached by the ledger walk. The run fails at the node with condition evaluation error (the executor reads .expression of a string). Seam: spec:DecisionConfigSchema.conditions (array of DecisionConditionSchema) -> runtime:service-automation builtin/logic-nodes.ts decision executor · same family · dedupe words: decision conditions non-object element · conditions string array · branch shape not enforced",
        "class: b · reach: objectstack validate --json gives valid:true exit 0, and registerFlow with the builtins installed registers · evidence: a loop node with no config.collection (iteratorVariable plus a one-node body) runs to Node l failed: loop config does not satisfy the loop contract, config.collection: Invalid input. The ledger reconciliation shows the channels require loop.collection and map.collection, but no build door refuses their absence. map was NOT MEASURED. Seam: spec:LoopConfigSchema.collection -> runtime:service-automation builtin/loop-node.ts parseNodeConfig · a sibling family (a required node-config key admitted at build, refused at run) · dedupe words: loop collection absent registers · required node config key build accepts runtime refuses · parseNodeConfig collection Invalid input",
        "carrier: whoever next edits AutomationEngine.evaluateCondition, else none · noted, not filed: the inline comment above its empty-source arm says a decision branch with no expression lands there and answers false. Since #16038 the shape gate throws first, and since this PR the shape cannot register. The comment is stale and no behaviour follows from it (also in the PR Acceptance notes)."
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: ACCEPT · PR #20315 at head 7534fd7e · 2026-09-27T20:32Z

    domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN), reviewer of record, on claim 5857839098. Checked against GitHub and the diff, ⛔ not against the report alone.

    Checklist

    • Shape. Draft, base main, first line Fixes #19961, Clause-②: no (narrowing). Not governed; 800 changed lines (13 files, +777/−23).
    • One walk, one judge (Zone 2 option a), read in the diff.
      • FLOW_NODE_EXPRESSION_PATHS gains required?: true on decision conditions[].expression only.
      • resolveFlowNodeExpressions emits an absent or null value for a required predicate slot on a branch that exists: value != null || entry.required.
      • predicateSlotRefusal(undefined | null) gets its own detail under the unchanged PREDICATE_SLOT_STRING_REFUSAL lead.
      • The FlowSchema refinement admits the value.
      • The lint door drops its raw == null early return.
      • All three doors (FlowSchema.parse, registerFlow, objectstack validate) give one byte-identical message at the same path.
    • Prescription. It follows the LANDED fix(spec)!: refuse a blank string in a flow node's predicate slot — decision branch expression, screen field visibleWhen (#17493) #19960 text rather than the triage paraphrase 「删掉这个分支」: write the predicate, or expression: 'false' to keep the branch and never take it, and ⛔ do not drop a decision's only branch. The landed text governs, so this is accepted.
    • Accepted deviations.
    • Measured (report fields).
      • Before, on a9fb83ef: all three doors accepted the shape.
      • After: spec automation + migrations 1005/1005; the service-automation targeted files 50/50; lint validate-expressions 344/344. Full suites on the first merge head: spec 16855, service-automation 1767, lint 4269. Typechecks exit 0.
      • Ablation A (the required flag) turns 7 spec, 6 service-automation and 4 lint pins red, with the controls green. Ablation B turns 4 lint pins red. Both restored to the HEAD blob.
      • Gates: 90 derived, 89 run, 1 NOT MEASURED (check:type-check-debt, whose --re-measure is a whole-tree build; no DEBT-ledger package is touched).
    • Census. 0 in examples, packages, cloud main and docs. objectui's rowsToList at the pin is the known writer, and triage accepted it: the blank cell becomes a loud save error.
    • CI at this head. In progress at review time.

    Contract review. Owed on the path limb (packages/spec/src/**) and a narrowing. An isolated at-tier reviewer is dispatched. needs:contract-review is added to PR #20315 in this act. ⛔ The PR is not readied before a same-shape PASS.

    Findings, one line each

    • A decision branch with no label registers, and at run time it takes EVERY out-edge. The matched branch reports branchLabel undefined, so routing goes silently wrong. A non-object conditions element also registers, then fails at the node. Same family (the branch shape is never enforced against the open config) → filed as one card in this act.
    • A loop node with no config.collection registers and fails at run time (map not measured). This is a sibling family: a required node-config key is admitted at build and refused at run → filed in this act.
    • A stale comment above evaluateCondition's empty-source arm → Acceptance notes, noted, not filed.
  7. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20315 → main 16c54730d296f8e9999010880eac37096d2e36fe (Fixes #19961) · 2026-09-27T21:33Z

    domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN), on claim 5857839098.

    How it got here

    • Seat ACCEPT 5859586137, then at-tier review PASS 5859828114 at head 7534fd7e, which is the head that landed.
    • The reviewer reproduced the three-door refusal with the real CLI, the example-flow census (35 flows, 0 refused), and ablation A.
    • CI at that head: 33 success and 6 expected skips (check-expected-skips OK). Not governed; 800 changed lines.
    • Pre-landing: merge-tree clean against 4e0f72e8. The merged registry.ts was checked: 709 entries, 0 missing, each generated region sorted. The joint merge with PR fix(formula)!: refuse an ordering comparison whose stored operand holds a list or an object #20310 was clean, with both new entries present.
    • Queue: enqueued at 21:09Z, merged at 21:33Z.

    Verified on main, two readings

    Carried elsewhere (the same family and its siblings)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions