Skip to content

Commit cf71d00

Browse files
fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
A gate with `on_reject: retry` consumes a bound reject verdict before pausing by resetting the named input to `""` (documented behaviour, so a later resume prompts again). Every `resume()` re-resolves the persisted inputs through `_coerce_input`. Those two rules collide when the bound input declares an `enum` that does not list `""`. The reset writes a value the input's own enum forbids, and the run wedges: inputs: spec_verdict: type: string enum: [approve, reject] steps: - id: review type: gate options: [approve, reject] on_reject: retry verdict_input: spec_verdict $ specify workflow run wf --input spec_verdict=reject Status: paused $ specify workflow resume <run_id> --input note=b Error: Input 'spec_verdict' value '' not in allowed values: ['approve', 'reject']. The workflow validates clean and the first run looks fine, so the failure only appears at the second resume. It is also unrecoverable in practice: `_resolve_inputs` re-coerces the whole persisted map, so *any* resume that supplies an input dies on the stored `""`. Only a resume with no inputs at all still works -- and that is precisely the call that cannot deliver a new verdict, which is the one thing the retry cycle exists to allow. Extend the existing `verdict_input` cross-check (which already confirms the name is declared) to also require that a retry-bound input's `enum` admits the reset sentinel, and report it with a fix hint. To do that, thread the input *definitions* through `_validate_steps` instead of just their names. Rejected the alternative of popping the key instead of writing `""`: that lets the input's `default` flow back in on the next resume, so a gate the user just rejected would silently auto-approve. Docs: note the `enum` requirement next to the reset behaviour it follows from. Adds 4 validation tests for the new guard plus a characterization test that drives the engine directly to pin the wedge it prevents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Opus 5 (1M context)
1 parent 7f40c82 commit cf71d00

3 files changed

Lines changed: 203 additions & 18 deletions

File tree

docs/reference/workflows.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,19 @@ pauses: the named stored input is reset to `""`. A later resume therefore
623623
prompts or pauses again until another verdict is supplied. Approve, abort, and
624624
skip outcomes leave the input unchanged.
625625

626+
Because of that reset, a verdict input used with `on_reject: retry` must accept
627+
`""`. If it declares an `enum`, include the empty string — otherwise the reset
628+
value violates the input's own `enum` and the run can no longer be resumed with
629+
any input. `specify workflow add` reports this as a validation error.
630+
631+
```yaml
632+
inputs:
633+
spec_verdict:
634+
type: string
635+
enum: ["", approve, reject]
636+
default: ""
637+
```
638+
626639
## FAQ
627640

628641
### What happens when a workflow hits a gate step?

src/specify_cli/workflows/engine.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -308,15 +308,16 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
308308
errors.append("Workflow has no steps defined.")
309309

310310
seen_ids: set[str] = set()
311-
# ``input_names`` is the set of declared workflow input names — used by
312-
# ``_validate_steps`` to cross-reference gate ``verdict_input`` bindings.
313-
# ``None`` means the inputs block itself is malformed (already reported
314-
# above); the cross-check is then disabled so one authoring mistake does
315-
# not cascade into N spurious "undeclared" errors.
316-
input_names: set[str] | None = (
317-
set(definition.inputs) if isinstance(definition.inputs, dict) else None
311+
# ``input_defs`` maps declared workflow input names to their definitions —
312+
# used by ``_validate_steps`` to cross-reference gate ``verdict_input``
313+
# bindings (both that the name exists and that its ``enum`` permits the
314+
# reset sentinel). ``None`` means the inputs block itself is malformed
315+
# (already reported above); the cross-check is then disabled so one
316+
# authoring mistake does not cascade into N spurious "undeclared" errors.
317+
input_defs: dict[str, Any] | None = (
318+
dict(definition.inputs) if isinstance(definition.inputs, dict) else None
318319
)
319-
_validate_steps(definition.steps, seen_ids, errors, input_names)
320+
_validate_steps(definition.steps, seen_ids, errors, input_defs)
320321

321322
return errors
322323

@@ -325,15 +326,15 @@ def _validate_steps(
325326
steps: list[dict[str, Any]],
326327
seen_ids: set[str],
327328
errors: list[str],
328-
input_names: set[str] | None = None,
329+
input_defs: dict[str, Any] | None = None,
329330
inside_fan_out: bool = False,
330331
) -> None:
331332
"""Recursively validate a list of steps.
332333
333-
``input_names`` is the set of declared workflow input names (or ``None``
334-
when the inputs block is malformed). ``inside_fan_out`` is threaded
335-
through nested control-flow steps so gate verdict bindings can be rejected
336-
anywhere inside a fan-out template.
334+
``input_defs`` maps declared workflow input names to their definitions (or
335+
is ``None`` when the inputs block is malformed). ``inside_fan_out`` is
336+
threaded through nested control-flow steps so gate verdict bindings can be
337+
rejected anywhere inside a fan-out template.
337338
"""
338339
from . import STEP_REGISTRY
339340

@@ -440,11 +441,39 @@ def _validate_steps(
440441
f"Gate step {step_id!r}: 'verdict_input' is not "
441442
"supported inside fan-out templates."
442443
)
443-
elif input_names is not None and verdict_input not in input_names:
444+
elif input_defs is not None and verdict_input not in input_defs:
444445
errors.append(
445446
f"Gate step {step_id!r}: 'verdict_input' references "
446447
f"undeclared input {verdict_input!r}."
447448
)
449+
elif input_defs is not None:
450+
# ``on_reject: retry`` resets the bound input to "" before
451+
# pausing, and every later resume re-resolves the persisted
452+
# inputs through ``_coerce_input``. If the input declares an
453+
# ``enum`` that omits "", that reset value is instantly
454+
# illegal: the run pauses fine, but the next resume that
455+
# supplies any input raises "value '' not in allowed
456+
# values", and no verdict can be routed through the gate
457+
# again. Require the enum to admit the sentinel so the
458+
# retry cycle the field advertises is actually reachable.
459+
verdict_def = input_defs.get(verdict_input)
460+
enum_values = (
461+
verdict_def.get("enum")
462+
if isinstance(verdict_def, dict)
463+
else None
464+
)
465+
if (
466+
step_config.get("on_reject") == "retry"
467+
and isinstance(enum_values, list)
468+
and "" not in enum_values
469+
):
470+
errors.append(
471+
f"Gate step {step_id!r}: on_reject='retry' resets "
472+
f"verdict input {verdict_input!r} to '' when the "
473+
f"gate is rejected, but that input's 'enum' does "
474+
f"not allow ''. Add '' to the enum or use "
475+
f"on_reject='abort'/'skip'."
476+
)
448477

449478
# Recursively validate nested steps
450479
for nested_key in ("then", "else", "steps"):
@@ -454,7 +483,7 @@ def _validate_steps(
454483
nested,
455484
seen_ids,
456485
errors,
457-
input_names,
486+
input_defs,
458487
inside_fan_out=inside_fan_out,
459488
)
460489

@@ -467,7 +496,7 @@ def _validate_steps(
467496
case_steps,
468497
seen_ids,
469498
errors,
470-
input_names,
499+
input_defs,
471500
inside_fan_out=inside_fan_out,
472501
)
473502

@@ -478,7 +507,7 @@ def _validate_steps(
478507
default,
479508
seen_ids,
480509
errors,
481-
input_names,
510+
input_defs,
482511
inside_fan_out=inside_fan_out,
483512
)
484513

@@ -491,7 +520,7 @@ def _validate_steps(
491520
[fan_step],
492521
set(),
493522
fan_errors,
494-
input_names,
523+
input_defs,
495524
inside_fan_out=True,
496525
)
497526
errors.extend(fan_errors)

tests/test_workflows.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4673,6 +4673,149 @@ def test_malformed_verdict_input_no_duplicate_error(self):
46734673
# No undeclared-input error (123 is not a string, so cross-check skips)
46744674
assert not any("undeclared input" in e for e in errors)
46754675

4676+
def test_retry_verdict_enum_must_allow_reset_sentinel(self):
4677+
# on_reject: retry resets the bound input to "" before pausing, and
4678+
# every resume re-resolves persisted inputs through _coerce_input. An
4679+
# enum that omits "" makes that reset value instantly illegal, so the
4680+
# next resume supplying any input dies with "value '' not in allowed
4681+
# values" and no verdict can reach the gate again.
4682+
errors = self._errors("""
4683+
workflow:
4684+
id: wf
4685+
name: wf
4686+
version: "1.0.0"
4687+
inputs:
4688+
spec_verdict:
4689+
type: string
4690+
enum: [approve, reject]
4691+
steps:
4692+
- id: review
4693+
type: gate
4694+
message: "Review?"
4695+
options: [approve, reject]
4696+
on_reject: retry
4697+
verdict_input: spec_verdict
4698+
""")
4699+
assert any(
4700+
"on_reject='retry' resets verdict input 'spec_verdict'" in e
4701+
for e in errors
4702+
), errors
4703+
4704+
def test_retry_verdict_enum_including_sentinel_passes(self):
4705+
errors = self._errors("""
4706+
workflow:
4707+
id: wf
4708+
name: wf
4709+
version: "1.0.0"
4710+
inputs:
4711+
spec_verdict:
4712+
type: string
4713+
enum: ["", approve, reject]
4714+
default: ""
4715+
steps:
4716+
- id: review
4717+
type: gate
4718+
message: "Review?"
4719+
options: [approve, reject]
4720+
on_reject: retry
4721+
verdict_input: spec_verdict
4722+
""")
4723+
assert not any("on_reject='retry'" in e for e in errors), errors
4724+
4725+
def test_verdict_enum_without_sentinel_passes_when_not_retry(self):
4726+
# abort/skip never reset the input, so the enum need not admit "".
4727+
for on_reject in ("abort", "skip"):
4728+
errors = self._errors(f"""
4729+
workflow:
4730+
id: wf
4731+
name: wf
4732+
version: "1.0.0"
4733+
inputs:
4734+
spec_verdict:
4735+
type: string
4736+
enum: [approve, reject]
4737+
steps:
4738+
- id: review
4739+
type: gate
4740+
message: "Review?"
4741+
options: [approve, reject]
4742+
on_reject: {on_reject}
4743+
verdict_input: spec_verdict
4744+
""")
4745+
assert not any("on_reject='retry'" in e for e in errors), (
4746+
on_reject,
4747+
errors,
4748+
)
4749+
4750+
def test_retry_verdict_without_enum_passes(self):
4751+
# No enum means _coerce_input accepts "" — the documented shape.
4752+
errors = self._errors("""
4753+
workflow:
4754+
id: wf
4755+
name: wf
4756+
version: "1.0.0"
4757+
inputs:
4758+
spec_verdict:
4759+
type: string
4760+
default: ""
4761+
steps:
4762+
- id: review
4763+
type: gate
4764+
message: "Review?"
4765+
options: [approve, reject]
4766+
on_reject: retry
4767+
verdict_input: spec_verdict
4768+
""")
4769+
assert not any("on_reject='retry'" in e for e in errors), errors
4770+
4771+
def test_retry_verdict_enum_wedge_is_reachable_end_to_end(self, tmp_path):
4772+
"""The validation error above guards a real, unrecoverable run state.
4773+
4774+
Without the guard this workflow installs and runs fine, then wedges:
4775+
the retry reset writes "" into the persisted inputs, and the next
4776+
resume that supplies *any* input re-resolves them and dies on the
4777+
enum. Only a resume with no inputs at all still works, so the bound
4778+
verdict can never be delivered.
4779+
"""
4780+
import pytest
4781+
import yaml as _yaml
4782+
4783+
from specify_cli.workflows.engine import WorkflowEngine
4784+
4785+
definition_data = {
4786+
"schema_version": "1.0",
4787+
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
4788+
"inputs": {
4789+
"spec_verdict": {"type": "string", "enum": ["approve", "reject"]},
4790+
"note": {"type": "string", "default": "a"},
4791+
},
4792+
"steps": [
4793+
{
4794+
"id": "review",
4795+
"type": "gate",
4796+
"message": "Review?",
4797+
"options": ["approve", "reject"],
4798+
"on_reject": "retry",
4799+
"verdict_input": "spec_verdict",
4800+
}
4801+
],
4802+
}
4803+
wf_dir = tmp_path / ".specify" / "workflows" / "wf"
4804+
wf_dir.mkdir(parents=True)
4805+
(wf_dir / "workflow.yml").write_text(
4806+
_yaml.safe_dump(definition_data), encoding="utf-8"
4807+
)
4808+
4809+
engine = WorkflowEngine(tmp_path)
4810+
definition = engine.load_workflow("wf")
4811+
state = engine.execute(definition, inputs={"spec_verdict": "reject"})
4812+
assert state.status.value == "paused"
4813+
# The retry reset persisted a value the input's own enum forbids.
4814+
assert state.inputs["spec_verdict"] == ""
4815+
4816+
with pytest.raises(ValueError, match="not in allowed values"):
4817+
engine.resume(state.run_id, inputs={"note": "b"})
4818+
46764819
def test_verdict_input_in_switch_case(self):
46774820
# Recursion coverage: bad reference inside a switch case must surface.
46784821
errors = self._errors("""

0 commit comments

Comments
 (0)