Skip to content

fix: resolve ternary CEL expressions with partial inputs (#814)#815

Merged
stack72 merged 1 commit intomainfrom
fix/814-ternary-expression-regression
Mar 22, 2026
Merged

fix: resolve ternary CEL expressions with partial inputs (#814)#815
stack72 merged 1 commit intomainfrom
fix/814-ternary-expression-regression

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Mar 22, 2026

Summary

Fixes a regression introduced in PR #655 where CEL expressions containing ternary operators would be incorrectly skipped during workflow execution when any input referenced in any branch of the ternary was absent — even if that branch would never be evaluated at runtime.

Root Cause

PR #655 added a skip-guard to evaluateDefinitionExpressions() and evaluateDefinition() to fix issue #653 (factory model delete steps failing with No such key: cidrBlock). The guard used extractInputReferencesFromCel() — a regex scan — to detect all inputs.* references and skip evaluation if any were absent.

This is semantically incorrect for conditional expressions. Given:

inputs.transport == "lan" ? inputs.lan_host : inputs.tailnet_host

The regex reports transport, lan_host, and tailnet_host as all required. If lan_host is absent (because transport is "wan"), the entire expression is skipped — leaving globalArguments.host as a raw ${{ }} string. The shell model then tries to execute that literal string and fails with exit code 1.

Fix

Replace the inputs regex pre-check with try-catch around the CEL evaluation call.

CEL natively short-circuits ternaries — it only evaluates the taken branch. So:

The model resource/file dependency pre-check (checking whether a prior step's output is available in context) is retained unchanged — that is a structural availability check, not an input presence check, and is always correct to guard.

User Impact

Who is affected: Any workflow step whose model definition uses a ternary (or other conditional CEL expression) in globalArguments or methods.<name>.arguments, where only the inputs for the taken branch are provided.

Symptom: The workflow step fails with a non-zero exit code because the shell executes a literal ${{ ... }} string rather than the resolved command.

Breaking change: No. This is a bug fix. Existing workflows that worked before #655 will now work correctly again. The factory model delete pattern fixed by #653 continues to work — directly-referenced missing inputs are still left unresolved via try-catch.

Changes

File Change
src/domain/workflows/execution_service.ts Remove providedInputKeys / regex skip block; wrap celEvaluator.evaluate() in try-catch; rename hasMissinghasMissingModelDep for clarity
src/domain/expressions/expression_evaluation_service.ts Identical fix for the CLI evaluation path
src/infrastructure/cel/cel_evaluator_test.ts Two new tests proving CEL ternary short-circuit and direct-missing-input throw behaviour
src/domain/expressions/expression_parser_test.ts Documents why extractInputReferencesFromCel must not be used as a skip gate
src/domain/expressions/expression_evaluation_service_test.ts Two model-agnostic unit tests for evaluateDefinition() with ternary in globalArguments
integration/workflow_partial_inputs_test.ts End-to-end ternary test through the shell model execution path

Test Plan

  • deno lint — clean
  • deno fmt --check — clean
  • deno run test — 3484 passed, 0 failed
  • deno run compile — binary builds successfully
  • Manual repro: workflow run issue-814-ternarysucceeded (regression fixed)
  • Manual repro: workflow run issue-653-partial-inputssucceeded (GlobalArguments CEL expressions fail when factory inputs are omitted — selective evaluation not working #653 fix preserved)
  • New CEL unit tests: ternary short-circuits, direct missing input throws
  • New model-agnostic unit tests: globalArguments ternary resolves correctly
  • New integration test: end-to-end ternary in method arguments

Closes #814

🤖 Generated with Claude Code

PR #655 introduced a skip-guard in evaluateDefinitionExpressions() and
evaluateDefinition() that used extractInputReferencesFromCel() — a regex
scan — to detect all inputs.* references and skip evaluation if any were
absent. This was intended to fix issue #653 (factory model delete steps
failing with 'No such key: cidrBlock' when create-time inputs are omitted).

The guard is too aggressive: for a ternary such as
  inputs.transport == "lan" ? inputs.lan_host : inputs.tailnet_host
the regex reports all three inputs as required. When lan_host is absent
(because transport is "wan"), the entire expression is skipped — leaving
globalArguments.host as a raw ${{ }} string. The shell model then tries
to execute that literal string, producing exit code 1 and a failed
workflow step.

CEL natively short-circuits ternaries, so missing branch inputs are never
accessed at runtime. The correct fix is to attempt evaluation and catch
any CEL error (which only fires for inputs referenced directly, not inside
a non-taken branch). This preserves the #653 behaviour — a directly-
missing input still throws at eval time and is caught, leaving the
expression unresolved — while allowing ternary and other conditional
expressions to evaluate correctly.

Changes:
- execution_service.ts: remove providedInputKeys / extractInputReferencesFromCel
  skip block; wrap celEvaluator.evaluate() in try-catch; rename hasMissing
  → hasMissingModelDep for the retained model-resource dependency check
- expression_evaluation_service.ts: identical change for the CLI path
- cel_evaluator_test.ts: two new tests proving CEL ternary short-circuit
  and direct-missing-input throw behaviour
- expression_parser_test.ts: documents why extractInputReferencesFromCel
  must not be used as a skip gate (reports all ternary branch inputs)
- expression_evaluation_service_test.ts: two model-agnostic unit tests for
  evaluateDefinition() with globalArguments ternary (primary regression proof)
- workflow_partial_inputs_test.ts: end-to-end ternary test through the
  shell model execution path

Fixes #814. Preserves #653 fix.

Co-authored-by: Adam Stacoviak <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix: resolve ternary CEL expressions with partial inputs (#814)

No blocking issues found. The fix is clean, well-tested, and correctly addresses the regression.

What I verified

  • Root cause & fix: The regex-based extractInputReferencesFromCel() pre-check was semantically incorrect for conditional expressions — it treated all inputs.* references as required regardless of branch. Replacing it with try-catch around celEvaluator.evaluate() is the right approach: CEL natively short-circuits ternaries, so only the taken branch needs its inputs.
  • Both code paths fixed: The identical fix is applied in both ExpressionEvaluationService.evaluateDefinition() and DefaultStepExecutor.evaluateDefinitionExpressions(), keeping them in sync.
  • Model dependency pre-check retained: The structural extractDependencies() check for model resource/file availability is correctly preserved — model data is never conditionally accessed, so the pre-check remains valid. The rename from hasMissing to hasMissingModelDep makes the intent clearer.
  • #653 regression protection: The "directly-missing input stays unresolved" test explicitly verifies the original fix is preserved.
  • Import boundary: No libswamp internal imports from CLI/presentation layers. Integration tests import from domain/infrastructure directly, which is correct for test code.
  • License headers: All new/modified files have the AGPLv3 header.
  • DDD: Changes are scoped to domain services (ExpressionEvaluationService, DefaultStepExecutor) — the right layer for expression evaluation logic. No domain model changes needed.
  • Test coverage: Excellent — 6 files changed with tests at every layer: CEL evaluator unit tests, expression parser documentation test, expression evaluation service unit tests, and an end-to-end integration test.

Suggestions (non-blocking)

  1. Bare catch swallows all CEL errors (expression_evaluation_service.ts:251, execution_service.ts:779): The catch blocks silently discard any InvalidExpressionError, not just missing-input errors. A CEL syntax error in a ${{ }} expression would be silently ignored and left as a raw string, only surfacing later when the Proxy throws. Consider catching InvalidExpressionError specifically and logging at debug level so expression authoring mistakes are easier to diagnose. This is a minor observability improvement, not a correctness issue — the Proxy still guards against using unresolved values.

  2. extractInputReferencesFromCel is now unused outside tests and extractInputReferences: It's still exported and called internally by extractInputReferences() in expression_parser.ts:272, so it's not dead code, but it might be worth adding a doc comment noting it should not be used as a skip gate (the test documents this, but a comment on the function itself would help future contributors).

LGTM — approve.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

None found.

Medium

  1. Overly broad catch swallows all CEL errors, not just missing-input errorssrc/domain/expressions/expression_evaluation_service.ts:251 and src/domain/workflows/execution_service.ts:779

    The empty catch {} catches every error from celEvaluator.evaluate(), including syntax errors, type mismatches, and other genuine bugs in CEL expressions — not just the missing-input case the comment describes.

    Breaking example: A user writes a typo in a CEL expression in methods.execute.arguments.run:

    run: '${{ "echo " + inputs.transprot }}'  # typo: transprot instead of transport

    Previously (before #655), this would throw InvalidExpressionError with a clear message like "No such key: transprot". Now it's silently swallowed, the raw ${{ ... }} string is passed to the shell, and the user sees bash: ${{: command not found or similar — a far less helpful error.

    For globalArguments, the Proxy safety net catches this with a reasonable message. But for methods.<name>.arguments values (the execution_service.ts path), there is no Proxy — the raw ${{ }} string flows directly to the execution driver. The comment on line 782 says "The Proxy on globalArgs will surface a clear error" but this is only true for globalArguments, not method arguments resolved in this same code path.

    Suggested fix: Catch only InvalidExpressionError where the cause message indicates a missing variable/key, and re-throw everything else. Alternatively, at minimum log a warning in the catch block so users have a breadcrumb when debugging expression issues. Something like:

    } catch (error) {
      if (error instanceof InvalidExpressionError && isMissingInputError(error)) {
        // Leave unresolved — CEL short-circuited past this input
        continue;
      }
      throw error;
    }
  2. Model dependency pre-check has the same ternary problem this PR fixes for inputssrc/domain/expressions/expression_evaluation_service.ts:228-243 and src/domain/workflows/execution_service.ts:756-770

    The comment says "model data is never conditionally accessed in CEL — member access on a missing model ref is always an error." This is an assumption about user behavior, not enforced by the system. A user could write:

    ${{ inputs.use_cache ? model.cache-model.resource.value : "default" }}
    

    The extractDependencies regex would find model.cache-model.resource, see it missing, and skip the entire expression — the exact same class of bug this PR fixes for inputs.

    This is pre-existing behavior and not introduced by this PR, so it doesn't block merge. But worth noting since the PR specifically touches this code and adds a comment claiming it's always correct.

Low

  1. Duplicated evaluation logic between two files — The expression evaluation loop in execution_service.ts:749-785 and expression_evaluation_service.ts:221-256 are near-identical. This was pre-existing duplication, but the PR required applying the same fix in both places. A future refactor to share this logic would prevent the two paths from diverging.

  2. extractInputReferencesFromCel is now unused in production code but still exported — The import was removed from both expression_evaluation_service.ts and execution_service.ts. It's still used in tests (to document why it shouldn't be used as a skip gate), but if no production code calls it, it's dead code.

Verdict

PASS — The fix is logically sound and well-tested. CEL's native short-circuit evaluation is the correct approach to handle conditional expressions with partial inputs, and the regression tests are thorough. The broad catch (Medium #1) is a real concern for debuggability but not a correctness or safety issue — the Proxy provides a safety net for globalArguments, and for method arguments the expression would have been skipped by the old regex check in many of the same scenarios. The test coverage (unit + integration) gives confidence the fix works for both the new ternary case and the original #653 partial-inputs case.

@stack72 stack72 merged commit f8ab784 into main Mar 22, 2026
9 checks passed
@stack72 stack72 deleted the fix/814-ternary-expression-regression branch March 22, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swamp does not resolve the model’s inputs.* before evaluating globalArguments

1 participant