Skip to main content
← Back to list
01Issue
BugShippedSwamp CLIPublic
Assigneesstack72

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.

  • run requires the guard to be a ${{ }} expression. A bare CEL string is rejected at execution: Step "off-branch" guard must be a ${{ }} expression.
  • evaluate rejects the ${{ }} form. It pre-resolves the expression to a typed boolean and re-parses the document against the schema, where guard is z.string(), giving Invalid 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 ran

Then:

  1. swamp workflow validate guard-reproResult: PASSED (7 checks).
  2. swamp workflow run guard-repro --input action=start → succeeds. Only on-branch runs; off-branch is skipped. Correct behaviour.
  3. swamp workflow evaluate guard-repro --input action=start → fails.
  4. Now replace both guards with the bare CEL equivalent — guard: 'inputs.action != "start"' and guard: 'inputs.action != "stop"' — and repeat: evaluate now succeeds (No expressions to evaluate), and run now 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.
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 7 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

9/9/2026, 12:47:17 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack729/9/2026, 12:03:35 PM
Editable. Press Enter to edit.

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.