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