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_modeand 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
- Add an
eca_new_entityaction to an ECA model. - Set Entity Type to "Defined by token" in the UI, and add the companion
eca_new_entity_typetoken setter. - 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:221covers 20 plugins;StringComparisonBase.php:195,204covers 11; alsoCacheActionBase.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,201suppliesfrom,entity_typeandlangcodeby service injection. Grepping the class name finds zero consumers. This is where this report's owneca_token_load_entity.entity_typecomes from. - A trait —
modules/miscellaneous/src/Plugin/RouteTrait.php:67. - Branch variable name differs from the config key —
EntityFieldIsAccessible.php:45branches on$field_opfor keyoperation;Cacheability.php:90branches on$keyfor keycache_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— noChoiceat all; it istype: machine_name(eca_render.schema.yml:112-114), pattern validation. Tracked in #3590374 (closed).- The 25 latent keys with no
Choiceconstraint — they reject nothing. - Four
type: integerkeys where the sentinel is rejected byPrimitiveTypeConstraintValidatorindependently ofChoice:eca_route_match.request,eca_token_load_route_param.request,eca_write_log_message.severity,eca_enqueue_task_delayed.delay_unit. Same class as themachine_namecase 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 thefilter/image/responsive_imagederivatives), so their entire configuration is unvalidated. Separate, larger issue.
Remaining tasks
- Audit which config keys expose the
_eca_tokenoption - Establish the correct enumeration source and the
''pairing rule - Add the
EcaChoiceconstraint 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