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.

  • GroupAudienceBase holds the dimension: token minting, recipients, labels, cache tags, unmatched.
  • Groups is the fixed-config plugin, declaring StableCandidatesInterface because 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() calls GroupMembership::loadByUser(), a static, against the DI style used elsewhere. Deliberate: it runs on every inbox query and that loader is backed by cache.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. AssignmentMatcher declares the viewer's own user: 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 Roles only 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: without orchestra.audience.groups a 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 project keys, 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.)

Edited by Frank Mably

Merge request reports

Loading
Loading