Skip to content

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_weight controls the order of this scope in forms, tabs, and listings. Lower values appear first, matching Drupal's #weight convention. 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_weight controls 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:

  • TRUE means the item positively matches.
  • FALSE means the hard filter rejects the item.
  • NULL means 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: when FALSE, the scope is hidden on consumer subscription forms (via ai_context.scope_subscription_form and 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 to FALSE on AiContextScopeBase: 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 declares TRUE and 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 returning supportsSubscriptions(): FALSE was implicitly exact-match eligible; such scopes must now declare supportsExactMatch(): TRUE.
  • supportsSituationalMatch() defaults to FALSE. Languages, Site Sections, Entity Types, and Taxonomy Terms return TRUE; Context Tags deliberately does not. Situational matching runs against every loaded candidate item and Relevant and Broad scan the full published catalog, so a situational matchesRequestContext() 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:

  1. Global items — an index query for global rows.
  2. Specific Entities matches — a query on the built-in entity_items field for references to the request entity (its own storage, so its own query).
  3. Exact-match candidates — for every scope declaring supportsExactMatch(), all items carrying any indexed value for it. SQL cannot evaluate matchesRequestContext(), 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.
  4. Subscription matches — items indexed under any participating subscription (supportsSubscriptions() is TRUE and the scope is enabled and available), with wildcard expansion via AiContextScopeValueMatchingInterface. 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 (via AiContextScopeBase)
  • Store values through the base getValuesFromEntity() / extractFormValues() flow (getScope() / setScope())
  • Use documented hooks and alters
  • Implement AiContextScopeValueMatchingInterface when stored values have wildcard or hierarchical matching
  • Implement AiContextScopeStoredValueInterface when 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_scope for 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:

  1. Its scoring influence is the plugin's scoring_weight attribute (or the config override if a site builder set one). Values below 1 are treated as 1.
  2. 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).
  3. 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 AiContextScopeValueMatchingInterface can match more broadly. Entity Types treats a type-level All value (node:_all) as matching every bundle of that type.
  4. 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)
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() returns TRUE
  • isDynamic() returns FALSE
  • supportsSubscriptions() returns TRUE
  • supportsExactMatch() returns FALSE (opt-in; Entity Item overrides)
  • supportsSituationalMatch() returns FALSE
  • isAvailable() returns TRUE (override for runtime checks such as Language requiring more than one language)
  • getAvailabilityCacheableMetadata() returns cache metadata for whether the scope is available. Override when isAvailable() depends on external config such as Language adding config:configurable_language_list
  • Subclasses must implement doGetDetectedValue() (return NULL when the scope has no contextual auto-detection)
  • matchesRequestContext() uses getDetectedValue() + getValuesFromEntity()
  • buildValueForm() renders checkboxes (multiple) or radios (single) from getAlteredValues()
  • getSelectedValueLabels() resolves labels through getAlteredValues()
  • getValuesFromEntity() reads effective scope: it resolves through AiContextItem::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.