fix: #3621674 Refuse a form up front when submitting it can do nothing
Refuses a form whose submission could not do anything, while it is being built, instead of after the answers are in.
A webform bound to Orchestra was offered, filled in and submitted before anyone found out that submitting it could do nothing. Four cases were already true while the form was being built:
- the workflow a start form names cannot run.
postSave()survives that by keeping the answers and saying so, but only once they are in; - the actor may view the step but not complete it. The render gate refuses what
checkViewAccess()denies, and a bearer handle authorizes viewing, while binding needscheckCompleteAccess(); - the handle names no live token, because the branch has moved on. This one was silent:
postSave()reads the resume asif ($token !== NULL && allowed) ... elseif ($token !== NULL) ..., so with no token neither arm ran and the actor was told nothing at all; - the handle resolves to nothing, expired or tampered with.
alterForm() now asks, for a step and a start form alike, whether submitting could do anything, and when it could not it says so and disables the submit action. The escape-outcome buttons stay usable, because leaving the step by a path the workflow author defined is still valid, and the step's context stays readable. A step the actor may not see at all is still refused outright.
This is not the enforcement. The bind and the resume are still gated on submit, so a form submitted past this - a stale page, a forged post - changes nothing either way. What it removes is the wasted filling in, and the submission bound to no run that somebody had to recognize and clear. One refusal is left to postSave() on purpose: one that becomes true between the build and the submit, such as cover being revoked in between. No question asked at build time can know it.
Asking whether it can start
ProcessControlInterface gains canStart(), beside the start() it describes, so a doorway asks the engine rather than reaching into it. Read-only, and judged on the live configuration.
It is defined the way WorkItemManager::canAct() is - getStartRefusal(...) === NULL - with the reason behind the question rather than in front of it: DefinitionResolver::getStartRefusal() holds the wording, and nothing in the module needs it, since the refusal a submitted start hits is logged from the exception the engine throws.
It cannot ask through start() itself: reaching the snapshot start() judges goes through ensureCurrentVersion(), which creates a version on a miss, and a form being built must write nothing. The snapshot is taken from that same configuration when the run starts, so the two answers differ only for a workflow edited between the build and the submit, which no build-time check covers.
So that the two cannot drift, the conditions that read a definition are read through the engine's own accessors. WorkflowDefinitionInterface gains getValidStartNode(): the start node, but only once it names a node that is there, which is what start() asked inline and what any caller about to start a run wants. getStartNode() still reports what the definition names, which is what an editor needs while a dangling id waits to be corrected. Both are implemented once, in WorkflowDefinitionTrait, so the engine asks them of the immutable version snapshot a run pins while a surface asks them of the live workflow.
Cacheability
The refusal is read from mutable state and written into a form webform declares cacheable, naming only the webform as its dependency. So what the answer was read from is declared: the workflow list cache tag and the tenant context for a start form, the token list tag for a step, plus the user context, since completing a step is answered per actor and the render gate lets through everyone who may view one.
The direction that matters is the second one: without this, a page cached while a form was refused would go on refusing every visitor after the workflow ran again.
Nothing is logged here
A build runs per page view, so a reason logged from it 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, by the handler's existing report.
Tests
The four refusals and the declaration, each failing without the guard on its own assertion rather than ending the test as an error:
Submitting is refused up front.
Failed asserting that false is true.Plus a control asserting that a workable start form and a completable step reach the actor untouched and silent, which also pins the action key the disabling writes to, since webform owns it and a guard that silently stopped disabling anything would leave only a message behind. The declaration is asserted through cache_tags.invalidator.checksum: retiring the workflow, adding a tenant and moving the token each invalidate what the form declared, so the assertion cannot pass on a tag that invalidates nothing.
StartRefusalTest binds the new question to start() itself: for every reason the engine refuses, three things are asserted on one fixture - canStart() says no, the reason behind it names which reason, and start() throws - so a condition cannot change on one side only. It also pins the accessor's two shapes - a start node never set, and one naming a node that was deleted - and that a dangling id stays readable for editing.
tests/src/Kernel/StartRefusalTest.php will error, not fail, in test-only changes: it calls a method the reverted code does not have. That is the revert artifact, not a finding; the four handler tests in that job are the red proof.
Documentation in docs/webform.md and docs/extending.md, and French for the four new strings.
phpcs, phpstan (cache cleared), cspell, the translations check, the codebase linter unit classes and the affected kernel classes pass locally.