Skip to content

fix(spec)!: refuse a decision branch with no expression — absent or null — at all three doors (#19961) - #20315

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19961-decision-branch-expression-absent
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19961-decision-branch-expression-absent

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19961

Clause-②: no (narrowing)

A decision branch with no expression (the key absent, or expression: null) is now refused at all three doors: FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate. It goes through the same walk, the same function and the same lead sentence that already refuse a blank branch predicate (#17493 / PR #19960).

What was wrong, measured on origin/main a9fb83ef

DecisionConditionSchema declares a branch { label, expression } with expression a required z.string(). Nothing parses a decision node's open config against that schema. The expression ledger's resolver also skipped an absent value as "not authored". So the build accepted a branch that the run refuses.

branch FlowSchema.parse registerFlow objectstack validate --json
{ label: 'y' } (the card's shape) accepted registered valid: true, exit 0
{ label: 'y', expression: null } accepted registered valid: true, exit 0
{ label: 'y', condition: 'true' } (the edge's spelling) accepted registered valid: true, exit 0
{ label: 'y', expression: ' ' } (control, #19960) refused, custom at nodes.1.config.conditions.0.expression refused, same issue valid: false, exit 1, same path
{ label: 'y', expression: 'true' } (control) accepted registered valid: true, exit 0

What the run did with it: evaluateCondition({ dialect: 'cel', source: undefined }) and source: null both throw condition evaluation error: A structural condition …. On the real decision executor, a run that reaches such a branch ends success: false at the branch (pinned below).

The fix: one walk, one judge

  • packages/spec/src/automation/flow-node-expression-paths.ts
    • FlowNodeExpressionPath gains required?: true. It is set on decision conditions[].expression and on nothing else.
    • For a required predicate slot, resolveFlowNodeExpressions now emits the absent or null value on a branch that exists. It still skips a decision with no conditions, an empty list, and an absent screen visibleWhen.
    • predicateSlotRefusal(undefined | null) now has its own detail sentence and prescription, under the unchanged PREDICATE_SLOT_STRING_REFUSAL lead.
  • packages/spec/src/automation/flow.zod.ts: the FlowSchema predicate-slot refinement now admits the absent or null value of a required slot, next to strings. Every other non-string keeps its service-automation: a decision condition accepts a CEL envelope that neither validator can see — a malformed one evaluates to false SILENTLY at run time and takes the wrong branch #15572 scope, so it is still not refused at this door.
  • packages/lint/src/validate-expressions.ts: checkDeclaredPredicate dropped its raw == null early return. Whether an absent value is a finding is the resolver's call. The early return answered "valid" for the exact value FlowSchema.parse refuses, for any caller of validateStackExpressions that does not parse first. This site is outside the claim's file surface. The measurement put the third door's refusal there (ablation B below).
  • engine.ts: no change. Its ledger pass already calls predicateSlotRefusal on everything the resolver emits, and registerFlow parses first, so the parse answers first. That is the same two-layer shape the blank has.

The route choice (Zone 2 item 3). I chose (a), the predicate-slot walk treating an absent expression as a refused slot. I did not choose (b), parsing each branch against DecisionConditionSchema. Reasons, per axis:

  • Business need, measured: the only named producer is objectui's rowsToList, which writes { label }. The absent key is the whole defect.
  • Long-term design: (a) keeps one judge (predicateSlotRefusal) and one walk for the three doors. (b) would be a second judge with Zod's own messages, a different prescription at each door, and a key-set closure on branches that nobody ruled.
  • Guarding AI authors: both refuse loudly. (a) also names the condition alias mistake in the same prescription.
  • No scope growth: (a) touches one ledger entry. (b) would narrow a much wider accept set, including unknown branch keys and the whole branch shape.

DecisionConditionSchema and the fenced DecisionConfigSchema / mode region are untouched. #20168's PR #20279 landed while this was in flight, and this branch is merged over it (7534fd7e). Its refusal lives in DecisionConfigSchema, which no door parses a node's config against, so it and this walk do not meet. Its suite is green here (in the src/automation run below).

Prescription wording. Triage (5811370954) says PR #19960's decision-branch prescription is "删掉这个分支" (delete the branch). The landed #19960 text says something else: write the predicate, or expression: 'false' to keep what the blank ran, and ⚠️ not by dropping a decision's only branch. That clause is pinned by predicate-slot-blank.test.ts. This PR follows the landed wording. It drops the "keep what ran" half, because an absent predicate never ran: it failed the run at the branch. So 'false' is offered as "keep the branch and its label, never take it", and nothing is claimed to be preserved.

The refusal text, quoted (predicateSlotRefusal(undefined), byte for byte what all three doors print)

A predicate slot holds BARE CEL TEXT that states a rule — it is declared `z.string()` — so an expression envelope, any other non-string, or a string that is blank after trimming is not authorable there. Found nothing — the key is absent where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. `record.rating >= 4`); a predicate written under another key — `condition` is the edge's spelling — belongs in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no `expression` failed the run there. To keep the branch and its label but never take it, write `expression: 'false'`. Not by dropping a decision's only branch: the node then routes by its out-edges alone, and the out-edge that branch labelled is no longer held back.

For null, Found nothing — the key is absent reads Found followed by the code-spelled null. The lead sentence (PREDICATE_SLOT_STRING_REFUSAL) is unchanged, byte for byte.

After, measured on e702ebd4 (the real CLI door, spec rebuilt; no file of this diff changed after that). { label: 'y' }, expression: null and condition: 'true' all give objectstack validate --json valid: false, exit 1, one custom error at flows.0.nodes.1.config.conditions.0.expression. The absent and alias messages are byte-identical. registerFlow refuses the same three with a custom issue at nodes.1.config.conditions.0.expression. expression: 'true' still validates and registers. The blank keeps its own message.

Pins: one table per door, the same five rows

Every refused row asserts the issue code, the path, and the full message equal to the spec's own predicateSlotRefusal(value).message.

  • packages/spec/src/automation/flow-decision-branch-expression-absent.test.ts: FlowSchema.parse.
    • The five rows: absent, null, condition alias, blank control, real accept control.
    • Branch index 1 is anchored. The ADR-0031 region body is anchored.
    • Controls: a decision with no conditions or [] still parses; an absent screen visibleWhen still parses.
  • packages/services/service-automation/src/decision-branch-expression-absent.test.ts: registerFlow.
    • The same table. getFlow is null after each refusal.
    • Region body.
    • On the real decision executor: the absent branch failed the run (success: false, condition evaluation error, ran ['start']); expression: 'false' routes to the fallback.
  • packages/lint/src/validate-expressions.test.ts describe('a decision branch with no expression (#19961)'): validateStackExpressions, with the same table, the exact where string, branch index 1, and controls.
  • packages/spec/src/automation/flow-node-expression-paths.test.ts:
    • The resolver emits undefined or null for the decision slot and skips everything else.
    • predicateSlotRefusal(undefined | null) prescription clauses are pinned by name.
    • The required set is pinned to exactly decision.conditions[].expression (predicate), because the absent arm's wording is decision-specific.
  • packages/services/service-automation/src/builtin/config-expression-ledger.test.ts: the reconciliation ratchet now reads each channel's JSON-Schema required list. It asserts that the ledger's required flags equal the channel's, in both directions, over the predicate role. It derives, not assumes, that visibleWhen is optional. It asserts that required is never set on another role. The channels do require loop.collection / map.collection, but no door refuses their absence (reported to the seat as an out-of-scope finding).

Pin sweep. One published pin flipped: decision-predicate-envelope.test.ts asserted decisionFlow('str_absent', undefined) registers. It was re-judged in place, and the reason is written beside it. It now asserts the throw carries PREDICATE_SLOT_STRING_REFUSAL and Found nothing — the key is absent where the slot is required.

Repo sweep for other branches without an expression: a bracket-balanced scan of every .ts / .json / .yaml file that mentions both decision and conditions found only this PR's own fixtures. A grep of helper-built branches ({ label: …, expression } shorthand) found 4 sites, all in suites run below. No other package's test builds a decision with conditions.

Ablation: the pins can fail

Both ablations were run on committed state through scripts/ablation-replace.mjs, which wraps the change, verifies it on disk and restores it with a trap. Both proved restore by blob hash equal to HEAD and an empty git diff HEAD.

  • A: the required flag neutralised. required: true, was replaced by a spread that is {} unless a globalThis flag named ABLATION_19961 is set.
    • ablation-dist-preflight.mjs @objectstack/spec ABLATION_19961 found the marker present in 20 built files.
    • Red, in the expected direction:
      • spec: 7 failed (the absent, null and alias rows, index 1, region, the required-set pin, the resolver pin);
      • service-automation: 6 failed (the three rows, region, the re-judged envelope pin, the ratchet);
      • lint: 4 failed.
    • The blank and real controls stayed green at every door.
    • Restore leg: rebuild, --absent marker gone from all 222 built files, whole-tree git status clean. spec 52/52, service-automation 34/34 and lint 344/344 green.
    • The first attempt was a no-op and its reading was discarded: my replacement was not valid TypeScript, so the transform failed and the build never ran.
  • B: the lint early return put back (if (raw == null) return { refused: false };): lint showed 4 failed (absent, null, alias, index 1), and the blank and real rows stayed green. Restored by blob hash. The first attempt was refused by the tool before running anything, because the anchor matched its own replacement.

Producer census (Zone 2 item 4): authored count 0

  • examples/** at e702ebd4: 3 flows carry decision nodes (app-crm convert-lead, app-showcase needs_exec / triage, app-todo check_recurring). All of them branch on out-edges and declare no conditions, so 0 branches lack an expression.
  • packages/** non-test: no default flow carries a decision node. The content/docs/automation/flows.mdx examples: 3 conditions lists, all with expression.
  • cloud origin/main 96eb092f: 0 decision nodes. service-ai-studio's authoring whitelist names decision as an authorable node type, so AI-authored flows now meet this refusal.
  • objectui at the pin f8a9d0fb (.objectui-sha): FlowObjectListField rowsToList still drops a blank cell, so a branch row with an empty expression cell is written as { label }. That is the known writer. Triage accepted that its save now fails loudly, so it is not fixed here.

ADR-0087 and changeset

  • New semantic entry flow-decision-branch-expression-absent-refused (major 18) and a regenerated registry.ts.
  • There is no D2 conversion: the platform cannot know the rule the author left out, and 'false' would change behaviour rather than keep it.
  • The changeset .changeset/19961-decision-branch-expression-absent-refused.md: @objectstack/spec and @objectstack/lint minor, BREAKING, with the FROM → TO table.
  • service-automation gets no changeset: its diff is test files only, and those are not in files[].

Verification (final head 7534fd7e, which is origin/main 6a6a17b6 merged, #20279 included)

  • Tests (os-verify-lock, spec rebuilt on this head):
  • Full suites, run on the first merge head 266cd043: spec 16855 passed (583 files), service-automation 1767/1767, lint 4269/4269.
  • Typecheck, including the test layers, on 266cd043: spec, lint and service-automation all exit 0. No file of this diff changed after that.
  • Gates: dispatch-gates.mjs --commands re-derived on 7534fd7e gives 90 families. 89 ran with exit 0. dispatch-gates --ran answers "90 derived famil(ies) accounted for — 89 run, 1 NOT-MEASURED".
    • NOT MEASURED: check:type-check-debt. Its --re-measure runs a whole-tree turbo run build --filter=./packages/* outside os-verify-lock. This diff touches no DEBT-ledger package.
    • On earlier heads, two gates needed their prerequisites built first, and both then exited 0. check:dual-build-cjs-loads answered PREREQUISITE NOT MET because 12 unrelated packages were unbuilt. check:dts-closure went red on local state: 6 packages lost their .d.ts to my own interrupted --re-measure build. That is not this diff.
  • 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: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], and no file here is ignored.
    • Invariance: the config never enables type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.
  • Declared narrowing: after the second and third origin/main merges, I re-ran the targeted suites above and every gate, not the full package suites. The incoming commits touch other surfaces (rls, date comparands, report charts, cli generate, pm scripts, and fix(spec): refuse decision mode beside a non-empty conditions list (#20168) #20279's DecisionConfigSchema mode), not the flow predicate walk.

Acceptance notes (observed, not filed)


Generated by Claude Code

…ate-slot walk (#19961)

The expression ledger marks decision.conditions[].expression required, so
resolveFlowNodeExpressions emits an absent or null value there and every
door refuses it through predicateSlotRefusal, under the blank's lead
sentence. FlowSchema.parse admits the absent value of a required slot into
its refinement; the lint pass drops its null early return so the resolver
decides what is a finding.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…edicate at all three doors; ADR-0087 entry; changeset (#19961)

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…; reconcile required over the predicate role (#19961)

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 8 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/concepts/metadata-lifecycle.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/data-modeling/drivers.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/kernel/services-checklist.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function), sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const), sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in semantic; a string literal in surface))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7b1e4a48714ded619ca58c4645a398f284c30dd3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 145a1c2726afdb34baf6ac336b676ae944843056 — the merge of head 7534fd7ea1aa0c3f49347eff689227575dc0d1a8 into base 7b1e4a48714ded619ca58c4645a398f284c30dd3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 145a1c2726afdb34baf6ac336b676ae944843056 && git checkout 145a1c2726afdb34baf6ac336b676ae944843056
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b1e4a48714ded619ca58c4645a398f284c30dd3 7534fd7ea1aa0c3f49347eff689227575dc0d1a8 && git checkout -B drift-repro 7b1e4a48714ded619ca58c4645a398f284c30dd3 && git merge --no-ff 7534fd7ea1aa0c3f49347eff689227575dc0d1a8

node scripts/docs-audit/affected-docs.mjs --json 7b1e4a48714ded619ca58c4645a398f284c30dd3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7b1e4a48714ded619ca58c4645a398f284c30dd3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7534fd7ea1aa0c3f49347eff689227575dc0d1a8
Local-runs: probe — the dispatching seat's order required measured evidence, so the read-only shape was broken in one detached worktree at the head (scratchpad pr-20315/wt-head, pnpm install --frozen-lockfile, one turbo run build of the cli and service-automation closures through os-verify-lock); in it the three doors, the real CLI, the example-flow census, the spec automation+migrations, service-automation and lint suites, the light registry/changeset gates and one ablation-A slice were run. Nothing outside that worktree was touched; the worktree is removed at the end.

① Derived judgments

  1. One judge, three doors, one answer — RIGHT. Measured on the built head with a six-row table (absent { label: 'y' }, expression: null, the predicate under condition, blank ' ', 'true', record.rating ≥ 4): the first three are refused at FlowSchema.parse (one custom issue at nodes.1.config.conditions.0.expression), at AutomationEngine.registerFlow (the same one issue, thrown by the parse inside canonicalizeStoredFlow; getFlow is null afterwards) and at validateStackExpressions (one error located at … decision branch expression at config.conditions[0].expression), and the message at every door is byte-identical to predicateSlotRefusal(value).message, leading with the unchanged PREDICATE_SLOT_STRING_REFUSAL. The real CLI (packages/cli/bin/run.js validate --json, dist built) on fixture configs: absent, null, alias and blank each give valid: false, exit 1, one custom error at flows.0.nodes.1.config.conditions.0.expression, full message byte-identical to the spec function; 'true' gives valid: true, exit 0. Controls: the blank is refused with the landed fix(spec)!: refuse a blank string in a flow node's predicate slot — decision branch expression, screen field visibleWhen (#17493) #19960 sentence (Found a string that is blank after trimming …, a different message from the absent arm); 'true' and record.rating ≥ 4 are accepted at all three doors. Absent and alias produce a byte-identical message; null differs only in the Found clause. No second walk: the diff adds no traversal — the parse refinement in flow.zod.ts, the engine's ledger pass (engine.ts:9496/9539, unchanged) and the lint pass (validate-expressions.ts:1623/1638) already iterate resolveFlowNodeExpressions and hand its emissions to predicateSlotRefusal; the whole change to the walk is value != null || entry.required on one line plus the parse refinement admitting required && value == null. The resolver emits undefined for an object element lacking the key and null for expression: null, and skips a non-object element and a decision with no conditions / [].

  2. No over-refusal — RIGHT. Census (my own count, examples/** at 7534fd7e, through a tsx probe that imports every flow module): 7 flow files, 35 flows, 5 decision nodes (app-showcase needs_exec, triage, any_found; app-crm check_converted; app-todo check_recurring), 0 conditions lists, 0 branches lacking expression; all 35 flows parse, register and lint clean on the head. Real CLI validate --json on the example apps: app-crm valid, exit 0 (9 warnings); app-todo valid, exit 0 (7 warnings); app-showcase's config could not be loaded in my worktree (ERR_MODULE_NOT_FOUND on @objectstack/connector-mcp, outside my build closure — a load failure, not a validation verdict), so its flows were judged through the module census above. packages/** non-test: no default flow carries a decision node (the hits are docblocks and the executor descriptor). content/docs/automation/flows.mdx: 3 conditions lists, every branch with expression. cloud origin/main 96eb092f: 0 decision nodes over ts/json/yaml; the three service-ai-studio files that mention both words carry node-type vocabulary, rollup and trigger conditions, not branches. objectui at the pin f8a9d0fb: FlowObjectListField.tsx:81 rowsToList drops a whitespace-only cell, so an empty expression cell commits { label } — the known writer, accepted by triage. required applies only to the decision branch: the ledger's required set is exactly decision.conditions[].expression (predicate); the schemaless channel's decision.conditions.items.required is ["label","expression"] with the xExpression marker, the screen fields channel has no required, and the descriptor channels require loop.collection / map.collection (template role, not flagged). Probed optional slots with an absent or null value, all accepted at parse, registerFlow and lint: an edge with no condition and a node with no config.condition, a screen field with visibleWhen absent, visibleWhen: null, a decision with config: {}, with no config, and with conditions: []. An edge condition: null is refused by Zod invalid_union at parse (pre-existing: EvaluatedExpressionInputSchema.optional()), with no PREDICATE_SLOT_STRING_REFUSAL at any door and lint clean. The lint change is bounded: checkDeclaredPredicate has one call site (validate-expressions.ts:1638), fed only by the resolver's found.value, so the removed raw == null early return is reachable only for a required slot. Full lint suite on the head: 111 files, 4304/4304 passed, exit 0.

  3. Prescription text — RIGHT. The lead sentence is byte-identical to main's PREDICATE_SLOT_STRING_REFUSAL (7b1e4a48, flow-node-expression-paths.ts:413-415). The absent arm carries the landed fix(spec)!: refuse a blank string in a flow node's predicate slot — decision branch expression, screen field visibleWhen (#17493) #19960 clauses — Write the predicate the branch … was meant to test (e.g. \record.rating ≥ 4`), write `expression: 'false'`, and, word for word, Not by dropping a decision's only branch: the node then routes by its out-edges alone, and the out-edge that branch labelled is no longer held back — and drops only the "keep what the blank did / the value the blank evaluated to" half, which is true to drop: the service-automation pin measures that the absent branch failed the run (success: false, condition evaluation error, ran ['start']) and that 'false'routes to the fallback (both in the passing suite). Triage's paraphrase 「删掉这个分支」 is not the landed #19960 wording, which warns against dropping the only branch; following the landed text is correct and the seat ACCEPT says the same. Beyond what the #19960 sentence names, the text names only the keycondition(the edge's spelling) and the shape{ label, expression }— key names, not values; the attributedsourceis the empty string, so nothing the author wrote is echoed; fornullit prints the literalnull, which main's non-string arm already printed. check:doc-authoring` (self-test and run) exits 0: no tracker number in any runtime string.

  4. The ledger ratchet — RIGHT. collectExpressionProps now reads required off the same schema node that carries the marker (a map value is fixed false), threads it through both channels, and adds one assertion in both directions over the predicate role plus a pin that required is never set on another role, with the derivations asserted non-vacuously (['decision.conditions[].expression (predicate)'] required; ['screen.fields[].visibleWhen (predicate)'] optional). The existing missing/stale reconciliation assertions are untouched in the diff, so the ratchet is strengthened, not weakened. The channel readings above are mine; the service-automation suite on the head (147 files, 1767/1767, exit 0) includes config-expression-ledger.test.ts.

  5. Semver and ADR-0087 — RIGHT, with one enqueue note. The changeset grades @objectstack/spec and @objectstack/lint minor, carries Clause-②: no (narrowing), the BREAKING banner, adr-0087: registered flow-decision-branch-expression-absent-refused, the FROM → TO table and the one-line fix; service-automation's diff is tests only and its files is ["dist","README.md","CHANGELOG.md"], so it owes none. check-adr-0087-registration --self-test (441 assertions) and --base 7b1e4a48 --head HEAD: exit 0, one declared-breaking changeset carrying its disposition, "new here". check-changeset-no-major --self-test (339) and the run: exit 0, no major; its level axis is not applicable locally (no PR payload) and CI's Check Changeset is success at the head. Registry: gen:migration-registry rewrote it byte-identical (blob fded17ed before and after), check:migration-registry reports current (269 semantic / 215 retired-key / 199 retired-def), check:spec-changes and check:upgrade-guide up to date. PR fix(spec): refuse decision mode beside a non-empty conditions list (#20168) #20279 (733822cf) is an ancestor of the head and schemaless-node-config.zod.ts is byte-identical across the merge-base 6a6a17b6, the head and current main, so the fenced region and fix(spec): refuse decision mode beside a non-empty conditions list (#20168) #20279 are intact. The semantic entry's citations check out: the three boot-path warn spellings exist (plugin.ts:995, :2015, :2061), StackSchemaInvalidError exists (stack.zod.ts:2301), and its quoted lint where phrasing and CLI path match what I measured. Note, not blocking: current origin/main 7b1e4a48 is three commits past the head's last merge, and one of them (fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255) added 25 D3 entries and regenerated registry.ts, which the head therefore lacks; the registry is NOT_DRIVER_MANAGED and the queue text-merges it, and check:migration-registry in the required typecheck job catches a wrong merge — merge origin/main once more and run pnpm --filter @objectstack/spec build && check:generated before enqueue (Multi-agent discipline §10).

  6. Pins and ablation — RIGHT. Ablation A, reproduced on committed state inside one os-verify-lock hold (81s): the one line required: true, was deleted from packages/spec/src/automation/flow-node-expression-paths.ts (HEAD blob 44394d22 → ablated blob 9453e55f, 1 deletion), spec rebuilt (exit 0). The doors went red in the expected direction: absent, null and alias were ACCEPTED at parse, registerFlow and lint (ok=true/true/true, zero issues), while the blank control stayed refused at all three and 'true' / record.rating ≥ 4 stayed accepted. The pins went red in the same direction and count: spec 7 failed / 45 passed (52) across flow-decision-branch-expression-absent.test.ts (the three rows, index 1, the region body) and flow-node-expression-paths.test.ts (the required-set pin, the resolver pin); service-automation 6 failed / 28 passed (34) — the three rows, the region body, the re-judged decision-predicate-envelope pin and the ledger ratchet; lint 4 failed / 3 passed with the 19961 name filter (the three rows and index 1). Restore by git checkout HEAD -- packages/spec/src/automation/flow-node-expression-paths.ts (index and tree together): restored blob 44394d22 equals the HEAD blob, git status --porcelain on the file is empty, the flag line is back (1 occurrence), spec rebuilt (exit 0), the door probe is green again (ALL CHECKS PASSED), the two spec pin files are 52/52, and the whole-tree status shows only my untracked probe directory. The dev's ablation-A counts (7 / 6 / 4) are reproduced exactly; ablation B (the lint early return) was not re-run here — the single call site established in ①2 and this ablation's lint reading (the lint door accepts the value once the resolver stops emitting it) together show the lint door refuses through the same judge and nothing else.

② Semver level

minor on @objectstack/spec and @objectstack/lint with the BREAKING banner and the ADR-0087 disposition, under the launch-window convention that check-changeset-no-major enforces; Clause-②: no (narrowing) matches the diff, which narrows one authored slot's accept set and adds no key to any published payload. FlowNodeExpressionPath.required?: true is an optional type widening on a public interface, covered by the green TypeScript Type Check (api-surface) at the head. The level and the declaration are consistent with what the diff publishes.

③ Boundary flags

  • Local-runs deviation: declared above on the dispatching seat's order; every local reading in this record names the worktree and the head it was taken against.
  • Dev flag, fix site outside the claimed surface (packages/lint/src/validate-expressions.ts): accepted — one call site, resolver-fed, bounded to required slots; the lint suite and my null probe show no new refusal on an optional slot.
  • Dev flag, expression: null and the condition alias taking the same arm: accepted — the same class through the same walk, measured identical at all three doors and byte-identical between absent and alias.
  • Dev flag, the prescription 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 over triage's paraphrase: judged RIGHT in ①3.
  • Dev flag, check:type-check-debt NOT MEASURED locally: CI Type Check · debt ledger is success at the head.
  • Dev open_questions: none. Out-of-scope findings (absent label, non-object conditions element, absent loop.collection) are the seat's cards, not this PR's.
  • CI at the head: 39 check runs, 33 success, 6 skipped, 0 failure; all seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core ×6, Dogfood Regression Gate ×3, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) are success.
  • Shape: draft, base main, head repo = base repo, Fixes #19961, 13 files, +777/−23 (under 5,000), no governed path, label needs:contract-review present; commits carry the model-free trailer pair; no model identifier in the PR body, diff or changeset.
  • Enqueue note: merge origin/main (7b1e4a48) and regenerate spec artifacts before arming (①5).

Implemented-by: claude/issue-19961-decision-branch-expression-absent
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 21:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 16c5473 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19961-decision-branch-expression-absent branch September 27, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants