Scope API
The scope system is built on Drupal's plugin API using PHP attributes for discovery.
Plugin attribute
use Drupal\ai_context\Attribute\AiContextScope;
#[AiContextScope(
id: 'my_scope',
label: new TranslatableMarkup('My Scope'),
description: new TranslatableMarkup('General description of the scope.'),
display_weight: 55,
scoring_weight: 60,
)]
Namespace: Drupal\ai_context\Attribute\AiContextScope
Two separate weight parameters control display and scoring independently:
-
display_weightcontrols the order of this scope in forms, tabs, and listings. Lower values appear first, matching Drupal's#weightconvention. Must be a positive integer. Default: 100 (sorts last). Built-in display weights: Global 10, Use Cases 20, Context Tags 30, Roles 40, Languages 50, Site Sections 60, Entity Types 70, Taxonomy Terms 80, and Specific Entities 90. Custom scopes can use intermediate values (for example, 55 between Languages and Site Sections). -
scoring_weightcontrols how much influence this scope has when calculating subscription match scores. Higher values give the scope more influence. Defaults to NULL, which is treated as 1. Scopes that do not support subscriptions (supportsSubscriptions()returns FALSE) are never scored regardless of this value. Values below 1 are treated as 1. Built-in scoring weights: Use Cases 100, Context Tags 50, Site Sections 50, Entity Types 50, Taxonomy Terms 50.
Site builders can override either weight per scope via the scope settings page without touching plugin code. The scoring override is only offered for scopes that support subscriptions.
Interface: AiContextScopeInterface
Namespace: Drupal\ai_context\Plugin\AiContextScope\AiContextScopeInterface
All scope plugins must implement this interface. Key methods:
Identity and metadata
| Method | Return | Description |
|---|---|---|
getPluginId() |
string |
Plugin ID (PluginInspectionInterface). There is no getId(). |
getLabel() |
string |
Human-readable label |
getDescription() |
string |
General description, shown on the scope overview and settings pages |
getItemFormDescription() |
string\|TranslatableMarkup |
Description shown on context item forms; defaults to getDescription() |
getSubscriptionDescription() |
string |
Description shown on consumer subscription forms |
getDisplayWeight() |
int |
Display order weight (ascending, lower = first) |
getSubscriptionScoringWeight() |
int |
Subscription scoring influence (always ≥ 1) |
Choosing a description method
A scope plugin can show different text in each place its description appears. Implement only the ones that need to differ; the rest fall back.
| Where it appears | Method used |
|---|---|
Scope overview page (/admin/config/ai/context/settings/scope) |
getDescription() |
| A scope's own settings page | getDescription() |
| Context item add/edit form | getItemFormDescription() |
| Consumer subscription form | getSubscriptionDescription() |
getDescription() comes from the description parameter on the
#[AiContextScope] attribute, falling back to a string generated from the
label. getItemFormDescription() defaults to getDescription(), so a plugin
that does not override it behaves exactly as before.
Override getItemFormDescription() when guidance is specific to saving an
item and would be confusing elsewhere. The Global scope does this: its item
form warns that saving with Global enabled clears the item's other scope
values, which is meaningless on the overview page.
The overview and settings pages escape their text with Html::escape(), so
getDescription() must be plain text. getItemFormDescription() is rendered
as a form element #description, which Twig autoescapes like any other
printed value: a plain string gets Html::escape()'d, but a
\Drupal\Component\Render\MarkupInterface object (e.g. TranslatableMarkup)
is printed as-is, so returning one lets the description carry simple markup
such as <br>.
Value management
| Method | Return | Description |
|---|---|---|
getValues() |
array |
Raw value_id => label pairs from the plugin |
getAlteredValues() |
array |
getValues() after hook_ai_context_scope_values_alter(); use this when building forms, filters, or resolving labels |
allowsMultiple() |
bool |
Whether multiple values can be selected |
getSelectedValueLabels(array) |
array |
Labels for selected value IDs |
getValuesFromEntity(AiContextItem) |
array |
Effective scope values for an item — resolves parent inheritance (see Effective scope and parent inheritance) |
AiContextScopeCacheableMetadataInterface is an optional companion interface
for cached displays. It provides getAlteredValuesWithCacheability(),
getSelectedValueLabelsWithCacheability(),
getValuesCacheableMetadata(), and getAvailabilityCacheableMetadata().
AiContextScopeBase implements it and varies labels by interface language
and scope configuration by default. Override
getUnalteredValuesCacheableMetadata() when labels also depend on
entities or other configuration. Override
getAvailabilityCacheableMetadata() when isAvailable() depends on
config outside this scope's settings (Language adds
config:configurable_language_list). Direct
AiContextScopeInterface implementations remain valid without this
optional interface.
Admin summaries (buildScopeSummary(),
buildScopeSummaryFromStoredValues(), and the consumer-editor scope
column) collect that metadata onto the render array. The context item
listing does the same via getSelectedLabelsByScope(). The consumer
listing does the same via getSubscribedLabelsByScope(). Specific
Entities, Tags, and Taxonomy override the cacheable-label method so they
load only selected values.
Contextual detection
| Method | Return | Description |
|---|---|---|
getDetectedValue() |
?string |
Detected value from request context. Matching uses this method |
getMatchingIndexedValues(array) |
?string[] |
Indexed values that can situationally match the request; feeds the Relevant prefilter. Base returns NULL = cannot enumerate, Relevant falls back to a full scan; every shipped situational scope overrides it with its matcher's predicate |
matchesRequestContext(AiContextItem) |
?bool |
Whether item matches request context |
matchesRequestContext() is a tri-state contract:
TRUEmeans the item positively matches.FALSEmeans the hard filter rejects the item.NULLmeans the scope is neutral because the item is unrestricted.
Entity Types and Site Section fail closed: if the item has values but no
request entity or request path can be resolved, they return FALSE rather
than NULL. Roles also fails closed when the acting account cannot be
determined. When the user is allowed, Roles returns NULL rather than
TRUE so a role match never auto-includes the item. Other scopes may still
return NULL when the detected value cannot be determined.
The resolver removes an item when any enabled scope returns FALSE.
Capabilities
| Method | Return | Description |
|---|---|---|
supportsSubscriptions() |
bool |
Show on consumer subscription forms, participate in subscription scoring, preserve stored values when hidden |
supportsExactMatch() |
bool |
A strict matchesRequestContext() match enters the exact-match group (all modes, including Minimal), and indexed items are Minimal prefilter candidates |
supportsSituationalMatch() |
bool |
A strict matchesRequestContext() match enters the situational-match group in Relevant mode and above |
There is deliberately no hard-filter capability. The capabilities declare
what a positive match earns; a FALSE from matchesRequestContext()
is a hard reject in every mode, from every enabled and available scope,
and needs no declaration. A scope controls its own filtering behavior
through the tri-state return itself, per item and per context: Use Cases
and Context Tags never hard-filter because they never return FALSE,
Site Sections fails closed only when the item has section values and no
path can be resolved, and Roles is a hard filter only because it returns
NULL or FALSE, never TRUE.
The three capabilities are independent declarations (#3586419 split the
former dual-purpose supportsSubscriptions() flag):
supportsSubscriptions()gates exactly three behaviors: whenFALSE, the scope is hidden on consumer subscription forms (viaai_context.scope_subscription_formand the consumer editor) and listed in the form's "do not allow subscriptions" note, and the scope never contributes to subscription scores. Because the scope is hidden from the form, a save's submission contains nothing for it, so any stored subscription values (from before a capability change, a recipe, or an import) are copied back on save rather than misread as deleted; they stay inert while the scope does not support subscriptions.supportsExactMatch()defaults toFALSEonAiContextScopeBase: exact-match is opt-in, because a strict positive match injecting the item in every mode — including Minimal, with no subscription — is a strong behavior a scope must declare deliberately. Entity Item declaresTRUEand is the canonical exact-match scope; it is the only built-in that does. Global and Roles inherit the default (global items form their own group; a role match means the viewer may receive the item, never that the item is relevant, so Minimal does not load Roles-indexed items as candidates). A scope that supports both subscriptions and exact match promotes its strict matches above ordinary subscription scoring. Before #3586419, a scope returningsupportsSubscriptions(): FALSEwas implicitly exact-match eligible; such scopes must now declaresupportsExactMatch(): TRUE.supportsSituationalMatch()defaults toFALSE. Languages, Site Sections, Entity Types, and Taxonomy Terms returnTRUE; Context Tags deliberately does not. Situational matching runs against every loaded candidate item — the prefiltered set in Relevant, the full published catalog in Broad — so a situationalmatchesRequestContext()must be cheap: resolve the current value once and memoize it, and never load entities or resolve paths per item. A situational scope also feeds the Relevant prefilter throughgetMatchingIndexedValues(), which must use the same predicate asmatchesRequestContext()so the prefilter cannot drop an item the scan would match. The base implementation returnsNULL, which forces Relevant back to the full catalog scan — correct but slow — so every shipped situational scope overrides it. Shipped scopes are covered byAiContextMatchingIndexedValuesContractTrait; custom scopes must call that trait from their own tests. See custom scopes.
How Minimal and Relevant find candidate items (the prefilter)
Broad loads every published item and evaluates it in PHP — the documented full-catalog scan, needed for leftover budget fill. Minimal and Relevant do not: they first build a candidate ID list in SQL against the scope index (one row per item, scope, and value, maintained on item save), and only load those entities. The list obeys one law: it must be a superset of anything the mode could select. Over-including is cheap, because PHP re-checks every loaded item; an item missing from the list is invisible no matter how well it would have matched.
Three layers answer three different questions. The index answers
"what does each item claim": one row per item, scope, and value,
rewritten on every save regardless of publication status — an
unpublished item's rows sit in the index and cost nothing. The
published-ID list answers "what may this user see right now": an
access-checked entity query built per request, which every route
below intersects with, so status and permissions are never encoded
in the index. And matchesRequestContext() on the loaded survivors
answers "does it actually match": the prefilter only shrinks the
load set, it never grants inclusion.
The candidate set is the union of these routes (1–4 for Minimal; Relevant adds route 5):
- Global items — an index query for
globalrows. - Specific Entities matches — a query on the built-in
entity_itemsfield for references to the request entity (its own storage, so its own query). - Exact-match candidates — for every scope declaring
supportsExactMatch(), all items carrying any indexed value for it. SQL cannot evaluatematchesRequestContext(), so this route deliberately over-includes and lets PHP classify the real matches. This is what the capability costs: declare it only when a strict match is a genuine inclusion reason, or Minimal loads your indexed items just to discard them. - Subscription matches — items indexed under any participating
subscription (
supportsSubscriptions()isTRUEand the scope is enabled and available), with wildcard expansion viaAiContextScopeValueMatchingInterface. Languages, Roles, Global, and other non-subscription scopes are stripped first so a dormant language subscription cannot load language-only items into Minimal. When participating subscriptions exist, routes 1–3 still merge into the candidate set, so subscribing can never make Minimal drop an exact match that Relevant would keep. - Situational matches (Relevant only) — for every scope declaring
supportsSituationalMatch(), the distinct indexed values are passed togetMatchingIndexedValues(), which filters values (not items) against the current request, and items indexed under the values the plugin keeps become candidates. Plugins filter with the same predicate asmatchesRequestContext(), so pattern and wildcard values (Site Sections custom paths, Entity Types bundle_all, every pre-defined section matching the path) stay candidates. A plugin that returnsNULL(matches cannot be enumerated from indexed values, e.g. custom storage) makes Relevant fall back to the full catalog scan.
What this means for a capability declaration:
supportsExactMatch() creates prefilter candidacy in every mode.
supportsSituationalMatch() creates candidacy in Relevant through
getMatchingIndexedValues(); Broad needs no prefilter because it
scans the full catalog. No capability can create candidacy for values
stored outside the scope field: the prefilter finds items through
index rows, so a custom store has no entry point regardless of what
the plugin declares — a custom-storage situational scope keeps the
base NULL return from getMatchingIndexedValues() so its items
stay selectable in Relevant at full-scan cost (see
Storage model for custom scopes; index
participation for custom storage is a planned follow-up).
The prefilter machinery itself (AiContextScopeResolver,
AiContextScopeIndexService) is internal; this section describes the
behavior your capability declarations buy, not a query API to build on.
Wildcard and hierarchical matching
Most scopes use exact equality for subscription scoring and SQL
prefiltering. Scopes whose stored values can match more than one other
value should implement AiContextScopeValueMatchingInterface in addition
to AiContextScopeInterface:
| Method | Return | Description |
|---|---|---|
getMatchingSubscribedValues(array, array) |
string[] |
Subscribed values satisfied by the item values; used for scoring |
expandSubscribedValues(array) |
string[]\|null |
Extra keys a SQL index lookup must include so matching rows are not dropped. Return NULL when keys cannot be enumerated; Minimal and Relevant then fall back to the full catalog scan. |
The resolver uses exact intersection and unchanged index keys when a plugin does not implement this interface. Both methods must be implemented together so scoring and prefiltering stay consistent.
Entity Types is the built-in example: a type-level All value
(node:_all) matches every bundle of that type. Site Section path
wildcards do not use this interface; they are matched in
matchesRequestContext().
Do not write to the scope index table or call
AiContextScopeIndexService directly. Scope-field scopes are
indexed automatically with exact stored values.
Stale stored values
Most scopes have a fixed or admin-managed list. Scopes whose stored
options can disappear should implement
AiContextScopeStoredValueInterface in addition to
AiContextScopeInterface. The cleanup service collects stored values
and strips the stale subset; only the plugin decides what is stale.
| Method | Return | Description |
|---|---|---|
getStaleStoredValues(array) |
string[] |
Stored values that are no longer valid options |
shouldScrubAfterEntityDelete(EntityInterface) |
bool |
Whether this delete should scrub the scope |
getStaleStoredValuesForEntityDelete(EntityInterface) |
string[]\|null |
Specific IDs to strip, or NULL for a full scan |
shouldScrubAfterBundleDelete(string, string) |
bool |
Whether this bundle delete should scrub the scope |
shouldScrubAfterSettingsChange() |
bool |
Whether a save of this plugin's settings should scrub |
Language, Roles, Context Tags, and Entity Types treat a value as stale
when it is missing from getAlteredValues(). Site Section also keeps
custom: patterns. Taxonomy Terms keeps a value when the term still
exists and is not in the Context Tags vocabulary, or when an alter hook
added it. Use Case does not implement the interface: its options are
hardcoded and alterable.
Post-update 0006 uses an internal upgrade-only policy in the
cleanup service. Custom and settings-derived scopes, including Site
Section, are left in place. Entity Types drops only values whose
type or bundle is gone, via @internal
getObjectivelyMissingStoredValues(). Language, Roles, Tags, and
Taxonomy use getStaleStoredValues(). Disabled and unavailable
plugins are skipped.
A type-level All value (node:_all) stays valid after a bundle delete
while that type is still configured and exists: getValues() keeps the
All option for a configured type with no bundles. Removing the type
from Entity Types settings makes type:_all stale. Because the diff
runs against getAlteredValues(), a hook_ai_context_scope_values_alter()
implementation that removes node:_all also makes the stored value
stale on the next scrub.
Context Tags and Taxonomy Terms resolve stored values to terms. A
Context Tags value is stale when its term is gone, belongs to another
vocabulary, or was removed from the altered list. A serial term ID
left from before post-update 0013 follows the same rule through the
term it points at, so it is kept until the update converts it.
Entity and bundle deletes scrub a scope even while it is disabled or
unavailable; a plugin that returns NULL from
getStaleStoredValuesForEntityDelete() runs its full
getStaleStoredValues() scan in that state. Live and settings-change
scrubs skip disabled and unavailable scopes.
Term, language, and role deletes return that entity ID from
getStaleStoredValuesForEntityDelete() so a vocabulary delete is one
cheap strip per term, then one full scan when the vocabulary entity
itself is deleted. Module uninstall is also a trigger
(hook_modules_uninstalled): leftover values for a type, vocabulary,
or language whose providing module disappeared are scrubbed then.
Until that hook (or another trigger) runs, those values can linger.
Extension boundary for 1.0
Supported for custom scope plugins in 1.0:
- Implement
AiContextScopeInterface(viaAiContextScopeBase) - Store values through the base
getValuesFromEntity()/extractFormValues()flow (getScope()/setScope()) - Use documented hooks and alters
- Implement
AiContextScopeValueMatchingInterfacewhen stored values have wildcard or hierarchical matching - Implement
AiContextScopeStoredValueInterfacewhen stored options can disappear after a delete or settings change
Not a supported extension surface in 1.0:
- Custom entity-field storage (the built-in entity item scope is internal)
- Writing to the scope index, inventing a second cleanup path, or presave validation (scope-field values are indexed automatically)
- Injecting
plugin.manager.ai_context_scopefor orchestration (internal)
Built-in scope plugins expose a PLUGIN_ID constant matching their plugin ID.
Use those constants in module code when referring to built-in scopes; do not
hardcode scope ID strings in new code.
Storing scope values: two shapes
The scope field stores one assignment per delta:
$item = AiContextItem::create([
'label' => 'Writing guide',
'scope' => [
['scope_id' => 'use_case', 'value' => 'working_with_text'],
['scope_id' => 'language', 'value' => 'en'],
],
]);
Default Content and recipes use the same delta list:
scope:
-
scope_id: use_case
value: working_with_text
-
scope_id: language
value: en
getScope() / setScope() keep the grouped array used by scope plugins
and forms:
$item->setScope([
'use_case' => ['working_with_text'],
'language' => ['en'],
]);
$item->getScope();
// ['use_case' => ['working_with_text'], 'language' => ['en']]
Do not pass a grouped array to create() or $item->set('scope', …),
and do not pass deltas to setScope(). Either mismatch throws
InvalidArgumentException. Scope IDs are limited to 64 characters and
values to 255. Those values are stable identifiers on one site
(plugin value IDs, language codes, section IDs, custom: path keys,
taxonomy term UUIDs), not arbitrary payloads such as prose or rendered
content. Machine-name values and term UUIDs are portable across
environments. Specific Entities is UUID-safe in Default Content via
the entity_items dynamic entity reference field (entity: <uuid>).
A raw Specific Entities target_id copied between databases is not.
See
Consumer configuration — Export and import.
Site Section custom patterns are stored as custom: plus the path.
custom: uses 7 characters, so each custom pattern can be at most
248 characters. Longer URL structures should use a named Site Section
in settings; the item then stores only the short section ID.
Scope scoring
AiContextScopeResolver::calculateScopeScore() compares an item's effective
scope values with each non-empty subscription. Disabled scopes, empty
subscription arrays, and non-subscription scopes
(supportsSubscriptions() is FALSE, including Global, Roles,
Languages, and Specific Entities) are skipped.
For each participating scope:
- Its scoring influence is the plugin's
scoring_weightattribute (or the config override if a site builder set one). Values below 1 are treated as 1. - An item with no values for that scope receives no credit for it. An empty scope no longer earns automatic credit as generic context; it simply does not match that dimension (it can still earn credit from a different subscribed scope, or be picked up by situational-match or Broad mode).
- Otherwise, its credit is the number of subscribed values that match
the item values, divided by the number of subscribed values. Most
scopes use an exact intersection. Scopes that implement
AiContextScopeValueMatchingInterfacecan match more broadly. Entity Types treats a type-level All value (node:_all) as matching every bundle of that type. - The final score is the weighted credit divided by the maximum available weighted credit.
An item with a final score of zero is excluded from the scored group. A zero match for one scope does not necessarily exclude it because another scope can contribute positive credit.
The built-in scoring weights are currently:
- Use Cases: 100
- Context Tags: 50
- Site Sections: 50
- Entity Types: 50
- Taxonomy Terms: 50
These values are internal implementation details rather than a stable public API. Site builders can override them per scope without touching plugin code.
See Context Selection for the site-builder-facing explanation and examples.
Form building
| Method | Description |
|---|---|
buildValueForm(array $form, ...) |
Builds checkboxes/radios for scope values. The parent form is not modified. |
extractFormValues(mixed) |
Normalizes submitted form values |
buildSettingsForm(...) |
Builds per-scope settings form |
validateSettingsForm(...) |
Validates settings submission |
submitSettingsForm(...) |
Processes settings submission |
Configuration
Settings live in ai_context.scope_settings.{plugin_id}.
| Method | Return | Description |
|---|---|---|
defaultConfiguration() |
array |
Default settings (must include enabled) |
isEnabled() |
bool |
Whether the admin setting is enabled |
isAvailable() |
bool |
Whether the scope can be used in the current environment (Language is FALSE when the site has fewer than two languages) |
isEnabledAndAvailable() |
bool |
Public usable gate: isEnabled() && isAvailable(). Call this from forms and selection. |
Management links
| Method | Return | Description |
|---|---|---|
getManageRoute() |
?array |
Route info array (route_name, route_parameters) to manage scope values |
getManageLabel() |
?string |
Link text for the management link |
Both must be non-NULL or the manage link is omitted.
Base class: AiContextScopeBase
Namespace: Drupal\ai_context\Plugin\AiContextScope\AiContextScopeBase
Abstract base class providing default implementations for all interface
methods except getValues(). Extend this class for custom scope plugins.
Key defaults:
allowsMultiple()returnsTRUEsupportsSubscriptions()returnsTRUEsupportsExactMatch()returnsFALSE(opt-in; Entity Item overrides)supportsSituationalMatch()returnsFALSEisAvailable()returnsTRUE(override for runtime checks such as Language requiring more than one language)isEnabledAndAvailable()returnsisEnabled() && isAvailable()getAvailabilityCacheableMetadata()returns cache metadata for whether the scope is available. Override whenisAvailable()depends on external config such as Language addingconfig:configurable_language_list- Subclasses must implement
doGetDetectedValue()(returnNULLwhen the scope has no contextual auto-detection). That is the in-class hook; matching and the manager callgetDetectedValue() matchesRequestContext()usesgetDetectedValue()+getValuesFromEntity()buildValueForm()renders checkboxes (multiple) or radios (single) fromgetAlteredValues()getSelectedValueLabels()resolves labels throughgetAlteredValues()getValuesFromEntity()reads effective scope: it resolves throughAiContextItem::getScopeItem(), so a subcontext item that inherits its parent's scope returns the parent's values (see below)
Effective scope and parent inheritance
A subcontext item can inherit its parent's scope instead of storing its
own. When it does, its own stored scope is empty and the effective values live on
the parent. Resolution goes through AiContextItem::getScopeItem(), which
returns the parent for an inheriting child and $this otherwise.
The base getValuesFromEntity() already resolves this for you:
public function getValuesFromEntity(AiContextItem $item): array {
// Reads the parent's values when $item inherits parent scope.
return $item->getScopeItem()->getScopeValues($this->getPluginId());
}
If you override getValuesFromEntity() in a custom scope plugin, resolve
through getScopeItem() — do not read $item->getScopeValues() (or
$item->getScope()) directly. Reading the item's own stored scope bypasses
parent inheritance: for an inheriting child those values are empty, so your
scope would silently contribute nothing while every other scope still
reflects the parent's values, producing an inconsistent effective scope.
// Correct — honors parent inheritance.
public function getValuesFromEntity(AiContextItem $item): array {
$values = $item->getScopeItem()->getScopeValues($this->getPluginId());
// ...any custom post-processing...
return $values;
}
// Wrong — an inheriting child returns [] here even though its parent is scoped.
public function getValuesFromEntity(AiContextItem $item): array {
return $item->getScopeValues($this->getPluginId());
}
The distinction between effective and stored scope is intentional and mirrored across the entity API. Effective accessors resolve inheritance; stored accessors read the item's own field values (used by edit forms, scope summaries, and persistence limits):
| Effective (resolves inheritance) | Stored (this item only) |
|---|---|
getScopeItem()->getScopeValues() |
getScopeValues() / getScope() |
isGlobal() |
isStoredGlobal() |
getEntityItems() / hasEntityItems() |
getStoredEntityItemScopeValues() / hasStoredEntityItems() |
matchesEntityItem() |
— |
The scope index (AiContextScopeIndexService::indexItem()) also indexes
effective scope, so SQL prefiltering matches runtime selection.
Plugin manager: AiContextScopeManager
Namespace: Drupal\ai_context\AiContextScopeManager
Service ID: plugin.manager.ai_context_scope
Status: Internal — see API stability. Contrib and site code should not depend on manager orchestration methods. The subscription form builder service is the supported entry point for subscription UI.
Discovers and manages scope plugins. getScopePlugins() returns all plugins
in ascending display_weight order, with plugin ID used to break ties. Core and
custom plugins are interleaved according to their declared display_weight.
Call $plugin->isEnabledAndAvailable() from contrib; do not use the
manager's same-named helper. That helper is an internal wrapper
around the plugin method.
getSelectedLabelsByScope() collects an item's effective scope labels
for the context item listing. getSubscribedLabelsByScope() collects
the same style of labels from a scope_id => selected_values map. Both
skip disabled and unavailable scopes. buildScopeSummary() uses
getSubscribedLabelsByScope(). When a cacheability collector is passed,
both methods also call addAvailabilityCacheableMetadata() so listings
invalidate when a scope is enabled, disabled, or becomes unavailable.
addAvailabilityCacheableMetadata() collects cache tags from plugins
that implement the optional companion interface so listings and item
views invalidate when availability changes. getScopeIconClass() is
module-internal UI plumbing (definition metadata, built-in lookup, then default
fallback); contrib code should declare icon_class on custom scopes instead.
Scope plugin definitions can be altered via
hook_ai_context_scope_info_alter(); see Hooks.
Scope subscriptions on custom forms
When a module needs scope subscription widgets outside the consumer editor,
inject or load the public
ai_context.scope_subscription_form service
(AiContextScopeSubscriptionFormBuilderInterface). It wraps the internal
scope plugin manager and provides buildWidgets(), extractValues(), and
buildSummary() with the same behavior as the consumer editor. See
Services — ai_context.scope_subscription_form.