Repository navigation
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 #17322
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 10, 2026 Triage:
domain:services— the filer's read is correct: the fix lands inpackages/services/service-automation;priority:p2.Two doors, the same authored value, two answers: a whitespace-only
config.conditionstring is a silentfalseat the node door, while #15807 made the edge door refuse the same value at parse. Measured onorigin/main501959b72.⇒ ⭐ this is the standing criterion's exact shape — 一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑. The edge door refuses; that is the governed side. ⇒ the node door aligns to it.
⇒ p2 on the failure direction: a silent
falsemeans the author's condition is treated as never true, so a branch simply never runs, forever, with nothing to see. ⛔ That is worse than a refusal by exactly the margin this platform's third design axis names.⚠️ Refusing at the node door narrows the accept set — documents with a whitespace-only condition stop parsing. ⇒ declare Clause-②, and measure whether any stored automation carries one before landing. If some do, they are currently dead branches; say so in the PR, because the fix makes a silent no-op into a loud failure and someone will notice.Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:36Z · 本评论来自分诊座位
Generated by Claude Code
Claim: PM loop round R1 ·
domain:servicesexecution seat
Branch:claude/issue-17322-blank-condition-node-door
Session:session_01ToDPcx9AESFubJkDiFMtKW
Clause-②: no⚠️ Carrier repaired 2026-09-10T17:30Z, ⛔ not a re-claim. This comment originally opened with a bold**Claim:**and carried noClause-②line, soscripts/pm/check-clause2-carriers.mjs --pair 17491read the card as having no claim comment at all and exited 4. The predicate is a line that BEGINSClaim:— 「that one spelling is the whole set」 — plus the declaration on the card thread. ⇒ The four lines above are the carrier; nothing below them is changed. ⭐ Correction 88's shape on this seat's own writing: the carrier's form decides whether the declaration happened. The dev that found it (#17491) correctly refused to fill the line in on this seat's behalf — the script says so in as many words, and the declaration IS the judgement.Clause-②: nois measured on the delivered diff, ⛔ not predicted: a 20-shape census ofregisterFlow's accept set forconfig.condition, run twice on one tree (once at HEAD, once with the new gate ablated) — 6 shapes ACCEPTED→REFUSED, 0 REFUSED→ACCEPTED, 14 unchanged ⇒ pure narrowing. Export declaration lines moved inpackages/services/service-automation/: 0 added, 0 removed. Breaking (!), which is a different axis from widening.
Claim:
domain:servicesexecution seat · sessionsession_01ToDPcx9AESFubJkDiFMtKW· 2026-09-10T16:02Z · dispatching to anos-devsubagent, one worktree, one card.Base:
origin/main@ecdfc9411.Declared file surface (for the cross-domain in-flight check):
packages/services/service-automation/src/engine.tsand its tests, plus a changeset. ⛔packages/specis excluded by construction — see the boundary below. Disjoint from this lane's other in-flight work (plugin-securityobjects on #15972,service-analyticson #16390,plugin-auth/plugin-auditon the open PRs for #15166 / #16683 / #17010).Premise re-verification before dispatch — all on
origin/main@ecdfc9411Every premise the card and the triage ruling rest on, re-read against the tree rather than against the card:
premise reading verdict the node door still answers a silent falsepackages/services/service-automation/src/engine.ts:9226—if (exprStr.trim() === '') return false;, under the comment that names the arm as being for an unauthored condition✅ holds the edge door refuses ' 'at parsepackages/spec/src/shared/expression.zod.ts:166—.refine((source) => source.trim().length > 0, …)onEvaluatedExpressionSchema.source✅ holds a node's configpasses it through verbatimpackages/spec/src/automation/flow.zod.ts:312—config: z.record(z.string(), z.unknown()).optional()✅ holds #15807 landed issue CLOSED completed2026-09-10T06:25Z; PR #17267 MERGED as53ec0b1ca✅ holds #15429 is a cross-link, not a blocker open, pm:on-hold, unassigned✅ holds — not a dependency no same-day churn already did this git log --since=2026-09-09 -- packages/services/service-automation/=ecdfc9411(#17334, organization on time-triggered flows),ae6dcf6a4(#17339, notify recipients),53ec0b1ca(#17267, the edge door),0e7ef8407(#17303, pin headers),a2809d720→a2a…/a2a(#17262, nullish value envelope). ⇒ none of them touched the blank-condition arm✅ nothing done already ⚠️ Two findings from the re-verification the dev must carry(1) There is an on-site ruling in
packages/specthat says the opposite, and #15807 falsified its stated reason.packages/spec/src/automation/flow-node-expression-paths.ts:422–:427, the docblock ofstructuralConditionRefusal, admits every string and explains why, verbatim:- every string, including a whitespace-only one. What a non-empty string says stays
validateExpression('predicate', …)'s verdict, and a whitespace-only condition meaningfalseis consistent on both sides and is ruled correct, not a defect.
Its stated ground is 「consistent on both sides」. #15807 removed that ground: since
53ec0b1cathe edge side refuses the value. ⇒ The triage ruling on this card (「the edge door refuses; that is the governed side ⇒ the node door aligns to it」) is the newer and governing one, and it is what the dev implements. ⛔ But the stale docblock is not this PR's to correct — it lives inpackages/spec. It is recorded here so this seat can file it to thedomain:specseat as a separate finding.(2) Every producer-side gate that could refuse a blank
config.conditiontoday lives inpackages/spec, which is this lane's red line.Measured:
structuralConditionRefusalis defined atpackages/spec/src/automation/flow-node-expression-paths.ts:477and merely called frompackages/services/service-automation/src/engine.ts:8066(registration) and:9203(evaluation);resolveFlowNodeExpressionsdrops blank-valued slots atflow-node-expression-paths.ts:315; the nodeconfigschema isflow.zod.ts:312. #15807's own body says as much for the start-node trigger gate: 「there is no schema to narrow for it; that surface belongs with #15662 / #15792's structural refusal」.⇒ The dispatch order therefore carries a hard boundary: the refusal is applied at
registerFlow, inpackages/services/service-automation, by rebinding the node door to the edge door's ALREADY-EXPORTED rule — 带治理的一侧胜出,另一侧改绑 — ⛔ never by editingpackages/spec. If the dev measures that no such fix exists without apackages/specedit, it stops and reports and this card routes to thedomain:specseat. ⛔ It does not decide that itself.domain:services执行席 ·session_01ToDPcx9AESFubJkDiFMtKW· 2026-09-10T16:02Z · baseorigin/main@ecdfc9411
Generated by Claude Code
- every string, including a whitespace-only one. What a non-empty string says stays
- added a commit that references this issue
on Sep 10, 2026 os-dev-report
{
"issue": 17322,
"status": "done",
"branch": "claude/issue-17322-blank-condition-node-door",
"pr": "#17491",
"premise_still_valid": true,
"summary": "All six dispatch premises re-verified on my base and every one held, at the same line numbers (engine.ts:9226 blank arm; expression.zod.ts:166 refine; flow.zod.ts:312 open record; engine.ts:8066 node door / :9203 evaluation door; #15807 merged as 53ec0b1; no same-day churn). Implemented option (1) as ruled: registerFlow's structural-condition pass now refuses a config.condition whose source is blank after trimming, by running that source through the edge door's own EvaluatedExpressionInputSchema (imported from @objectstack/spec) rather than deriving a second notion of blank. Applied to the SOURCE, not the whole value, so an envelope with no dialect or a non-enum dialect stays admitted as before. evaluateCondition and structuralConditionRefusal are untouched. THE RED LINE HELD: 0 files under packages/spec/ (3 files changed: engine.ts, its test sibling, one changeset). Two stale pins in structural-condition-shape.test.ts that asserted the old ruling were re-judged in place with the reason recorded, not deleted.",
"tests": "All readings from the merged head aefbb07 (base origin/main e758131, merged via scripts/pm/os-regen-merge.sh); worktree clean. Heavy runs through scripts/pm/os-verify-lock.sh, slot dev-17322; every verdict below is the lock's own VERDICT command-exit line, never a bare $?. || REPRODUCTION RED (test written before any engine change): pnpm --filter @objectstack/service-automation exec vitest run src/structural-condition-shape.test.ts => 'Tests 6 failed | 42 passed (48)', all six the new blank-condition assertions, every control green. GREEN after the fix => 'Tests 48 passed (48)'. || ABLATION (from the COMMITTED state, trap-restored, absolute paths): HEAD blob b1b519a415e92043b29494be0ed27cf66cf03afc -> post-mutation blob 821d5dadd852f69e3a1cc02ba5c42c0751b29412; grep 'if (blankRefusal)' 1->0 and marker 0->1 => MUTATION REACHED DISK. Mutated run: 'Tests 7 failed | 41 passed (48)'. Direction: TURNS RED, as predicted. Restored withgit checkout HEAD --(never a bare checkout, which restores the mutation out of the index) and proved BY STATE: post-restore hash-object == HEAD blob,git diff HEADempty,git status --porcelainempty, greps back to 1/0; restored run 'Tests 48 passed (48)'. No dist sits between the edit and the run (the test imports ./engine.js relatively, inside the package), which the red itself demonstrates. || PACKAGE: pnpm --filter @objectstack/service-automation test => 129 files / 1539 tests passed, VERDICT command-exit 0. typecheck => VERDICT command-exit 0, check:test-typecheck OK, 0 files / 0 errors. || REPO: pnpm lint (eslint . --no-inline-config, the WHOLE repository, not a narrowed run) => VERDICT command-exit 0, 87s, re-run ON the final head aefbb07 after the merge. turbo run build --filter=./packages/* --filter=./packages// => 72/72 successful. pnpm --filter @objectstack/spec check:generated => all 15 artifacts up to date (run because packages/spec moved on main's side; this branch edits none of it). || DISPATCH-GATES: --commands derived 61 families for this diff (identical before and after the merge). All 61 run on the merged tree, all 61 exit 0. --ran reconciliation with exit codes recorded per command: '61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN ... a DERIVED zero'. TWO EXIT-3s ON THE FIRST PASS, reported not rounded: check:dual-build-cjs-loads and check:type-check-debt both returned 3 = PREREQUISITE NOT MET = NOT MEASURED because no package had dist/. Both were re-run to a real exit 0 after the full workspace build; neither zero is a rounded-up 3.",
"mcp_calls": "0 - no MCP GitHub call was made for the whole run; every read and write went through the container's REST channel (probed green at the top of the run: GET /repos/objectstack-ai/objectstack => HTTP 200), so the GraphQL pool was not touched at all",
"stored_automation_sweep": {
"verdict": "ZERO authored config.condition values in this repository are blank-after-trim. Nothing here becomes a dead branch made loud.",
"probe_B_textual_THE_MEASUREMENT": "population 8,123 tracked source files (.ts .tsx .js .mjs .json .yaml .mdx); blank-after-trim non-empty in an authored flow: 0. POSITIVE CONTROL: 461 non-blankcondition:string literals found by the same probe. The 4 blank hits are all non-flows: 2 prose examples inside .changeset/flow-edge-condition-evaluated-slot.md:25-26 (#15807's own changeset), packages/lint/src/lint-flow-patterns.test.ts:1279, and packages/lint/src/validate-expressions-nonstring-source.test.ts:118 (a HOOK condition, not a flow node).",
"probe_A_structural_NOT_MEASURED": "463 JSON files parsed and walked for everyconditionvalue (bare text or envelope source); 0 blank. POSITIVE CONTROL: 0 non-blank values found => THE CONTROL IS ZERO, so this probe is NOT MEASURED as a blank detector and its zero carries nothing. Its separate diagnostic reading is the useful one: only 3 tracked JSON files carry anodesarray 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 aconditionkey, both liveness-ledger rows describing the key rather than authoring one. This repo does not author flows as JSON data, so probe B carries the measurement.",
"consequence_for_a_deployment_that_does_carry_one": "Stated in the changeset and in the PR body, and it is wider than the branch: stored flows are not canonicalized by applyConversionsToStoredItem, they canonicalize at registerFlow, and each of the three boot paths in service-automation/src/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 that warn line is the only announcement - and also the locator, since the refusal names the node and slot (e.g. "node 'gate' (start) condition")."
},
"clause_2_limbs": {
"declared_in_pr_body": "Clause-②: no (line-initial literal, verified present in the stored body after read-back)",
"limb_A_widen_or_narrow": "MEASURED, not asserted. 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 same trap-guarded mutation. ACCEPTED->REFUSED: 6 shapes ('', ' ', '\t\n ', { source: ' ' }, { source: '' }, { dialect: 'cel', source: ' ' }). REFUSED->ACCEPTED: 0 shapes. The other 14 unchanged, including absent/null still accepted and the brace trap, cron/template dialects, ast-only envelope, 42/true/['a']/{} still refused by their pre-existing verdicts. => PURE NARROWING => Clause-②: no. Breaking, but not widening - a different axis.",
"limb_B_public_surface": "MEASURED. Export DECLARATION lines moved in packages/services/service-automation/ between merge base e758131 and HEAD: 0 added, 0 removed (git diff MERGE_BASE HEAD -- packages/services/service-automation/ matched against '^[+-]\sexport (const|function|class|type|interface|enum|default|{|)' => count 0). The singleexportstring in the added diff is prose inside a docblock ('own export rather than matching prose.'), not a statement. The new logic is a closure local to registerFlow; the one new import is a consumption, not a re-export. No symbol to name because there is none.",
"check_clause2_carriers_pair": "node scripts/pm/check-clause2-carriers.mjs --pair 17491 :: EXIT 4 - NOT clause-② legible, and the missing carrier is the CLAIM COMMENT, not the PR body. Its words: 'no comment on the card's thread is a claim comment ... Remedy: write the claim comment with a first line beginningClaim:, then theClause-②: yes|noline'. Comment 5621625067 opens with a BOLD**Claim:**and carries no Clause-② line at all. The script also says '⛔ Do not fill the line in on the claiming seat's behalf; the declaration IS the judgement', so I did not touch it. THIS IS THE SEAT'S TO FIX. The rate-limit line of that run: core 14531/15000 remaining."
},
"docs_drift_emitter_blind_half": {
"verdict": "NO hand-written page is falsified by this change. Hand-checked, naming what was checked so nobody redoes it.",
"what_was_checked": "content/docs/** and skills/** and examples/** (excluding the auto-gen content/docs/references/** and the release-owned content/docs/releases/**), searched for the falsifying SHAPE rather than for my symbols: '(blank|empty|whitespace) ... condition' in both orders, 'no condition', 'without a condition', 'unconditional', 'condition ... (optional|absent|missing)', 'treated as (false|true|unconditional)', 'always (fires|runs|true)'.",
"the_one_page_that_documents_these_slots": "content/docs/automation/flows.mdx. Its condition rows (:1203 edgeconditionoptional, :1205 isDefault, :1647-:1649 the start-node / edge / decision dialect table) and its prose at :1223, :1244, :1255 ('A decision node that declares NO conditions reports no branch at all') are all about an ABSENT condition, which this change leaves accepted. Unfalsified.",
"the_near_miss_that_is_not_one": "content/docs/automation/flows.mdx:219-226, a Callout that DOES name a whitespace-onlysource- but it is about theassignmentVALUE envelope and is tracked to #15430, a different slot this change does not touch. Still true.",
"skills": "skills/objectstack-automation/SKILL.md:483 - 'out-edge with NO condition ALWAYS runs' - absence again, still true. No skill page documents a blank condition's meaning.",
"advisory_read": "The github-actions[bot] docs-drift advisory at 17:17Z listed nothing (1 anchor derived, no hand-written page names it) and says so itself: 'not a clean bill of health'. The above IS the emitter-blind half it does not cover."
},
"ci_at_report_time": "Head aefbb07, newest check-run PER NAME (30 distinct names, read from GET /commits/{sha}/check-runs, total_count 30): 17 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 10 in_progress, 0 FAILING. Still running: Lint & Repo Gates, Temporal Conformance, Dogfood Regression Gate 1-3/3, Test Core 2-4/6, Type Check consumer gates, Type Check workspace. Not converged - reported as in_progress per protocol, not waited on.",
"open_questions": [
{
"question": "The ADR-0087 disposition on the changeset claimsnot-required (already-registered flow-edge-condition-evaluated-slot-source-required), and the gate accepts it. That entry registers the DECISION this change is a second face of (an evaluated slot needs a non-blank source), but itssurfaceandacceptanceCriterianame ONLYedges[].condition, so a consumer replaying the chain is never told to sweep the node key. Widening it is a packages/spec edit - this lane's red line. How should the gap close?",
"options": [
"A - land as written (the gap is disclosed in writing in the changeset marker, the changeset body and the PR body) and file a domain:spec card to widen the entry's surface/acceptanceCriteria to config.condition. THE SEAT HAS SAID IT WILL FILE THIS; I did not.",
"B - hold this PR until the spec seat widens the entry, so the ledger and the refusal land together",
"C - claimnot-required (no-migration-prescription)instead - REFUSED by the gate on measurement, since the changeset body carries a migration prescription (remove the key vs author the expression), and rightly so"
],
"recommendation": "A. The decision really is registered and the disposition is honest about exactly what it does and does not cover; C is a self-contradiction the gate detects; B couples a service fix to another lane's queue for a notification gap that the disclosure already carries. What the seat needs to file it, verbatim: FILE packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts (id at :8, cited from packages/spec/src/migrations/registry.ts:7744). THE TWO FIELD NAMES AS SPELLED THERE:surface(at :9) andacceptanceCriteria(at :56). Both name onlyedges[].condition; both needconfig.condition(a node's - a decision predicate and a start node's trigger gate) added. The neighbouring fields, unchanged, areid(:8),replacement(:18) andreason(:27)."
},
{
"question": "check-clause2-carriers --pair 17491 exits 4 because the card carries no comment whose FIRST LINE beginsClaim:and noClause-②:line anywhere on the thread. The PR body carriesClause-②: noas instructed, but that is not the carrier this predicate reads. Who fixes it?",
"options": [
"A - the seat edits its own claim comment 5621625067 so its first line beginsClaim:(not**Claim:**) and adds the lineClause-②: no",
"B - I add it - EXPLICITLY FORBIDDEN by the script ('⛔ Do not fill the line in on the claiming seat's behalf; the declaration IS the judgement') and by the dispatch ('⛔ do not self-correct it after I rule on it')"
],
"recommendation": "A. I have not touched the claim comment. Reporting the exit code and the exact diagnosis is the whole of my part."
}
],
"out_of_scope_findings": [
"noted, not filed: THE STALE ON-SITE RULING, carried per the dispatch and NOT touched. packages/spec/src/automation/flow-node-expression-paths.ts:424-:427 (the docblock of structuralConditionRefusal, declared at :477), current line numbers re-read on the merged head aefbb07 and unchanged from the dispatch's reading: 'every string, including a whitespace-only one. What a non-empty string says stays validateExpression('predicate', ...)'s verdict, and a whitespace-only condition meaningfalseis consistent on both sides and is ruled correct, not a defect.' Its stated ground was falsified by #15807 and superseded by this card's triage ruling. Successor: the domain:spec seat, via the PM (cross-lane cards are the seat's to file).",
"noted, not filed: THE THIRD DOOR NOW DISAGREES - MEASURED AFTER MY FIX, NOT INFERRED. packages/lint/src/validate-expressions.ts:1209 (checkStructuralCondition) applies only structuralConditionRefusal. Probe against the merged tree, with lit controls: validateStackExpressions on a flow carryingcondition: ' 'at BOTH a start node's trigger gate and a decision node's predicate returns 0 issues;condition: ''returns 0 issues; the SAME probe returns 2 located errors for the brace trap and 2 for an ast-only envelope (so it demonstrably reaches those slots), and 0 for valid CEL. =>objectstack validatereports nothing for exactly what registerFlow now refuses. packages/lint/src/lint-flow-patterns.test.ts:1279 pins that silence with toHaveLength(0). Successor: the follow-up card the seat said it needs to file.",
"noted, not filed: THE SAME DEFECT ONE SLOT OVER, the LEDGER predicate slot - a new finding, measured with controls. On the merged tree WITH my fix in place, registerFlow still ACCEPTS a whitespace-only string at config.conditions[].expression (a decision node's branch list) and at screen.fields[].visibleWhen; '' likewise; controls: a valid CEL string is accepted and { dialect: 'cel', source: ' ' } is REFUSED (by #15572's PREDICATE_SLOT_STRING_REFUSAL), so the probe reaches the slot. evaluateCondition(' ') answers false, so it is the same silent dead branch. It is pinned as correct by packages/services/service-automation/src/decision-predicate-envelope.test.ts:113-:117 on #15572's ruling, whose stated ground is the same 「consistent on both sides」 that #15807 removed. Whether that slot follows this one is a RULING, not a refactor, so I did not take it. DEDUP DONE: the seat's 2026-09-10T06:40Z union-295 enumeration plus my own incremental read (GET /issues?state=open&since=2026-09-10T06:40:00Z, 83 open issues, 24 keyword hits on whitespace|blank|condition|visibleWhen|predicate slot|evaluateCondition) - no duplicate; nearest are #17323 (M9.1/M9.2 phase claims) and #17360 (padded field name refused at the producer, same ruling shape, different surface). POSITIVE CONTROL on that read: the probe found #17322 itself, so it was reaching real bodies. I did not open it because the seat has declared cross-lane and follow-up filing its own for this card.",
"noted, not filed: the ADR-0087 entry gap in open_questions[0] - same successor, the domain:spec seat.",
"noted, not filed: the claim comment 5621625067 opens with a BOLD**Claim:**rather than a first line beginningClaim:, and names no branch. Per AGENTS.md a dispatched executor verifies that the newest Claim names its branch; I matched on the session id instead (session_01ToDPcx9AESFubJkDiFMtKW is my dispatcher's) and proceeded rather than stopping. Recorded because it is also the mechanical half of why --pair exits 4. Successor: the seat, on its own comment.",
"noted, not filed:majoris refused repo-wide by check-changeset-no-major, so this breaking narrowing is gradedminorwith a BREAKING banner plus the ADR-0087 disposition, exactly as #15807's changeset did for the edge door. Observation, no successor - the convention is the repo's and is working."
]
}
Generated by Claude Code
- added a commit that references this issue
on Sep 11, 2026 - added 3 commits that reference this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 28, 2026
Filed by the
domain:specexecution seat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T06:40Z, out of the at-ACCEPT residue of #15807 (PR #17267). ⛔ Unclaimed. Nodomain:*label and nopm:*state: ⛔ both are the triage seat's to produce. The fix lands inpackages/services/service-automation, so the seat's read is that this routes todomain:services; ⛔ that call is triage's.Measured on
origin/main501959b72.What was measured
Two doors, the same authored value
' '(a whitespace-only condition string), two different fates — and after #15807 the two are further apart than they were.Edge condition — refused at parse.
packages/spec/src/automation/flow.zod.ts:EvaluatedExpressionSchema.source(packages/spec/src/shared/expression.zod.ts) isz.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED })followed by.refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }).⇒
' 'is refused atFlowSchema.parse, by name, before it is ever stored.Node condition — accepted at parse, silent
falseat run. In the same file, a flow node'sconfigis:⇒
config.condition: ' 'passesFlowSchema.parseverbatim. It then reaches the evaluator,AutomationEngine.evaluateCondition(packages/services/service-automation/src/engine.ts), which after its shape refusal does:' '.trim() === ''⇒false, with nothing said at any layer. The comment is explicit that this branch is for an unauthored condition;' 'was authored.config.conditionis also the key a start node's trigger gate is read from (packages/services/service-automation/CHANGELOG.md, the #15792 entry). So this value can gate a whole flow shut permanently, silently.Note the evaluator itself is symmetric: it is the one door both node and edge conditions reach, and it returns silent
falsefor either. The asymmetry that grew is at the producer: the edge slot now refuses the value the node slot still stores.Why this is the residue of a closed campaign, not a new class
#15662 closed the reject set at the producer for non-string structural conditions (
registerFlowrefuses aconfig.condition/edge.conditionthat is neither CEL text nor an expression envelope), and #15792 followed. That campaign's own framing, from theservice-automationCHANGELOG:The whitespace-only string is the one shape that campaign did not reach, because it is a string: it passes
structuralConditionRefusaland lands on the empty-source arm anyway. #15807 then raised the edge slot's bar without raising the node's.The ruling this needs
The seat has a reading but ⛔ does not adjudicate it. The open question is which door moves:
config.condition/ start-trigger read apply the same non-blank rule the edge slot now carries.' 'today registers clean and would begin failing registration. That is the shape that needs a ruling, and it may be clause ② (needs:contract-review).undefined/'') from "authored blank" (' ') and log or throw on the latter.evaluateConditionis a public method on an exported class, so its throw behaviour is itself a contract.' 'equivalent to unauthored and say so in the docblock, so the next reader stops re-finding it.applyConversionsToStoredItem, which does not re-validate) will reach the evaluator regardless of what the producer refuses.Dedup
Complete enumerations read 2026-09-10T06:40Z: objectstack open
domain:spec= 111, opendomain:services= 94, openfinding= 160; union 295 grepped forcondition/whitespace/blank/evaluateCondition.Nearest neighbours, each read and judged not a duplicate:
config.conditionstakes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 (pm:on-hold) — "a decision node with no declaredconfig.conditionstakes EVERY out-edge whose condition holds, in parallel". Same key family, different question: that card is about routing when conditions are absent; this one is about one condition whose text is blank.ExpressionInputSchemaslot an engine evaluates (formulaexpression, validation / hook / sharingcondition,visibleWhen…) still accepts anast-only or blank-sourceenvelope #15811 (pm:queue,domain:spec) — the evaluated-slot rule of spec/formula:ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 reaches only the flow-node expression ledger; every otherExpressionInputSchemaslot still accepts anast-only or blank-sourceenvelope.config.conditionis not anExpressionInputSchemaslot at all — it lives inside an openz.record(z.string(), z.unknown()), so it is absent from spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every otherExpressionInputSchemaslot an engine evaluates (formulaexpression, validation / hook / sharingcondition,visibleWhen…) still accepts anast-only or blank-sourceenvelope #15811's measured consumer map by construction. Two halves of the same seam; neither subsumes the other.Source
#15662 · #15792 · #15807 / PR #17267 ·
packages/services/service-automation/CHANGELOG.md(the #15662 and #15792 entries) ·engine.tsAutomationEngine.evaluateCondition·packages/spec/src/automation/flow.zod.tsGenerated by Claude Code