Engine correctness: state that can be lost, resurrected or stranded

The rest of #3620607, after !420 (merged). Six commits, one per finding, each with a test seen to fail against unmodified code.

Two more that lose or misdirect state

A definition that cannot be resolved threw where a throw is the wrong answer. Two accessors resolve the definition an instance runs: one throws when neither the pinned version nor the live workflow can be loaded, the other answers NULL, and its docblock says why it exists. Three callers used the throwing one.

The worst is the dead-letter handler: reading the retry policy is the first thing it does, outside its own try, so an instance pinning no version whose workflow has since been deleted threw out of advanceQueued() itself. The queue then retried that item forever and no incident was ever raised, which is the one outcome the dead-letter policy exists to prevent. The pending-actions finder builds a list, so one such row took the whole "My actions" page with it. And skipping an incident is an operator pressing a button, which gave them a 500 instead of a refusal; that third one is not in the issue, it is the same family one hop away.

A moderation transition published stale content. An attachment resolves an entity's default revision, and for a moderated entity the reviewer's own edits are typically a pending revision: core's editorial draft is not a default revision, so a preceding entity form step leaves the default revision holding the pre-review content. Setting the moderation state there created a new default revision carrying the old field values, publishing content nobody reviewed and leaving the reviewer's revision pending forever. The transition now starts from the latest revision, in the translation it affects, and creates its revision from there keeping its default-ness, so content moderation decides from the target state whether what it saves becomes the default one. That is the shape core's own latest-version form uses.

Three that took the most consequential path on a configuration error

An unknown timeout action fell back to resume, so a typo in a node's timeout action, or a plugin that left with its module, advanced the run down the timeout branch: an escalation misspelled became a completion, silently and unrepeatably. Nothing fires now, and the re-arm still runs, so the task stays where it was and its deadline is asked again — correcting the configuration is all the timeout needs.

A flow the step does not have signalled nothing and reported success. The trace's branch buttons carry a flow id in the query, and a trace rendered before the workflow changed carries one the step no longer has. That reached the bare signal the plain Signal button asks for: the token was signalled with no outcome at all while the message claimed the operator's branch had been taken.

A subprocess node naming a workflow that is gone was a pass-through. Naming no child is a pass-through by configuration; naming one that is not there carried the run past the sub-process as if it had completed, with its output variables unset. It throws now, which dead-letters the token and raises the incident an operator resolves once the child is back.

One low on the quorum's arithmetic

The quorum counted votes from arcs it did not count as arrived. Which arcs match is evaluated in the arriving token's own view, and a collected vote is token-scoped, so an arc an earlier branch travelled is not necessarily one this arrival expects. The tally ran over every collected value regardless, so a vote from an arc this arrival does not count could reach the quorum on its own, and the "can no longer be reached" test compared a tally from one set of arcs against a remainder from another. Collected values are keyed by their arc now.

With this, every finding in #3620607 is implemented.

Merge request reports

Loading