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
Filed by the domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T06:40Z, out of the at-ACCEPT residue of #15807 (PR #17267). ⛔ Unclaimed. No domain:* label and no pm:* state: ⛔ both are the triage seat's to produce. The fix lands in packages/services/service-automation, so the seat's read is that this routes to domain: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) is z.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED }) followed by .refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }).
⇒ ' ' is refused at FlowSchema.parse, by name, before it is ever stored.
Node condition — accepted at parse, silent false at run. In the same file, a flow node's config is:
⇒ config.condition: ' ' passes FlowSchema.parse verbatim. It then reaches the evaluator, AutomationEngine.evaluateCondition (packages/services/service-automation/src/engine.ts), which after its shape refusal does:
const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? '');
...
// An absent / empty condition is not a predicate to evaluate. Callers that
// mean "unconditional" guard before calling; this is the one that does not
// (a `decision` node whose `conditions[]` entry has no `expression`), and
// an unauthored branch must not open.
if (exprStr.trim() === '') return false;
' '.trim() === '' ⇒ false, with nothing said at any layer. The comment is explicit that this branch is for an unauthored condition; ' ' was authored.
⚠️config.condition is 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 false for 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 (registerFlow refuses a config.condition / edge.condition that is neither CEL text nor an expression envelope), and #15792 followed. That campaign's own framing, from the service-automation CHANGELOG:
evaluateCondition derives its source as typeof expression === 'string' ? expression : (expression?.source ?? ''). For a value that is neither … the empty-source arm returns false: the "an unauthored branch must not open" rule, applied to a value that was very much authored.
The whitespace-only string is the one shape that campaign did not reach, because it is a string: it passes structuralConditionRefusal and 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:
Refuse at the node producer — make the config.condition / start-trigger read apply the same non-blank rule the edge slot now carries. ⚠️ This narrows an accept set on a published surface and on stored flows: a flow saved with ' ' today registers clean and would begin failing registration. That is the shape that needs a ruling, and it may be clause ② (needs:contract-review).
Signal at the evaluator — keep accepting, but distinguish "unauthored" (undefined / '') from "authored blank" (' ') and log or throw on the latter. ⚠️evaluateCondition is a public method on an exported class, so its throw behaviour is itself a contract.
Neither — declare ' ' equivalent to unauthored and say so in the docblock, so the next reader stops re-finding it.
⚠️Do not assume (1). The evaluator's existing comment is a deliberate, documented choice, and the same value arriving from a stored flow (replayed through 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, open domain:services = 94, open finding = 160; union 295 grepped for condition / whitespace / blank / evaluateCondition.
Nearest neighbours, each read and judged not a duplicate:
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