Issue #3621168: Announce a guarded state transition, so cascades react to the change itself
The engine flips a contended state with a guarded conditional write rather than an entity save, so exactly one caller wins the race. That write fires no save hook, which leaves the transition invisible: anything that has to react to a branch ending has nothing to listen to, and hangs off whatever else the winning caller saved afterwards. In #3621060 that was the incident.
The announcement
StateTransitionEvent carries the entity as the flip left it, the state it left, the state it reached, and the reason the caller named. StateTransitions::claimState() dispatches it last, after the row is written and the in-memory copy synced, and only on the path that performed the flip: a caller that found the row already moved on returns FALSE and announces nothing, so a cascade cannot hear one transition twice. All three entities that take this route are covered, tokens, instances and incidents, so a consumer of "this run ended" or "somebody is on this incident" no longer has to read the state back.
claimState() gains an optional $reason, which nothing in the primitive reads. It exists so the answer to "why did this task get canceled" survives the hop to whoever reacts.
A parent resumed by its completed child was claiming its state with its own copy of the flip, written out beside the primitive, so half the resumes in the module announced themselves and half did not. It goes through claimState() now. One flip stays hand-written and silent: the timeout sweep claims a due join token guarding on the deadline as well as the state, which the primitive cannot express, and the release that undoes that claim stays beside it. Both the primitive and the extending guide say so, since a subscriber is entitled to know where the announcements stop.
The name
getTransition() was a getter that gets nothing. What it returns is whether the next move is the caller's to make, and the guarded row is what settles that: exactly one caller may halt this branch, end this run or take up this incident. So it is claimState(), which is the word the module already uses wherever this shape appears (claimIncident(), claimDueDeadline(), claim(), and core's claimItem()). claimTerminalState(), which wraps it, needed no change.
The task side
It already watched a branch ending, through hook_orchestra_token_update, which reaches it only for the branches ended by an entity save. WorkItemCancellationSubscriber is the other half: it hears the halt, which is the one end a guarded flip reports. A token consumed by a guarded flip (a fired timer) has finished what it was for, and a task completed the ordinary way is completed on the resume, so neither is anybody's to cancel.
Completing a task is the case that needs care. The completion resumes the token inside the task lock and finishes the task when the resume returns, so a synchronous run that dead-ends on that resume announces the halt of the very token whose task is being completed. The manager already flags the token it is signaling, so that the resume-driven auto-complete skips it; the cancellation now asks the same question, and a completion whose own branch halts stays a completion.
The departing-holder hook from #3621033 stops cancelling the task itself and lets its halt carry the cause instead: one call where there were two, and the same holder blocked in the audit entry either way.
Tests
StateTransitionEventTest: a flip that applied is announced once with what it did and why; the caller that lost the race announces nothing; a flip that names no reason announces none; an instance and an incident are announced the same way as a token. WorkItemCancellationTest gains the two task-side cases (a halt cancels the task with the reason given, a flip that does not end the branch leaves it). AuditEventsTest pins that the reason reaches the audit entry rather than a generic one, and that a completion whose branch dead-ends is recorded as a completion and nothing else. SubprocessTest pins that a parent put back to work by its child announces it like any other resume.
Mutation-checked: dropping the dispatch fails the announcement tests and the halt cascade, dropping the ended-state check cancels the task of a token that was merely resumed, dropping the signaling guard puts a cancellation in the middle of a completion's trail, and writing the parent's resume by hand again leaves it unannounced.
Note for the reviewer
#3621060 (!445 (merged)) adds a cascade on orchestra_incident_insert for exactly this gap, since it had nothing else to hang off. This supersedes it. Whichever lands second should remove it: if !445 (merged) goes in first I will drop it here on the rebase.