Skip to content

fix(service-automation)!: a whitespace-only config.condition is refused at registerFlow, the rule the edge door already carries (#17322) - #17491

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17322-blank-condition-node-door
Sep 10, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-17322-blank-condition-node-door

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #17322

Clause-②: no

A flow node's config.condition — a decision node's predicate, and on a start node the trigger gate — accepted a whitespace-only string, passed it to AutomationEngine.evaluateCondition, and landed on the empty-source arm (exprStr.trim() === ''), which returns false under a comment that names that arm as being for an unauthored condition. The value was authored. The branch never ran, forever, with nothing said at any layer; on a start node it gated the whole flow shut in silence.

Since #15807 the edge door refuses exactly this value at FlowSchema.parse (FlowEdgeSchema.condition composes EvaluatedExpressionInputSchema). Triage's ruling on this card:

⇒ ⭐ this is the standing criterion's exact shape — 一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑. The edge door refuses; that is the governed side. ⇒ the node door aligns to it.

What moved

One gate, in registerFlow's structural-condition pass — the only producer-side gate config.condition has, its config being an open z.record(z.string(), z.unknown()) with no schema in front of it. It sits after structuralConditionRefusal (a string is still a well-shaped condition) and before the CEL check.

The rule is imported, never re-derived: the condition's source is run through EvaluatedExpressionInputSchema itself, so the two doors cannot drift into two notions of "blank" or two sentences for it — the property the #15662 campaign built the shared refusal for. Both the verdict and the published sentence (EVALUATED_EXPRESSION_SOURCE_REQUIRED) come from spec's own export; nothing is re-spelled here.

Applied to the source, not to the whole value, deliberately: the union would also refuse an envelope with no dialect or with a dialect outside its enum, and this slot admits both (structuralConditionRefusal's own docblock, and #4336). Refusing them would widen the narrowing past what was ruled.

evaluateCondition and structuralConditionRefusal are unchanged. evaluateCondition still answers false for a blank string: it is the shared evaluator and a public method on an exported class, so its throw behaviour is itself a contract, and a stored flow reaches it whatever the producer refuses. This change is at the producer only.

The measurement triage asked for

⚠️ Refusing at the node door narrows the accept set … measure whether any stored automation carries one before landing.

Two probes over this branch's tree, each with its own positive control, because a zero without a control is not a measurement.

probe population blank-after-trim, non-empty POSITIVE CONTROL
B — textual (all tracked sources: .ts .tsx .js .mjs .json .yaml .mdx) 8,123 files 0 in any authored flow (4 hits total, all non-flows: 2 prose examples inside #15807's own changeset, packages/lint/src/lint-flow-patterns.test.ts:1279, packages/lint/src/validate-expressions-nonstring-source.test.ts:118 — the last a hook condition, not a flow node) 461 non-blank condition: string literals found by the same probe
A — structural (parse every JSON, walk it, read every condition value as bare text or as an envelope source) 463 JSON files parsed 0 0 — so probe A is NOT MEASURED as a blank detector

Probe A's zero is reported as unmeasured on purpose: its control is zero, so it proves nothing about its own reach. Its diagnostic reading is separate and is the useful one — only 3 tracked JSON files carry a nodes array at all (docs/qa/platform-checklist/areas/automation.json, packages/spec/liveness/flow.json, scripts/fixtures/i18n-walk-parity/every-group.stack.json) and only 2 carry a condition key, both of them liveness-ledger rows describing the key rather than authoring one. This repository does not author flows as JSON data, so probe B carries the measurement.

Conclusion: no automation checked into this repository carries a blank config.condition. Nothing here becomes a dead branch made loud. For a deployment that does carry one, the consequence is stated in the changeset and is wider than the branch: stored flows canonicalize at registerFlow, and each of the three boot paths in plugin.ts wraps that call in try/catch, logs one warn naming the flow, and continues — so the whole flow stops registering, its trigger is never armed, and the warn line is the only announcement. It is also the locator: the refusal names the node and the slot, e.g. node 'gate' (start) condition.

Reproduction: RED before, GREEN after

packages/services/service-automation/src/structural-condition-shape.test.ts, run before any engine change:

 Test Files  1 failed (1)
      Tests  6 failed | 42 passed (48)

All six failures are the new blank-condition assertions; every control was green. After the fix, the same file:

 Test Files  1 passed (1)
      Tests  48 passed (48)

Two pins in that file were flipped, not deleted — both pinned the ruling this card supersedes, and both are re-judged with the reason recorded in place:

The evaluator half of both pins is kept and still asserts false.

Reverse verification

From the committed state: the gate was removed, the mutation was proved to have reached disk, and the same file was re-run.

HEAD blob      : b1b519a415e92043b29494be0ed27cf66cf03afc
post-mutation  : 821d5dadd852f69e3a1cc02ba5c42c0751b29412
post-mutation grep 'if (blankRefusal)' = 0  (expect 0)
post-mutation grep 'ABLATION-17322'    = 1  (expect 1)
MUTATION REACHED DISK: on-disk hash differs from the HEAD blob
      Tests  7 failed | 41 passed (48)          ← mutated

Direction: turns red, as predicted. Restored from HEAD (never a bare git checkout --, which restores the mutation out of the index) and proved by state, not by an exit code:

post-restore   : b1b519a415e92043b29494be0ed27cf66cf03afc   ← equals the HEAD blob
git diff HEAD is empty: yes
git status --porcelain: []
      Tests  48 passed (48)                     ← restored

The mutation is resolved from src (the test imports ./engine.js relatively, within the package), so no dist sits between the edit and the run — which the red itself demonstrates.

Clause-② limbs, measured

Limb A — widen or narrow? A 20-shape census of registerFlow's accept set for config.condition, run twice on one tree: once at HEAD, once with the new gate ablated by the mechanism above.

  • ACCEPTED → REFUSED: 6 shapes — '', ' ', '\t\n ', { source: ' ' }, { source: '' }, { dialect: 'cel', source: ' ' }.
  • REFUSED → ACCEPTED: 0 shapes.
  • Unchanged: the other 14, including absent / null (still accepted), the brace trap, the cron and template dialects, the ast-only envelope, 42 / true / ['a'] / {} (all still refused by their pre-existing verdicts).

A pure narrowing. ⇒ Clause-②: no — breaking, but not widening.

Limb B — public surface? Export declaration lines moved in packages/services/service-automation/ between the merge base and HEAD: 0 added, 0 removed (git diff MERGE_BASE HEAD -- packages/services/service-automation/ matched against export const|function|class|type|interface|enum|default). The change is entirely inside a closure local to registerFlow; the one new import is a consumption, not a re-export. No symbol is named because there is none.

Verification

Base origin/main e758131b3, merged in via scripts/pm/os-regen-merge.sh; every reading below is from the merged tree at aefbb07b2. Heavy runs went through scripts/pm/os-verify-lock.sh and the verdict quoted is the lock's own VERDICT command-exit line.

  • pnpm --filter @objectstack/service-automation test — 129 files / 1539 tests passed, VERDICT command-exit 0.
  • pnpm --filter @objectstack/service-automation typecheck — VERDICT command-exit 0; check:test-typecheck: OK, 0 files / 0 errors.
  • pnpm lint (eslint . --no-inline-config, the whole repository, not a narrowed run) — VERDICT command-exit 0, 84s.
  • pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' — 72/72 tasks successful.
  • pnpm --filter @objectstack/spec check:generated — all 15 generated artifacts up to date (run because packages/spec moved on main's side of the merge; this branch edits none of it).
  • node scripts/pm/dispatch-gates.mjs derived 61 gate families for this diff. All 61 were run on the merged tree and all 61 exited 0. --ran reconciliation: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded rather than claimed. Two of them (check:dual-build-cjs-loads, check:type-check-debt) exited 3 — PREREQUISITE NOT MET on the first pass and were re-run to a real 0 after the full build; neither zero is rounded up from a 3.
  • node scripts/check-adr-0087-registration.mjs — green on the disposition below.

Acceptance notes

Noted while measuring, not fixed here and not filed by this branch — each is outside this card's package, and packages/spec is this lane's declared red line.

  1. A stale on-site ruling in packages/spec that says the opposite. packages/spec/src/automation/flow-node-expression-paths.ts:424–:427, the docblock of structuralConditionRefusal, still admits "every string, including a whitespace-only one … a whitespace-only condition meaning false is consistent on both sides and is ruled correct, not a defect". Its stated ground was falsified by spec/automation: FlowEdgeSchema.condition still accepts an envelope the engine cannot evaluate (ast-only, whitespace-only source) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 and superseded by this card's triage ruling. domain:spec.
  2. objectstack validate and registerFlow now disagree. packages/lint/src/validate-expressions.ts:1209 (checkStructuralCondition) applies only structuralConditionRefusal, so a blank config.condition that this PR refuses at registration is still reported as nothing by the linter — measured, and packages/lint/src/lint-flow-patterns.test.ts:1279 pins that silence with toHaveLength(0). The third door needs the same rebinding.
  3. The ADR-0087 D3 entry needs widening, not adding. flow-edge-condition-evaluated-slot-source-required registers the decision this change is a second face of, but its surface and acceptanceCriteria name only edges[].condition; a consumer replaying the chain is not told to sweep the node key. The changeset's disposition says so in writing rather than claiming coverage it does not have. domain:spec.
  4. The ledger predicate slot is the same defect one slot over. A whitespace-only string at config.conditions[].expression (a decision node's branch list) and at screen.fields[].visibleWhen still registers clean and still lands on the same silent-false arm — packages/services/service-automation/src/decision-predicate-envelope.test.ts:113–:117 pins it, on 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's ruling, whose ground is the same 「consistent on both sides」 that spec/automation: FlowEdgeSchema.condition still accepts an envelope the engine cannot evaluate (ast-only, whitespace-only source) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 removed. Whether that slot follows this one is a ruling, not a refactor, so it is reported rather than taken.

Generated by Claude Code

…used at `registerFlow` (#17322)

A flow node's `config.condition` — a `decision` node's predicate, and on a
`start` node the trigger gate — accepted a whitespace-only string, passed it to
`AutomationEngine.evaluateCondition`, and landed on the empty-source arm
(`exprStr.trim() === ''`), which returns `false` under a comment naming that arm
as being for an UNAUTHORED condition. The value was authored: the branch never
ran, forever, with nothing said at any layer.

Since #15807 the EDGE door refuses exactly this value at `FlowSchema.parse`
(`FlowEdgeSchema.condition` composes `EvaluatedExpressionInputSchema`). One
operation, two implementations, inconsistent behaviour: the governed side wins
and the other rebinds. A node's `config` is an open `z.record`, so `registerFlow`'s
structural pass is the only producer-side gate it has, and the rule is applied
there — by importing `EvaluatedExpressionInputSchema` and running the condition's
source through it, so the two doors cannot drift into two notions of "blank" or
two sentences for it.

Applied to the SOURCE rather than to the whole value: the union would also refuse
an envelope with no `dialect` or with a dialect outside its enum, both of which
this slot admits, and refusing them would widen the narrowing past what was ruled.
`evaluateCondition` and `structuralConditionRefusal` are unchanged.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 6 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 113050e2c2bc92502895a5a174cdd1e8aef0f4ae → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 80006201bc5ac3d06405d21b832dddb16fdeba6c — the merge of head aefbb07b26bc37abbf3147f71870e364b0092721 into base 113050e2c2bc92502895a5a174cdd1e8aef0f4ae, 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 80006201bc5ac3d06405d21b832dddb16fdeba6c && git checkout 80006201bc5ac3d06405d21b832dddb16fdeba6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 113050e2c2bc92502895a5a174cdd1e8aef0f4ae aefbb07b26bc37abbf3147f71870e364b0092721 && git checkout -B drift-repro 113050e2c2bc92502895a5a174cdd1e8aef0f4ae && git merge --no-ff aefbb07b26bc37abbf3147f71870e364b0092721

node scripts/docs-audit/affected-docs.mjs --json 113050e2c2bc92502895a5a174cdd1e8aef0f4ae

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

Copy link
Copy Markdown
Collaborator

ACCEPT — 2026-09-10T17:47Z, head aefbb07b26bc37abbf3147f71870e364b0092721, base main @ 113050e2c, mergeable_state: clean.

Checks on this head, taken from the check-run list and not from an aggregate roll-up: raw: 33 | distinct: 33 | {success: 30, skipped: 3} — NOT GREEN: none. Lint & Repo Gates: completed / success. The three skips are the repository's own conditional families, not suppressions introduced here.

Clause-② carriers, node scripts/pm/check-clause2-carriers.mjs --pair 17491 ⇒ EXIT 0: the PR body carries a line-initial Clause-②: no, and the claim pair on #17322 carries a comment whose first line begins Claim:. (Both halves of that pair were repaired this shift — the defect was mine, in four claim comments that used a bold **Claim:**, not the dev's.)

What I checked beyond green:

The four acceptance notes are correctly reported rather than taken: notes 1 and 3 land in packages/spec (this lane's declared red line, filed as #17493 to the domain:spec seat), note 2 is the linter's third door (filed as #17495), and note 4 is a ruling on a sibling slot, not a refactor.

Flipped ready for review and armed for the merge queue. Not approved and not merged by this seat.


Generated by Claude Code

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A whitespace-only config.condition string is a silent false at the node door, while #15807 made the edge door refuse the same value at parse

2 participants