Problem/Motivation

Reassign hands a task to someone else, and then it is theirs: they choose the outcome and the process moves on. Absence cover, shipped as orchestra_delegation in #3613914: A user who is away has no way to let someone else act on their tasks, widens who may act on a person's tasks for a period, and the stand-in still decides. Neither lets you borrow a colleague's hands while keeping the decision, which is the ordinary case for a deputy who has the context but not the authority, and for a manager who wants the groundwork done before signing.

This is the third member of the family whose vocabulary #3613913: Call handing a task to another user reassignment, not delegation, so the word is free for delegating authority freed: reassignment moves ownership, absence cover widens the audience for a period, and this one borrows hands while holding the decision.

The pattern, and the words for it

What this describes is maker-checker, also known as the four-eyes principle: one person does the work, another confirms it before it binds. Here the checker is the task's own assignee, who never gave the decision away.

The words are deliberately about the work rather than about an opinion, because the work is real: the assignee asks someone to work the task, that person sends it for confirmation, and the assignee confirms it, sends it back, or decides differently. Calling what comes back a recommendation would undersell it: what is stored is an ordinary completion, and the changes the work made to the world already happened. Two nearby words are also spoken for and are not reused: "delegation" now means absence cover, and "worker" is a read scope (ReadScope::WORKERS, whoever holds or completed a task, the default and user-visible). "Checker" names the role when the design is being explained; it is not a field and not a label, because the assignee already is that person.

What is held back

The outcome signal, and nothing else. If the work saved an entity, amended a webform submission or attached a file, that already happened, exactly as it would for any review step. Only the token advance waits for the assignee. Every screen says so in plain words rather than implying the work is provisional, which is also why one of the ways forward is deciding differently: that changes only the outcome, so it needs no second pass at the work.

Shape

The assignee does not move. That is precisely what separates this from reassignment, so the work item needs no new owner field: the assignee is the checker throughout. What it gains is worked_by, pending_completion (the stored payload, as JSON) and one state, awaiting_confirmation.

The person asked is not recorded in the existing on_behalf_of, which means the opposite thing: that someone's authority was borrowed. Both can be true at once, when a colleague covering an absence asks a third person to work a task. On confirmation the completer is the confirming assignee, since theirs is the act that binds, and the pending completion is kept afterwards as the record of what was submitted, which is what makes a decision that departed from it legible.

The outcome is validated when the work is submitted, through the same contract completion uses, so an outcome the node does not configure is refused there rather than at confirmation time when whoever chose it has moved on. Escalation timers are unchanged, since a task awaiting confirmation is still parked.

One decision point, not one per surface

Five surfaces submit a completion: the inbox task form, the one-click outcome in the task list, the content task's form, the interaction resume gateway and the bulk action. Each of them deciding whether a submission binds or waits would be the same rule in five places, and the fifth would eventually be missed, so there is one router, WorkItemManagerInterface::submitCompletion(), which decides and then reports what it did through a small CompletionSubmission enum so each surface can word its own message. complete() stays the binding primitive for the confirmation itself, the timeout actions and the API.

That is what gives the feature to every surface at once: somebody asked to work a content task edits the content, presses the decision button, and their answer is held instead of binding, with no change to that surface's own code.

Whether the work can be done again

Sending the work back, and the assignee reworking it themselves, both mean entering the surface a second time, and not every surface survives that. The surface owner is the one that knows, so it declares it: canBeWorkedAgain() plus an optional caution sentence, on the interaction contract beside signalOutcomes() and guardsAllOutcomes(), answered by the configured plugin instance because the answer varies with the settings. A task type that is not interaction-based answers through the same core contract, and anything that does not answer is taken to be repeatable, which is the common case: a plain task's second pass rewrites nothing but the payload.

Of the shipped surfaces, a content step is repeatable with a caution that each pass saves another revision; a webform step is repeatable in every mode, since collect and editable review reopen the party's own submission rather than creating a second one, with a caution that earlier answers are overwritten; and the redirect step is not, because entering it hands the visitor to somewhere else and a second pass could mean a second order out there.

The gate is the surface, not the person: whoever is at the keyboard, the same declaration governs a send-back and the assignee reworking it. Where it says no, the operations are absent rather than disabled, since a disabled control with no role is dropped by assistive technology, and the notice on the task says what the remaining choices are.

Whether it is allowed here

A capability says what is possible, a workflow setting says what is permitted, and the setting can only narrow the capability. Human nodes carry one, allow_work_request, rendered as a May be worked by someone else checkbox beside "Assign to users" and "Offer to roles", on by default, with a boolean config schema entry of its own. Cleared, the request is neither offered nor accepted on that step: the route enforces it, not only the operation list. That is segregation of duties, and it is what models a legally personal sign-off.

It is a different question from whether the work can be redone, and the two never merge: a step whose surface cannot be worked twice is still a perfectly good candidate for being worked once by somebody else, which is exactly the case with real irreversible work in it.

Two things are deliberately not built. A second setting for whether sending back is allowed, because the assignee always keeps deciding differently, so forbidding a second pass buys very little and the surface declaration already stops the unsafe cases. And the site, tenant, workflow, node ladder that retention and read scope carry, because a checkbox does not earn four surfaces and an inheritance UI until somebody asks to set it for a whole tenant.

User interface

Asking. An operation beside Reassign on the assignee's own claimed task, on a form that is the reassign form's own shape: one user autocomplete, an optional note, one submit, returning to the list it was opened from. Both forms now share a base rather than a copy, so the reassign form got smaller.

The screen of the person asked is the task's own surface, unchanged. Same fields, same authored outcome labels. What is added is a notice saying that everything they save is saved for real and that the outcome they pick is held for the assignee to confirm, and a submit message naming what was sent.

The outcome buttons deliberately keep their authored labels rather than being prefixed. Those labels are per-node configured strings, so prefixing means concatenating a translated word onto authored text, which translates badly wherever the verb does not come first, and it makes the two screens diverge. The cost is stated plainly: someone who ignores the notice presses a button expecting more than it does, and the mitigation is that the button genuinely cannot decide anything.

The assignee's screen is the same surface, under a notice naming who worked it and what they submitted, with the choices that actually exist on that step. The operations are Confirm, Send back where the surface allows it, and Take back; deciding differently is the ordinary outcome the assignee picks themselves.

In the lists, exactly one person has the ball, and the operations say who. One-click outcomes are withheld in two cases, both because a click would do something other than it says: from the person asked, where it would hold their choice for someone else, and from the assignee of a task already worked, where it would override work they have not seen. Both keep the link that opens the task, so the way on is the page where the framing lives.

The notice itself is one service plus a request subscriber keyed on any route carrying a work item, rather than a banner written into each surface, so the inbox form, the comment form and the interaction pages are covered at once, including surfaces not yet written. The content entity form is reached with the task in a query parameter rather than a route parameter, so it asks the same service directly.

The ways out

Confirm; send back with the work kept, so the person opens the task and sees what they submitted; decide differently, which is always available since it re-enters nothing; rework it, where the surface allows; and, before anything has come back, take the request back. That last one matters: without it a colleague who goes on leave leaves the task parked in somebody else's inbox with no way home.

Rounds are unlimited and uncounted. Asking again over work that is already back is refused, since it would discard it silently, and so is asking yourself, which would make the confirmation a formality.

What clears a pending request

Reassigning the task, returning it to its pool, and the reassign and unclaim escalation timers all discard it, so a new holder never inherits a decision somebody else was holding. That is one shared step in the manager rather than four, which is also what makes the timeouts free: both timeout actions drive reassign, re-offer and release rather than writing state themselves.

Two defects in that area were found while wiring it and are fixed here. release() accepted only a claimed task, so an unclaim escalation timer would have silently done nothing for as long as unconfirmed work sat there. And the live-state list existed in four places (the inbox query, the pending-actions query, TaskActions and the entity), so a new state had to be added to each, and a miss makes a task vanish from a listing at exactly the moment somebody is waiting on it; they now share one constant.

Notifications and audit

Four notifications, each to the one person concerned rather than to an audience: the person asked when they are asked, the assignee when work arrives, the person asked when it is sent back, and the person asked when a request is discarded, which is the one that matters most since it fires when a reassign or a timeout throws their work away. They ride the existing task-notification dispatcher, so they inherit its guard (a broken notification degrades to no notification rather than a stuck run) and its workspace context, which is what makes the link correct when the run was started somewhere else. One notification type carrying the human sentence, not four near-identical templates a site would have to override four times.

Asking, submitting, sending back, confirming and discarding are each one audit event, on the audit spine and not the notification one, as the two are kept separate. On a surface whose work cannot be undone, that sequence is the only way to reconstruct who did what.

Remaining tasks

  • Release notes.

API changes

Additive. New on the work item: getWorkedById(), setWorkedBy(), getPendingCompletion(), setPendingCompletion(), isAwaitingConfirmation(), the STATE_AWAITING_CONFIRMATION state and a STATES_ACTIONABLE list. New on the manager: askToWork(), sendForConfirmation(), confirm(), sendBack(), withdrawWorkRequest() and submitCompletion(). New contracts: RepeatableWorkInterface, CompletionSubmission, HumanNodeInterface::mayBeWorkedByAnother() and two methods on the interaction contract, all with defaults so nothing downstream has to change. WorkItemEvent gains an optional context array. No task type learns that a confirmation exists, which is the point: the completion payload was already the task-type-agnostic currency of every doorway.

User interface changes

A new task operation and its form, three new operations on a task awaiting confirmation, notices on the task surfaces, one new state in the task lists, and one new checkbox on the human task node.

AI-Generated: Yes (Claude Code was used to write this change, its tests and this summary. I reviewed them; the kernel tests were run locally, the clearing and gating assertions were confirmed to fail against code with those guards removed, and the full suite runs on CI.)

Issue fork orchestra-3613915

Command icon 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

mably created an issue. See original summary.

mably’s picture

Status: Active » Postponed
mably’s picture

Issue summary: View changes
Status: Postponed » Active

mably’s picture

Status: Active » Needs review
mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes