Problem/Motivation
Starting a workflow through the client contract is refused with whichever of the engine's four sentences applies: Unknown workflow "%s"., Workflow "%s" is disabled, so it cannot be started., Workflow "%s" has no valid start node., or Workflow "%s" is not available in tenant "%s".
Three of those four say that a workflow by that name exists. A caller reached over this contract holds one tenant's credentials and nothing else, so the difference between the answers is an oracle: it can walk a list of machine names and read off, from which sentence comes back, which workflows the other tenants have. A workflow machine name is rarely nothing, and multi-tenancy is the module's stated isolation boundary.
Every other operation on the contract is already scoped to the caller's tenant: an instance, token or variable belonging to another tenant is invisible and cannot be acted on. The hole was not in what the API returns, it is in what it says when it refuses.
Steps to reproduce
On a site with two tenants and the OAuth-protected API installed, bind a consumer to tenant A and give it the drive orchestra api permission. Create a workflow scoped to tenant B. Then POST to /orchestra-api/process/{definition_id}/start as that consumer, once with a name that exists nowhere and once with tenant B's workflow. Both answer HTTP 422, and the two bodies differ: the first says the workflow is unknown, the second names tenant B's workflow as existing.
Proposed resolution
Ask the tenant question before the engine's, and answer a workflow outside the caller's tenant with the same sentence the engine uses for one that is not there at all. It reuses the one constant rather than repeating its text, so the two answers cannot drift back into being distinguishable.
Only the tenant case collapses. A workflow that is this tenant's and cannot be started as it stands still says which reason it is, because that names the caller's own configuration and they need it in order to fix it. Inside a tenant the engine keeps all four reasons apart, and that is what continues to reach the log, where the reader is an operator rather than a caller on the other side of a boundary.
API changes
The four refusal templates move from DefinitionResolver, which is @internal, onto WorkflowEngineInterface, which is @api and already carries the engine's other public constants. That is what lets the client reuse one of them without reaching into an internal class, and it is their right home, because a caller reads them. The strings themselves are unchanged.
OrchestraClientInterface::startProcess() now documents that a workflow which is absent and one belonging to another tenant are refused identically, so an alternative implementation of the contract has to keep them that way.
Data model changes
None.
User interface changes
None.
Release notes snippet
The cross-site API no longer reveals whether a workflow it refuses to start belongs to another tenant. An absent workflow and one scoped to a tenant the caller cannot reach are now refused in the same words, so a consumer cannot use the difference to enumerate other tenants' workflows.
AI-Generated: Yes (Claude Code was used to help draft this issue summary and to write the code and tests on the merge request. I reviewed and ran the work myself before posting it.)
Issue fork orchestra-3623816
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 #3
mably commentedComment #5
mably commented