Skip to content

fix(workflows): fail a gate whose on_reject is not abort/skip/retry instead of silently completing a rejection - #3888

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/gate-invalid-on-reject
Open

fix(workflows): fail a gate whose on_reject is not abort/skip/retry instead of silently completing a rejection#3888
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/gate-invalid-on-reject

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

GateStep.execute reads on_reject = config.get("on_reject", "abort"). In the reject branch it handles only two literals before falling through:

if on_reject == "abort":  ...
if on_reject == "retry":  ...
# on_reject == "skip" → completed, downstream steps decide

So any other value lands in the "skip" case. A gate that was rejected reports COMPLETED, and the run continues past the review the gate exists to enforce.

Reproduction on current main (81bf741)

Same config, REJECT verdict, varying only on_reject:

on_reject='abort'  -> failed     error="Gate rejected by user at step 'g'"
on_reject='retry'  -> paused
on_reject='skip'   -> completed  (by design)
on_reject='Abort'  -> completed  <-- rejection silently discarded
on_reject='fail'   -> completed  <-- same
on_reject='stop'   -> completed  <-- same
on_reject=None     -> completed  <-- same
on_reject=5        -> completed  <-- same

This is a fail-open failure mode on a safety control, and every trigger is an ordinary authoring mistake:

  • a capitalisation slip — Abort, SKIP
  • a guessed verb — fail, stop
  • a bare on_reject: in YAML, which parses to None. Note config.get(key, default) does not substitute the default for an explicit null.

Fix

validate already rejects anything outside the three literals:

on_reject = config.get("on_reject", "abort")
if on_reject not in ("abort", "skip", "retry"):
    errors.append(...)

…but the engine does not auto-validate before execute (see WorkflowEngine.load_workflow), so this PR adds the matching execute-side guard. It mirrors the options and verdict_input guards already in this method, and is placed before the non-TTY short-circuit so the error surfaces in CI rather than pausing and failing later on interactive resume.

No breaking change. All three documented values behave exactly as before — the existing test_reject_verdict_input_preserves_reject_behavior parametrizes over them and still passes untouched. The only inputs whose behaviour changes are ones that previously discarded a rejection.

Verification

  • Fail-before / pass-after: the seven parametrized invalid values fail on unpatched src and pass with the fix. tests/test_workflows.py: 27 failed → 20 failed, 837 → 844 passed.
  • The remaining 20 are identical to the clean-main baseline I captured on 81bf741 (all Windows symlink-privilege).
  • uvx ruff@0.15.0 check src tests → clean

Test goes into the existing TestGateStep, beside test_validate_invalid_on_reject — the validate-side half of the same contract.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

execute() reads `on_reject = config.get("on_reject", "abort")` and, in the
reject branch, handles only "abort" and "retry" before falling through to
its `# on_reject == "skip"` case. So any other value makes a REJECTED gate
report COMPLETED and the run walks straight past the review the gate
exists to enforce:

  on_reject='abort'  -> failed     "Gate rejected by user at step 'g'"
  on_reject='retry'  -> paused
  on_reject='skip'   -> completed  (by design)
  on_reject='Abort'  -> completed  <-- rejection silently discarded
  on_reject='fail'   -> completed  <-- same
  on_reject='stop'   -> completed  <-- same
  on_reject=None     -> completed  <-- same
  on_reject=5        -> completed  <-- same

Reachable by a capitalisation slip, a guessed verb, a non-string, or a
bare `on_reject:` — note `config.get(k, default)` does NOT substitute the
default for an explicit YAML null.

`validate` already rejects anything outside abort/skip/retry, but the
engine does not auto-validate before execute(). Fail loudly instead,
mirroring the `options` and `verdict_input` guards in the same method, and
placed before the non-TTY short-circuit so it surfaces in CI too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner July 31, 2026 07:31
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.

1 participant