Skip to content

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

@os-bill

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/main 501959b72.

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:

condition: EvaluatedExpressionInputSchema.optional()

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: z.record(z.string(), z.unknown()).optional()

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:

  1. 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).
  2. 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.
  3. 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:

Source

#15662 · #15792 · #15807 / PR #17267 · packages/services/service-automation/CHANGELOG.md (the #15662 and #15792 entries) · engine.ts AutomationEngine.evaluateCondition · packages/spec/src/automation/flow.zod.ts


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions