Relationships
#2079 workflow guard: run requires ${{ }}, evaluate rejects it — no guard form satisfies both
Opened by mellens · 9/9/2026· Shipped 9/9/2026
Summary
There is no way to write a guard: that both swamp workflow run and
swamp workflow evaluate accept.
runrequires the guard to be a${{ }}expression. A bare CEL string is rejected at execution:Step "off-branch" guard must be a ${{ }} expression.evaluaterejects the${{ }}form. It pre-resolves the expression to a typed boolean and re-parses the document against the schema, whereguardisz.string(), givingInvalid input: expected string, received boolean.
So the two commands demand opposite things about the same field. validate
passes on both forms, which means it does not catch either condition.
The ${{ }} form is the correct one — it runs, and it behaves correctly (a
truthy guard skips the step). evaluate is the command that is wrong: it
cannot read a workflow that the runtime executes fine.
This is adjacent to #1837 but not the same thing. #1837 is about a whole-field
substitution of a boolean input (guard: ${{ inputs.dryRun }}) failing at
run, and asks validate to catch it statically. Here the guard is a
comparison expression that legitimately yields a boolean, the runtime handles
it correctly, and it is evaluate alone that falls over.
Steps to reproduce
Save as workflows/workflow-guard-repro.yaml:
id: 6b1c0f7a-2c4e-4a1f-9a3b-0d5e7c8f1a22
name: guard-repro
description: Minimal reproduction of the guard evaluate/run inconsistency.
version: 1
inputs:
type: object
properties:
action:
type: string
description: start or stop
enum: [start, stop]
default: start
required: [action]
jobs:
- name: main
description: Two guarded assert steps
dependsOn: []
weight: 0
steps:
- name: on-branch
description: Runs only when action=start
dependsOn: []
weight: 0
allowFailure: false
guard: ${{ inputs.action != "start" }}
task:
type: assert
expr: 'true'
message: on-branch ran
- name: off-branch
description: Runs only when action=stop
dependsOn: []
weight: 0
allowFailure: false
guard: ${{ inputs.action != "stop" }}
task:
type: assert
expr: 'true'
message: off-branch ranThen:
swamp workflow validate guard-repro→Result: PASSED(7 checks).swamp workflow run guard-repro --input action=start→ succeeds. Onlyon-branchruns;off-branchis skipped. Correct behaviour.swamp workflow evaluate guard-repro --input action=start→ fails.- Now replace both guards with the bare CEL equivalent —
guard: 'inputs.action != "start"'andguard: 'inputs.action != "stop"'— and repeat:evaluatenow succeeds (No expressions to evaluate), andrunnow fails.
Actual
Step 3, with the ${{ }} guards:
[FTL] error: ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
"jobs",
0,
"steps",
0,
"guard"
],
"message": "Invalid input: expected string, received boolean"
},
...
]
at async evaluateSingle (…/src/libswamp/workflows/evaluate.ts:413:16)
at async workflowEvaluate (…/src/libswamp/workflows/evaluate.ts:429:5)
at async consumeStream (…/src/libswamp/stream.ts:54:20)
at async Command.<anonymous> (…/src/cli/commands/workflow_evaluate.ts:244:7)Step 4, with the bare CEL guards:
Error: Workflow execution failed: Step "off-branch" guard must be a ${{ }}
expression, got: inputs.action != "stop"Expected
evaluate should treat guard the way the runtime does: a ${{ }} expression
whose result is a boolean is valid, and evaluate should either leave it
unresolved (as it does for vault.get(...)) or accept the resolved boolean
rather than re-parsing it against z.string().
Whatever the resolution, run and evaluate should agree on which guards are
legal. Today a workflow author has to pick which of the two commands they want
to be able to use.
Impact
evaluate is the natural way to check a workflow before running it against
real infrastructure — which is exactly when you most want to check it. Any
workflow that branches on an input has to give it up. It took four iterations
(including a throwaway probe workflow) to establish that the ${{ }} form was
correct and the evaluate failure was not our bug, because the two commands'
error messages each point convincingly at the other form.
Also, possibly the same root cause
--input <name>=false for a boolean input is coerced by run against the
workflow's input schema, but rejected by evaluate:
$ swamp workflow evaluate sync-workflows --input dryRun=true
Input validation failed: dryRun must be a boolean
$ swamp workflow run sync-workflows --input dryRun=false
(works)Same pattern: evaluate applies a stricter, differently-typed reading of the
same document and flags than run does.
Environment
- swamp
20260908.065515.0-sha.03224b68 - macOS 26.6.2, arm64
- Reproduced on a local repo with no datastore or serve involvement.
Shipped
Click a lifecycle step above to view its details.
stack72 commented 9/9/2026, 12:47:24 PM
Thanks @mellens for reporting this! We shipped: Fix evaluate command to handle guard expressions and boolean inputs consistently with the run command. Two changes: (1) skip guard expressions during evaluate so they are not resolved to booleans that fail Zod re-parse, and (2) add coerceInputTypes in the libswamp evaluate function (not CLI command) before input validation so all callers (CLI and serve) benefit.. The fix has been merged and a release is on its way. We appreciate your contribution to swamp.
Sign in to post a ripple.