You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
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 main3b5607019f:
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.
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 (FlowObjectListFieldrowsToList) 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).
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.
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.
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.
{
"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)."
]
}
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.
packages/lint/src/validate-expressions.ts (the fix site) and the service-automation / lint test files are outside the claimed surface. Ablation B shows the lint door stays green without that change, and it is named in the PR body.
expression: null and a predicate under condition take the same arm: the same class, the same walk, bounded in place.
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.
Activity
objectstack-fleet commentedon Sep 24, 2026
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:blockedBlocked-by #19960; rationale:DecisionConditionSchemadeclaresexpressionrequired, but nothing parses adecisionnode's open config against it, so a branch with noexpressionpassesFlowSchema.parse,registerFlowandobjectstack validateand then throws in the executor — and objectui's Studio flow editor (FlowObjectListFieldrowsToList) 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)和 objectui62597c5上核对了前提。本席核对
schemaless-node-config.zod.ts里DecisionConditionSchema的expression是z.string(),也就是必填。flow-node-expression-paths.ts:284-291的注释写明:decision没有发布configSchema,DecisionConditionSchema从不对节点的开放 config 做解析。所以这个"必填"没有任何地方在执行。packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx:62就是卡面说的rowsToList。定级说明
expression, screen fieldvisibleWhen(#17493) #19960(spec/automation: three residues of #17322's node-door refusal — a stale on-site ruling, an ADR-0087 entry that names only the edge slot, and two sibling predicate slots still admitting a blank string #17493 裁定 A)在构建期拒绝。本卡是它相邻的"根本没有这个键"的情形。为什么挂
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
objectstack-fleet commentedon Sep 24, 2026
Unblock re-derivation:
pm:blocked→pm:queue· 2026-09-24T23:58ZActing 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 bycheck-half-states.mjsH19 on this sweep.expression, screen fieldvisibleWhen(#17493) #19960 closedcompletedon 2026-09-24 (blank-string predicate refusal).5811370954names exactly that condition (same two files).expression, screen fieldvisibleWhen(#17493) #19960 merged after that comment, but it is the named condition itself. It covers the blank-string case, not this card's absent-expression-key case, and it does not name [finding] Adecisionbranch with noexpressionkey registers and validates clean, althoughDecisionConditionSchemadeclares it required and the executor throws on the source-less envelope #19961. No commit onmaindoes either.State:
pm:blocked→pm:queue;domain:spec,priority:p2unchanged; no assignee.Generated by Claude Code
objectstack-fleet commentedon Sep 27, 2026
Serial note from
domain:specseat 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) andflow.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 adjacentDecisionConfigSchemablock inschemaless-node-config.zod.ts. Whoever takes this card re-reads both claims first. If #19938 has landed, mergemainand build on its ledger shape.objectstack-fleet commentedon Sep 27, 2026
Claim: PM loop round 1 · 2026-09-27T16:51Z
Session:
session_01Rjy9MeetSfq34PKn81CRiNAccount:
os-zhuangBranch:
claude/issue-19961-decision-branch-expression-absentWorktree:
objectstack-issue-19961Domain:
domain:specSeat:
domain:spec#1File surface:
packages/spec/src/automation/flow-node-expression-paths.ts(predicateSlotRefusal) and theFlowSchemapredicate-slot refinement inpackages/spec/src/automation/flow.zod.ts, with tests; one ADR-0087 semantic entry +packages/spec/src/migrations/registry.ts; generated artefacts;.changeset/. ⛔ Notschemaless-node-config.zod.ts'sDecisionConfigSchemaregion or itsmoderefusal helper (seat 4's #20168);DecisionConditionSchemais 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 hitspackages/spec/src/**, so it is reviewed atCONTRACT_REVIEW_TIERbefore 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.fix(spec)!: refuse a decision branch with no expression at the predic…
test(spec,service-automation,lint): pin the absent decision-branch pr…
test(service-automation): re-judge the absent-branch registration pin…
objectstack-fleet commentedon Sep 27, 2026
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
objectstack-fleet commentedon Sep 27, 2026
Review: ACCEPT · PR #20315 at head
7534fd7e· 2026-09-27T20:32Zdomain:specseat 1 (session_01Rjy9MeetSfq34PKn81CRiN), reviewer of record, on claim5857839098. Checked against GitHub and the diff, ⛔ not against the report alone.Checklist
main, first lineFixes #19961,Clause-②: no (narrowing). Not governed; 800 changed lines (13 files, +777/−23).FLOW_NODE_EXPRESSION_PATHSgainsrequired?: trueon decisionconditions[].expressiononly.resolveFlowNodeExpressionsemits an absent ornullvalue for a required predicate slot on a branch that exists:value != null || entry.required.predicateSlotRefusal(undefined | null)gets its own detail under the unchangedPREDICATE_SLOT_STRING_REFUSALlead.FlowSchemarefinement admits the value.raw == nullearly return.FlowSchema.parse,registerFlow,objectstack validate) give one byte-identical message at the same path.expression, screen fieldvisibleWhen(#17493) #19960 text rather than the triage paraphrase 「删掉这个分支」: write the predicate, orexpression: '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.packages/lint/src/validate-expressions.ts(the fix site) and the service-automation / lint test files are outside the claimed surface. Ablation B shows the lint door stays green without that change, and it is named in the PR body.expression: nulland a predicate underconditiontake the same arm: the same class, the same walk, bounded in place.origin/mainwas merged three times; the last merge carries the landed PR fix(spec): refuse decisionmodebeside a non-emptyconditionslist (#20168) #20279 ([Decision] A decision node that declares both a conditions list andmode: refuse it at authoring, give it a meaning, or leave it inert #20168), and the two do not meet.a9fb83ef: all three doors accepted the shape.validate-expressions344/344. Full suites on the first merge head: spec 16855, service-automation 1767, lint 4269. Typechecks exit 0.requiredflag) 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.check:type-check-debt, whose--re-measureis a whole-tree build; no DEBT-ledger package is touched).mainand docs. objectui'srowsToListat the pin is the known writer, and triage accepted it: the blank cell becomes a loud save error.Contract review. Owed on the path limb (
packages/spec/src/**) and a narrowing. An isolated at-tier reviewer is dispatched.needs:contract-reviewis added to PR #20315 in this act. ⛔ The PR is not readied before a same-shape PASS.Findings, one line each
labelregisters, and at run time it takes EVERY out-edge. The matched branch reportsbranchLabelundefined, so routing goes silently wrong. A non-objectconditionselement 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.loopnode with noconfig.collectionregisters and fails at run time (mapnot measured). This is a sibling family: a required node-config key is admitted at build and refused at run → filed in this act.evaluateCondition's empty-source arm → Acceptance notes, noted, not filed.objectstack-fleet commentedon Sep 27, 2026
Landed: PR #20315 →
main16c54730d296f8e9999010880eac37096d2e36fe(Fixes #19961) · 2026-09-27T21:33Zdomain:specseat 1 (session_01Rjy9MeetSfq34PKn81CRiN), on claim5857839098.How it got here
5859586137, then at-tier review PASS5859828114at head7534fd7e, which is the head that landed.check-expected-skipsOK). Not governed; 800 changed lines.4e0f72e8. The mergedregistry.tswas 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.Verified on
main, two readings16c54730's first parent iscc40033e, and no queue branch for fix(spec)!: refuse a decision branch with no expression — absent or null — at all three doors (#19961) #20315 remains.required: trueappears inflow-node-expression-paths.tsat16c54730and not at its parent.Fixes.Carried elsewhere (the same family and its siblings)
labeltakes every out-edge, and a non-object element registers. Serial after this PR, which has now landed.loopwith nocollectionregisters.fix(spec)!: refuse a decision branch with no expression — absent or n…
test(spec): automation test titles state each cited decision in words…