Complete scope plugin persistence API: manager orchestration, target entity encapsulation, and plugin-driven admin/selection
## Description ### Summary The scope plugin API was asymmetric: read and match were abstracted, but write, clear, index, cleanup, form integration, and save-time validation leaked storage details into callers. `AiContextItemForm`, entity presave, `hook_entity_delete()`, and `AiContextScopeIndexService` contained special cases — especially the `target_entities` DER field — that would repeat for every new scope. This issue completes the scope plugin value lifecycle contract before RC1. Callers delegate to `AiContextScopeManager` and scope plugins; custom storage (target entity) is encapsulated in the plugin layer. ### Where this fits (scope-model work) This is the **foundation (plumbing) layer** of a three-part stack that cleans up how scopes behave and how that behavior is presented to users. It deliberately stops at the API/persistence boundary; the conceptual model and UX clarity build on top of it: 1. **This issue (#3586243) — API & persistence foundation.** Symmetric plugin lifecycle (read/write/clear/index/cleanup/validate), target-entity encapsulation, and **explicit capability flags** replacing the overloaded `supportsSubscriptions()` boolean. Plugin-driven admin/selector. **No new conceptual model, no behavior changes to which scopes participate in subscriptions, no UI/docs reframing.** 2. **#3586281 — inclusion semantics & UX.** Builds on the capability flags introduced here to add a per-item **inclusion mode** (`ranked` / `guaranteed_when_relevant`), the "Match any / Filter by" item-form grouping, the agent-form/docs clarity work, and any behavior change to remove contextual scopes from agent subscriptions (with migration). It must **not** reintroduce a `supportsSubscriptions()`-style overloaded signal; it consumes this issue's flags. 3. **#3586197 — boundaries / exclusions.** Adds per-scope `None / Include / Exclude` polarity. Depends on both the persistence contract here and the inclusion semantics in #3586281. In short: **#3586243 (this) → #3586281 → #3586197.** The capability flags below (`supportsAgentSubscriptions()`, `supportsContextAutoInclusion()`) are the seam the later issues extend; whether #3586281 also surfaces a derived "scope type" label is its decision, not this one's. --- ### Problem | Concern | Before | |---------|--------| | Detect/clear values on item | Map loops + `hasTargetEntities()` and other entity helpers | | Save from form | Manual `setScope()` / DER handling; `target_entity.extractFormValues()` returned `[]` | | Form UI | DER widget grafted in `AiContextItemForm` and `ai_context.module` | | Scope index | Read `$item->getScope()` only; `target_entity` not indexed | | Entity delete cleanup | Hardcoded tag vocabulary and DER paths in hooks | | Stale values | Reactive cleanup only; invalid IDs could persist until external events | | Global wipe | Magic `'global'` / `'target_entity'` string checks scattered in form and entity code | | Capabilities | Single overloaded `supportsSubscriptions()` boolean | | Admin UI / selector | Hardcoded `use_case` and `target_entity` columns and filter keys | --- ## Solution ### 1. Persistence contract and manager orchestration Extended `AiContextScopeInterface` and `AiContextScopeBase` with entity and form persistence methods. Added manager orchestration in `AiContextScopeManager`: - `getEnabledScopePlugins()`, `itemHasNonGlobalScopeData()`, `clearNonGlobalScopeData()` - `applyItemScopeFromForm()`, `applyCustomStorageFromForm()` - `submittedFormHasNonGlobalScopeData()`, `clearSubmittedNonGlobalScopeData()`, `handleGlobalScopeFormSubmission()` - `validateAndApplyItemScope()`, `getItemScopeValues()`, admin/selector helpers Presave scope validation and global strip moved from `AiContextItem::preSave()` to `AiContextItemStorage::doPreSave()` with injected scope manager. ### 2. Capability flags Replaced `supportsSubscriptions()` with: - `supportsAgentSubscriptions()` — agent config form visibility - `supportsContextAutoInclusion()` — context-auto-inclusion via resolver Updated `AiContextAgentForm` and `AiContextScopeResolver`. Removed `supportsSubscriptions()` (no deprecated alias — all in-module callers updated in this issue). Global scope detection uses `AiContextScopeGlobal::PLUGIN_ID` / `VALUE_GLOBAL` constants and `AiContextItem::isGlobal()` rather than a separate `isGlobalScope()` plugin method. > **Note for downstream issues:** these two flags are the canonical capability signals going forward. #3586281 should layer its inclusion-mode and any "scope type" presentation on top of them rather than adding a new overloaded boolean. ### 3. Target entity encapsulation Moved DER form logic, widget alters, and field integration into `AiContextScopeTargetEntity` and `AiContextTargetEntityReferenceWidget`. Removed target-entity special cases from `ai_context.module` and the item form build loop. Added `AiContextScopeTargetEntity::PLUGIN_ID`, item display / explicit-match interfaces, and plugin-driven admin list column for targets. ### 4. Index, cleanup, and stale-value validation - `getIndexableValues()` — `AiContextScopeIndexService` loops enabled plugins - `getCleanupValuesForDeletedEntity()` — `ai_context_entity_delete()` delegates to `AiContextScopeCleanupService` - `validateStoredValues()` — prune-on-save for dynamic scopes; wired via manager in storage presave ### 5. Admin list and selector refactor - `AiContextScopeAdminListColumnInterface` for plugin-declared list columns - `AiContextItemListBuilder` and `AiContextSelector` use plugin metadata instead of hardcoded scope IDs - `MapFormatter` refactored to scope plugin value API ### 6. Code organization - `ScopeMapPersistenceTrait` and `ScopeSettingsTrait` extracted from `AiContextScopeBase` - Removed deprecated entity helpers (`getTagIds()`, `getDescriptionMarkup()`, etc.) - Interface documented in sections in `scope_api.md`, `custom_scopes.md`, and `docs/features/scopes.md` ### 7. Global scope wipe UX When an item is saved as global with existing non-global scope data: - Status message: *"Other scope criteria were removed because this item is marked as global context."* - Map-backed scopes and `target_entities` cleared on save via manager + storage presave --- ## Design principles - **Default storage:** scope map field (`scope[plugin_id] => string[]`) - **Custom storage:** `target_entity` overrides persistence + form methods (DER field retained) - **Single interface:** one `AiContextScopeInterface` with section comments; no sub-interfaces - **Base class + traits:** custom scope authors extend `AiContextScopeBase` only - **Foundation only:** this issue changes *how* scope behavior is expressed and persisted, not *what* the behavioral model is — that's #3586281. --- ## Out of scope - Migrating `target_entity` storage from DER field into the scope map - Multi-vocabulary taxonomy scope plugin (separate follow-up; depends on this) - Splitting `AiContextScopeInterface` into sub-interfaces - **Per-item inclusion mode (`ranked` / `guaranteed_when_relevant`)** — #3586281 - **Removing contextual scopes (Language, Site section, Entity bundle) from agent subscriptions, and the related migration** — #3586281 - **"Match any / Filter by" item-form grouping and agent-form/docs clarity** — #3586281 - **Boundaries / exclusions (`None / Include / Exclude` per scope)** — #3586197 --- ## Acceptance criteria - [ ] Single `AiContextScopeInterface` with documented sections - [ ] Explicit capability flags (`supportsAgentSubscriptions()`, `supportsContextAutoInclusion()`); agent form and resolver updated; no remaining `supportsSubscriptions()` references - [ ] No caller outside scope plugins references `target_entities` or hardcoded `'target_entity'` checks (except field definition and plugin) - [ ] `AiContextItemForm` scope build loop delegates uniformly to plugins (global limit warnings remain on form — see follow-up) - [ ] Global wipe, validation, index, and cleanup via manager + plugin methods - [ ] Stale dynamic scope values pruned on save with kernel tests - [ ] `./lint.sh` passes (PHPCS, PHPStan, stylelint, eslint) - [ ] Developer docs updated - [ ] 302 kernel tests pass (4868 assertions) --- ## Test coverage **Added/updated:** - `AiContextScopePersistenceTest`, `AiContextScopeManagerOrchestrationTest` - `AiContextScopeStaleValueValidationTest` - `AiContextScopeTargetEntityTest` (persistence, display, settings) - `AiContextItemGlobalScopeFormTest` (global wipe message + scope/target clear on save) - `AiContextScopeDiffIntegrationTest`, `MapFormatterTest`, manager/list/selector tests **Substituted / deferred:** - Functional global-wipe browser test → kernel form test (`AiContextItemGlobalScopeFormTest`) because functional suite blocked by [#3579372](https://www.drupal.org/node/3579372) (`node_make_sticky_action` plugin missing) - Dedicated functional tests for capability flags on agent form / resolver — follow-up - Explicit kernel test for target entity rows in scope index — follow-up --- ## Known follow-ups (separate issues or post-merge) 1. **Multi-vocabulary taxonomy scope plugin** — depends on this landing 2. **Move global item limit warnings** from `AiContextItemForm` into `AiContextScopeGlobal` (or plugin form hook) 3. **Functional tests** for global wipe, agent subscriptions, and context auto-inclusion once [#3579372](https://www.drupal.org/node/3579372) is fixed 4. **Kernel test:** scope index includes `target_entity` via `getIndexableValues()` 5. **Optional hardening:** capture pre-validate entity state for global wipe `$original` in `AiContextItemForm::submitForm()` 6. **Incomplete functional tests** in `AiContextScopeSettingsTest` (3× `markTestIncomplete`) — pre-existing --- ## Related - **Builds toward:** #3586281 (inclusion semantics + UX; consumes this issue's capability flags) → #3586197 (boundaries / exclusions) - **Decision record:** #3586196 ([Discuss] scope model) - Global scope wipe #3586214 - Stale scope values #3586145 - Test environment: #3579372 (`node_make_sticky_action`) - Blocks: multi-vocabulary taxonomy scope plugin #3586237 --- ## AI usage - [x] AI assisted issue - [x] AI generated code
issue