Issue #3624219: Add an Orchestra Group submodule that staffs tasks from group membership
Adds the orchestra_group submodule from #3624219: a Groups assignment plugin that staffs a human task from Group membership.
The dimension
AssignmentInterface already promised this shape, so the module is only a plugin: a group:GID candidate token per configured group on one side, and the same token per group the account belongs to on the other. No storage change, no query change.
GroupAudienceBaseholds the dimension: token minting, recipients, labels, cache tags, unmatched.Groupsis the fixed-config plugin, declaringStableCandidatesInterfacebecause the candidate set is known before the run reaches the step.
Decisions worth reviewing
- Membership, not permission. A group role in the outsider or insider scope derives from a global role and applies to every group of its type, including groups the account never joined, so it cannot be enumerated from an account the way a viewer token must be. Only a real membership mints a token, and the class says so where somebody would try to add it.
getViewerTokens()callsGroupMembership::loadByUser(), a static, against the DI style used elsewhere. Deliberate: it runs on every inbox query and that loader is backed bycache.group_memberships_chained. Going through the injected storage would be more testable but puts an uncached entity query on the listing.- What expires a cached answer. This is the first dimension whose viewer tokens are not read off the account.
AssignmentMatcherdeclares the viewer's ownuser:tag, and Group invalidates the member's tags whenever a membership is created or deleted, so joining and leaving reach every list that named the viewer with nothing for the plugin to add. That is load-bearing and unwritten on Group's side, so a test holds Group to it. - An unknown group ID is a validation error, where
Rolesonly warns about an unheld role. A role machine name may be about to be created; a numeric group ID resolving to nothing is a typo or a deleted group and will never fill. An existing group with no members still only warns. - Two unmatched reasons. A token is unmatched when the group is gone, and when it exists with no active member. Both leave the step waiting on nobody.
- Blocked accounts are excluded from recipients, matching the status condition the role dimension uses, and the fan-out cap is
RoleAudienceBase's. The status is part of the membership query rather than applied to its rows, so the row limit bounds members and not memberships.
composer.json gains a suggest entry for drupal/group, noting it tracks the 4.0.x alphas.
Exercised
Against real entities on a site with Group 4.0.0-alpha2: a member's account mints group:5; a non-member and an anonymous account mint nothing; labels resolve and drop a token whose group is gone; unmatched reports the deleted group and the empty one but not the populated one; recipients excludes a blocked member.
Wiring the module needs beyond the plugin
- A configuration schema. An audience's settings are typed per plugin, by the module that reads them, and the workflow entity is
FullyValidatable: withoutorchestra.audience.groupsa workflow naming this audience carries settings the schema cannot type at all. - The Group entity types mapped for static analysis, the way the other development dependencies are, so phpstan stays at level 5 rather than reading every storage load as an
EntityInterface. - The next-major lane. Adding Group turns Drupal 12 red twice over, and neither is visible locally because the lean root is PHPUnit 11.5: Group's classes raise 142 of the "might add a native return type declaration" deprecations, whose ignore entry names every development dependency it comes from, and PHPUnit 12 reports a mock configured with no expectations. Both verified against 12.0.0-alpha1 with PHPUnit 12.5.34 rather than argued from the lane.
- Its own French catalog and the two
interface translation projectkeys, without which the module's strings are English on a French site and its French has nowhere to live.
Coverage
GroupsAudienceTest covers the dimension on both sides, the two editor doors, the settings type a saved workflow needs, what expires a listing when somebody joins or leaves, and a run that reaches a task staffed from a group — which also pins that a group is a pool however few people are in it. GroupsAudienceEditorTest drives the classic modeler, because where a validation refusal lands is only observable through a real host form.
AI-Generated: Yes (Claude Code was used to help write this change and its description. I reviewed the work and exercised the plugin against real groups and memberships before posting it.)