Choice constraints reject the documented _eca_token ("Defined by token") sentinel value

Problem/Motivation

Many ECA plugins support a special _eca_token option — labeled "Defined by token" in the UI — which defers a value to a companion token at runtime. It is a documented, first-class feature. For example eca_render_markup's mode lists:

- `_eca_token` — Defined by token
  When using the "Defined by token" option, make sure there is a token with
  this name: eca_render_markup_mode

and the plugins branch on it explicitly, for example in modules/content/src/Plugin/Action/NewEntity.php:

line 160:  if ($this->configuration['type'] === '_eca_token') {
line 200:  if ($config['type'] === '_eca_token') {
line 218:  elseif ($langcode === '_eca_token') {

However, the typed-config Choice constraints do not include the sentinel. In modules/content/config/schema/eca_content.schema.yml:

action.configuration.eca_new_entity:
  mapping:
    type:
      type: string
      label: 'Entity Type'
      constraints:
        Choice:
          callback: '\Drupal\eca\Validation\EcaConstraintCallbacks::getAllValidContentEntityTypesAndBundles'

and EcaConstraintCallbacks::getAllValidContentEntityTypesAndBundles() (src/Validation/EcaConstraintCallbacks.php:50-52) returns only real entity type and bundle keys:

return array_keys(\Drupal::service('eca.service.content_entity_types')->getTypesAndBundles());

So configuration that uses a feature the plugin explicitly implements is reported invalid:

The value you selected is not a valid choice.

The same shape affects eca_token_load_entity.entity_type and eca_render.action_base.mode, and likely every other Choice-constrained key that offers "Defined by token".

Steps to reproduce

  1. Add an eca_new_entity action to an ECA model.
  2. Set Entity Type to "Defined by token" in the UI, and add the companion eca_new_entity_type token setter.
  3. Validate the resulting eca.eca.* config against its typed schema.

Result: The value you selected is not a valid choice. Expected: No violation — the model is correct and works at runtime.

Proposed resolution

Updated after a full audit of the sentinel surface. The original proposal below the line was based on an unsound heuristic; the corrected findings are authoritative and each carries file:line evidence.

The authoritative source is the form flag, not the branching

The report originally suggested using "plugins that branch on _eca_token" as the authoritative list. That under-reports materially. The real declaration is the form-element flag '#eca_token_select_option' => TRUE — 54 declaration sites across 47 files — consumed at exactly one point, src/Plugin/ECA/PluginFormTrait.php:77-84, which is called from src/Service/Actions.php:226, src/Plugin/ECA/Condition/ConditionBase.php:204 and src/Plugin/ECA/Event/EventBase.php:212.

Branch-based enumeration misses four classes of case:

  • Base-class fan-out — one branch, many plugins. RenderElementActionBase.php:221 covers 20 plugins; StringComparisonBase.php:195,204 covers 11; also CacheActionBase.php:50, ListAddBase.php:113, ListRemoveBase.php:136, FieldUpdateActionBase.php:111, FormAddFieldActionBase.php:119.
  • A service, not a plugin — modules/content/src/Service/EntityLoader.php:138,147,201 supplies from, entity_type and langcode by service injection. Grepping the class name finds zero consumers. This is where this report's own eca_token_load_entity.entity_type comes from.
  • A trait — modules/miscellaneous/src/Plugin/RouteTrait.php:67.
  • Branch variable name differs from the config key — EntityFieldIsAccessible.php:45 branches on $field_op for key operation; Cacheability.php:90 branches on $key for key cache_type.

Two structural traps also defeat per-plugin grepping: schema type inheritance (eca_render.action_base, eca_render.schema.yml:108, owns mode for 20 plugins and the key appears in no per-plugin section) and derivative section names (action.configuration.eca_render_views:views and similar).

Measured scope

101 (schema-section, key) pairs carry the sentinel:

count
Has a Choice constraint — broken today 69
Has Choice and already accepts the sentinel 7
No Choice constraint — latent, not broken 25

The 7 already-correct keys sit under EcaConstraintCallbacks::getAllValidLangcodes (src/Validation/EcaConstraintCallbacks.php:19), which already returns both _eca_token and ''. That is the existing precedent.

The sentinel never travels alone

PluginFormTrait.php:80 also injects '' => 'undefined' whenever the element's #required is falsy. 43 of the 69 broken keys are non-required and currently accept neither value, so a fix that adds only _eca_token leaves most of the defect in place.

But '' must not be blanket-added: 25 sentinel keys carry NotBlank beside Choice, and all 25 are #required. Adding '' there would create a value the UI offers and validation rejects — the same defect in reverse. The empty-string must be gated on requiredness exactly as the trait gates it.

Why a callback wrapper cannot work

The originally proposed ::withEcaToken() wrapper is not implementable. Symfony's ChoiceValidator invokes $constraint->callback with no arguments (vendor/symfony/validator/Constraints/ChoiceValidator.php:48-58), so a shared wrapper has no way to know which underlying list to wrap. It would also be useless for the 20 inline enums, impossible for [\Drupal\user\Entity\Role, getAllValidPermissions] (eca_user.schema.yml:77,91 — Drupal core, uneditable), and unable to resolve the one genuine callback collision: \Drupal\eca_views\Plugin\Action\ViewsQuery::getAllValidViews is shared by eca_views_query.view_id and eca_views_export.view_id, which offer the sentinel, and eca.event.views_base.view_id (eca_views.schema.yml:9), an event which must not.

The resolution

Introduce a dedicated constraint plugin, EcaChoice, extending Symfony's Choice, whose validator resolves the declared choices — inline or callback — appends _eca_token, appends '' when the key is not required, and delegates to the parent. Per key the change is one word: Choice: becomes EcaChoice:.

This states "this key offers Defined by token" once, declaratively, per key, and resolves every hard case: inline enums stay inline, the shared callbacks stay untouched, core stays untouched, and the views_base collision dissolves because that key simply keeps plain Choice.

Requiredness is read from the NotBlank constraint already present on the key rather than declared a second time in YAML. #required and NotBlank agree on 75 of the 76 affected locations; the single outlier, eca_render.action_base.mode (#required with no NotBlank), is corrected by adding NotBlank.

Guard against recurrence

The validation callbacks are not the source of the form #options — the two lists are independent, which is precisely how they drifted apart. A kernel test therefore walks every event, condition and action form, finds all elements carrying #eca_token_select_option, and asserts each corresponding schema key accepts _eca_token, and accepts '' exactly when the element is not required. This turns the fix into a permanent invariant rather than a one-off repair.

Known to be out of scope

  • eca_render.action_base.name — no Choice at all; it is type: machine_name (eca_render.schema.yml:112-114), pattern validation. Tracked in #3590374 (closed).
  • The 25 latent keys with no Choice constraint — they reject nothing.
  • Four type: integer keys where the sentinel is rejected by PrimitiveTypeConstraintValidator independently of Choice: eca_route_match.request, eca_token_load_route_param.request, eca_write_log_message.severity, eca_enqueue_task_delayed.delay_unit. Same class as the machine_name case above; needs its own issue.
  • Several eca_render_* actions have no config schema section at all (eca_render_markup, eca_render_lazy, eca_render_file_contents, and the filter / image / responsive_image derivatives), so their entire configuration is unvalidated. Separate, larger issue.

Remaining tasks

  • Audit which config keys expose the _eca_token option
  • Establish the correct enumeration source and the '' pairing rule
  • Add the EcaChoice constraint and route the 69 broken keys through it
  • Add the kernel guard test covering the sentinel and the empty-string rule
  • Review and merge

User interface changes

None.

Data model changes

None.


Environment: ECA 3.1.5, Drupal 11.

AI-Generated: Yes (Used OpenCode to analyze typed-config validation output on a production site, trace the sentinel handling through the plugin and validation source, and draft this report. All file and line references were verified against the installed code.)

Change record: #3616767

Edited by drupalbot