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 |
|---|---|---|
getId() |
string |
Plugin ID |
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 |
isDynamic() |
bool |
Whether values come from external source |
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(), buildScopeSummaryFromEntity(),
and the consumer-editor scope column) collect that metadata onto the render
array. The context item listing already does the same via
getSelectedLabelsByScope(). 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 |
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 and Relevant and Broad scan the full published catalog, so a situationalmatchesRequestContext()must be cheap: resolve the current value once and memoize it, and never load entities or resolve paths per item.
How Minimal finds candidate items (the prefilter)
Relevant and Broad load every published item and evaluate it in PHP — the documented full-catalog scan. Minimal does not: it first builds a candidate ID list in SQL against the scope index (one row per item, scope, and value, maintained on item save), and only loads those entities. The list obeys one law: it must be a superset of anything Minimal could select. Over-including is cheap, because PHP re-checks every loaded item; an item missing from the list is invisible to Minimal no matter how well it would have matched.
The candidate set is the union of four routes:
- 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.
What this means for a capability declaration:
supportsExactMatch() is the only capability that creates prefilter
candidacy. supportsSituationalMatch() deliberately does not —
situational matching runs in Relevant and above, which scan the full
catalog, so there is nothing to prefilter. And 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 (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; the prefilter then keeps all candidates. |
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.
A type-level All value (node:_all) stays valid after a bundle delete
while that type is still enabled. Removing the type from Entity Types
settings makes type:_all stale.
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 (plugin value
IDs, language codes, section IDs, custom: path keys), not arbitrary
payloads such as prose or rendered content.
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(...) |
Builds checkboxes/radios for scope values |
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) |
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 |
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()returnsTRUEisDynamic()returnsFALSEsupportsSubscriptions()returnsTRUEsupportsExactMatch()returnsFALSE(opt-in; Entity Item overrides)supportsSituationalMatch()returnsFALSEisAvailable()returnsTRUE(override for runtime checks such as Language requiring more than one language)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) 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->getId());
}
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->getId());
// ...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->getId());
}
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.
isEnabledAndAvailable() is an internal helper that combines
isEnabled() and isAvailable() for forms and selection.
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.