Problem/Motivation
A webform bound to Orchestra is offered, filled in and submitted before anyone finds out that submitting it could not do anything. The handler then says so afterwards, or in one case says nothing at all. Whoever filled it in has spent their time for nothing, and a submission that bound to no run is a row somebody has to recognize and clear later.
Four cases are already true before the form is rendered, so all four can be settled at build time:
- The start workflow cannot run: deleted, disabled, no valid start node, or not available in the tenant the submission acts in. #3621650 made this survivable - the answers are kept, the submitter is told, the reason is logged - but it is still told after the form has been filled in.
- The actor may view the step but not complete it. alterForm() refuses only what checkViewAccess() denies, and a bearer handle authorizes viewing, while binding needs checkCompleteAccess(). So the form is shown, filled and submitted, and postSave() then warns that the step "is no longer yours to act on".
- The handle resolves to no live token, because the branch has advanced since the link was issued. postSave() reads
if ($token !== NULL && allowed) ... elseif ($token !== NULL) ..., so with no token neither arm runs: the submission saves and the actor is told nothing whatsoever. This one is silent, and worse than the others. - The handle resolves to nothing at all: it expired, or it was tampered with, so no doorway claims it. The submission saves, binds nothing and starts nothing, and the submitter is told afterwards, as in the first two cases - when what they needed to hear, before filling anything in, is that the link is spent and they should ask for a new one.
One case is legitimately after the fact and should stay that way: a refusal that only becomes true between opening the form and submitting it, such as cover being revoked in between. The existing warning documents exactly that, and no build-time check can know it.
Steps to reproduce
- Point a webform handler at a workflow and disable that workflow. Open the form: it is fully fillable.
- Or open an assigned step through a bearer link as somebody who may view but not complete it: the form is fillable, and submitting warns afterwards.
- Or open a step link, let the branch advance, then submit: the submission saves and nothing is said.
- Or open a step link whose token has expired, or edit a character of it, then submit: the form is fillable, and the submission saves bound to nothing.
Proposed resolution
Settle it in alterForm(), which already runs on every build - that is why the step context is posted there rather than in the interaction plugin, so it survives wizard pages and validation rebuilds. Resolve the continuation as refuseUnviewableStep() already does, ask the four questions the submit will ask, and when the answer is no: show a message saying why, and disable the submit action rather than only warning afterwards.
The escape-outcome buttons stay enabled, because leaving the step by a path the workflow author defined is still a valid thing to do. The hard refusal for a step the actor may not even see stays as it is. A start form whose workflow can run, and a step that can be completed, are untouched.
Each answer has to be the one the submit itself would reach, or the form refuses what a submission would have accepted. The three step answers are read through the resolver and the access check postSave() uses. The start answer needs asking without starting, which nothing could do: start() judges the version snapshot it pins, and reaching that snapshot creates one on a miss, while a form being built must write nothing. So the engine answers it from the live configuration instead, through the conditions start() itself reads, and start() stays the enforcement.
The answer is read from state that changes, and it is written into a form Webform declares cacheable, naming only the webform as its dependency. So what the answer was read from is declared on the form: the workflow list, the tenant in effect, the run's tokens, and the actor. Without that, a form refused while its workflow was retired would go on refusing every visitor after the workflow ran again.
Nothing is logged from a build. It runs per page view, so a reason logged there would be logged again for every visitor, and a workflow retired behind an open form is a state an author chooses. A submitted refusal is still logged once per submission.
The shape is the one #3621295 settled for deletion: keep the page reachable so the reader is told why, say what is in the way, and disable the action. This surface had not caught up with it.
User interface changes
A form that cannot do anything on submit says so and cannot be submitted, instead of being filled in first. The escape buttons and the read-only step context are unchanged. A form refused because its workflow was retired offers a submit again as soon as the workflow can run, with no cache to clear by hand.
API changes
Two additions, both to @api interfaces, and both already implemented for everything the project ships:
ProcessControlInterface::canStart(string $definition_id): bool- whether the engine would begin a run of that definition now. Read-only, so a doorway can ask before it offers something whose submission would start one. Defined as the reason behind it being NULL, the way WorkItemManagerInterface::canAct() is.WorkflowDefinitionInterface::getValidStartNode(): ?string- the start node, but only once it names a node that is there, which is what a caller about to start a run wants and what the engine asked inline before. getStartNode() still reports what the definition names, which is what an editor needs while a dangling id waits to be corrected. It is implemented in WorkflowDefinitionTrait, so the workflow entity and the version snapshot both answer it; an implementation of the interface that does not use that trait has to add it.
No configuration, schema or update changes.
AI-Generated: Yes (Claude Code raised this while implementing #3621650, wrote the change, and drafted this summary. Each case was read off the handler before it was written down, and the two API additions are the ones the change makes.)
Issue fork orchestra-3621674
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedComment #4
mably commentedComment #6
mably commented