decision-no-match.decision-log.md
May 21, 2026 · View on GitHub
Title: Decision executor fails fast on no matching branch
Proposed by: Kuba Skibiński
Date: 29.04.2026
Context
The decision executor at packages/execution-core/src/executors/decision.ts:21–29 was routing execution down decisionBranches[0] whenever no branch's conditions matched — silently. No log, no error event, no node_failed. The matchedBranch in the output reflected the silent fallback, so an event-log audit looked identical to a successful match.
Users misconfigure decisions constantly: wrong operator, wrong field name, type mismatch ('5' < '10' is false because the comparison is string-based, etc.). The silent fallback hid every misconfig as "decision matched the first branch" and routed execution down a path the user never intended. Debugging required reading the conditions by hand against runtime values.
Two product-decision options were on the table:
- Strict — no match → throw →
node_failedwithcode: 'no_branch_matched'. Users author an explicit catch-all branch. - Explicit default — require
isDefault: trueon a designated branch. No default → runtime error. Default → run that branch (with a flag distinguishing match vs default).
User chose Strict for this PR.
Decision
Three changes in packages/execution-core:
decision.tsthrowsNodeExecutionError('no_branch_matched', ...)instead of falling through tobranches[0].- A new
NodeExecutionErrorclass inpackages/execution-core/src/errors.ts— anErrorsubclass carrying acode: stringfield. PlainErrorcontinues to work; onlyNodeExecutionErroropts in to structured-code propagation. graph-runner.tsrunNodecatch propagateserror.codeinto thenode_failedevent payload. TheExecutionErrorPayload['error'].codefield already existed inpackages/types/src/workflow-execution/execution-events.ts— this just fills it in.
Six tests (4 unit + 2 integration) lock down the contract:
decision.test.ts: returns first matching branch / skips non-matching to find next match / throwsNodeExecutionErrorwith code when nothing matches / treats empty-conditions branch as non-matching (so authors must use explicit operators for catch-all).graph-runner.test.ts(appended):NodeExecutionErrorthrown by an executor →node_failedpayload includescode/ plainErrorkeeps the current message-only shape (no regression for existing consumers).
Alternative Options Considered
- Keep silent fallback — rejected, the bug.
- Explicit
isDefault: trueflag on a designated branch — rejected for this PR: requires aDecisionBranchtype change inpackages/types, a Zod schema update inapps/backend/src/domain/mapper/snapshot-schema.ts, and frontend property-panel UI to expose the checkbox. Bigger scope, separate UX ticket on the follow-up list. - Throw plain
Errorwithoutcode— rejected. Adding a smallNodeExecutionErrorclass costs ~10 lines and gives executors a structured error-code channel for free. Reusable for future executors ('llm_timeout','template_unresolved', etc.) without further wiring. - Add
isDefaultand keep the silent fallback as a transition tool — rejected. Two semantics for the same concept is confusing; users couldn't tell which case they were in. Strict is the unambiguous answer.
Consequences
-
Pros
- Misconfigured decisions surface immediately as
node_failed(with precisecodeand human-readablemessage) andexecution_failed. No more silent wrong routing — the event log tells the whole story. - The
NodeExecutionError+runNodeplumbing is reusable infrastructure: any executor can now throw with a structured code and have it land in the SSE stream verbatim. - The fix is symmetric on the topological scheduler — a
node_faileddecision short-circuits the wave with the same semantics as any other failure. - Decision unit tests (4) and integration tests (2) prevent regression of both the no-match throw and the code-propagation wiring.
- Misconfigured decisions surface immediately as
-
Cons
- Existing flows that relied on the silent fallback (intentionally or by accident) will now fail loudly. For local-dev OSS-readiness deploy this is acceptable — the failure is the right outcome and the data is throwaway. Users who want a default need an explicit catch-all branch.
- A branch with empty
conditions[]no longer "matches by default" (it never did at runtime, but the silent fallback masked this). A catch-all must use a tautologically true condition like{x: 'a', y: 'a', comparisonOperator: 'isEqual'}. Reasonable; documented in the test.
Status
Accepted