Problem
Contract defects: an @api extension point that cannot be implemented at all, setters that invite the caller to corrupt state, and docblocks whose stated behaviour differs from the method beneath them. These matter more than usual now, because a beta freezes them.
11 findings: 9 med, 2 low.
The name collision
The @api WorkItemPresentationInterface publishes a name that a trait helper on every human task type already holds
Where: src/WorkItemPresentationInterface.php:40 and src/OutcomeConfigTrait.php:126, reached by every human task type through src/HumanNodeConfigTrait.php:24
HumanNodeConfigTrait uses OutcomeConfigTrait, so every human task type already declares getOutcomeLabel() with protected visibility and an incompatible signature: the trait takes one string and returns a string, the interface takes a work item plus a string and returns markup or NULL. A class's own declaration wins over a trait's, and a class implementing an interface must declare that method, so such a class compiles and PHP refuses nothing. What it loses is the trait's helper: every call to getOutcomeLabel() inside the class reaches the interface's method instead, with a different signature and a different job. One name, two meanings, depending on where it is read, on a surface a beta freezes. The users are exactly the classes the interface's own docblock addresses, "A task type implements this to override, in code, what the inbox shows for a task": orchestra_inbox's UserTask and its human-task trait, orchestra_interaction_task's InteractionTask, core's UserOperation, and the task-family test fixture. docs/human-tasks.md:100 advertises it to authors. Nothing in the repository implements it, which is why it has never fired, and the only consumer is the instanceof check at src/WorkItemManager.php:1091. A beta would freeze both signatures as they are.
The rest
One line each: what it is, and where. The failing scenario and the fix for each are carried by its own commit.
- med
InteractionToken::isValid()is dead, and weaker than the accessor everyone else uses: it compares only the instance, so it answers true for a token-scoped grant naming a different branch.modules/orchestra_interaction/src/InteractionToken.php:170 - med
retrymeans two different things on the same node, with three labels: the node-feature advance-retry policy, the subprocess re-launch budget, and a schema entry calling it a "retry budget".src/Plugin/NodeFeature/Retry.php:56,src/Plugin/TaskType/SubprocessTask.php:129,136,221,228,config/schema/orchestra.schema.yml:616 - med The audit trail names the wrong person as the actor of a reassignment: the target rather than the person who did it, while the other two sites use the field for the acting account.
src/WorkItemManager.php:863 - med
SourceProviderInterfaceandSourceViewcarry no@api, although they are the documented contrib extension point and the interface they say they mirror does carry it.src/SourceProviderInterface.php:21,src/SourceView.php:20 - med
CompositeConditionBase::children()instantiates child conditions unguarded, soevaluate()throws where its@apicontract says it returns a bool.src/CompositeConditionBase.php:200-212, reached from:145and:157 - med The engine mutates work-item state off
OrchestraAuditableEvent, which three sibling docblocks declare is "for recording, not acting", so any third-party recording listener that stops propagation silently leaves every resumed task showing as claimed.src/EventSubscriber/WorkItemResumeCompletionSubscriber.php:44-62 - med
TokenParkedEvent's docblock never says it is dispatched inside the advance transaction, so a subscriber cannot know a throw rolls the park back; both sibling lifecycle events do say it.src/Event/TokenParkedEvent.php:11-21 - med
ProcessInstanceInterface::setStatus()is an unguarded trap on an@apiinterface with no production caller: the engine writes status only through a guarded update, so a submodule taking the promise up would write every base field from a stale snapshot and could resurrect a cancelled run.src/Entity/ProcessInstance.php:279-282, declared atsrc/ProcessInstanceInterface.php:117-119 - low Layering and naming slips on
@apisurfaces that a beta would freeze.src/PluginSequenceFeatureBase.php:33,src/InstanceStatusProviderInterface.php:35,src/InstanceStatus.php:39,src/NotificationContextInterface.php:38,src/StatusInterface.php:21,src/HumanNodeConfigTrait.php:22,src/CommentVariableTrait.php:17 - low
TokenParkedEventandWorkItemEventare cross-module extension points without@api.src/Event/TokenParkedEvent.php:11-21,src/Event/WorkItemEvent.php:10-22
Proposed resolution for the getOutcomeLabel collision
Not a rename. OutcomeConfig::label(array $labels, string $value): string already exists as the single implementation, and its class docblock already claims the job: "the parsing and label resolution live here so a human task, an interaction or any other configurable step share exactly one implementation". The trait's getOutcomeLabel() is a two-line adapter that pulls outcome_labels out of the plugin's configuration, decodes it and delegates to that. So a convenience adapter is squatting on the name the published extension point needs, and that is the whole defect.
- The
@apipresentation hook keepsgetOutcomeLabel(WorkItemInterface $task, string $value). It is the only outcome-label method a task type should expose, because it is the only one anyone outside the class calls. - The trait stops adapting and exposes what it actually owns:
getOutcomeLabels(): array, returning the decoded map. Resolution stays where it already lives, so the call sites readOutcomeConfig::label($this->getOutcomeLabels(), $value). One concept per layer: the value object resolves, the plugin supplies its own decoded config, the interface presents. UserOperationForm's private helper is deleted rather than renamed. It isOutcomeConfig::label()with a different input shape.WorkItemManagerInterface::getOutcomeLabel()is untouched: it lives on a different object and never collided.
This also closes the separate finding about outcome labels read with a bare array cast. Four places reach into outcome_labels by hand, src/PendingActionsFinder.php:218, modules/orchestra_ui/src/Form/UserOperationForm.php:177, modules/orchestra_interaction/src/Form/CommentForm.php:145 and the trait itself, three of them with a bare cast that skips the decode the trait's own comment calls mandatory. getOutcomeLabels() becomes the one decoder and retires all three. That is why this shape is right rather than the smallest one: the missing accessor is the actual gap, and the collision was a symptom of not having it.
A test fixture is part of the fix rather than an extra. A task type that composes HumanNodeConfigTrait and implements WorkItemPresentationInterface has to exist in the suite, or nothing ever compiles the combination, which is why this survived eight previous audits, and the trap reopens the next time a method is added to either side.
This issue summary was drafted with the assistance of an AI agent (Claude). The analysis and the wording were reviewed by me before posting, and accountability for the content is mine.
Issue fork orchestra-3620614
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 #2
mably commentedComment #4
mably commentedComment #5
mably commentedComment #7
mably commented