Migrate external moderation config to guardrail sets and deprecate the moderation runner
## Goal Migrate existing **external moderation** configuration into the **Guardrails** system and retire the bespoke moderation runner. Ship an **update hook** that converts each `ai.external_moderation` entry into a guardrail set (using the **Moderation Guardrail** plugin) registered as a **global guardrail scoped to the entry's provider + tags**, then deprecate `ModeratePreRequestEventSubscriber` and the `ai.external_moderation` config object. This is the data-migration + cleanup half of folding external moderation into Guardrails. The plugin itself is created separately. **Blocked by:** - **#3586531** — the `moderation_guardrail` plugin must exist before config can be migrated onto it. - **#3586529** — global guardrails must be scopable by provider id / tags before moderation config can be migrated without changing behaviour. ## Background External moderation was copied out of the deprecated `ai_external_moderation` submodule into AI Core (#3479913). Today it lives entirely in core as a parallel mechanism to Guardrails: - **Config:** `ai.external_moderation` → key `moderations` (schema `type: ignore`). Each entry is `{ provider: <chat provider id>, models: ["<providerId>__<modelId>", …], tags: "<comma,separated>" }`. - **Runtime:** `src/EventSubscriber/ModeratePreRequestEventSubscriber.php` subscribes to `PreGenerateResponseEvent`, matches entries by the request's **provider id** and **tags** (`matchConfigs()`), runs each configured moderation model via `$provider->moderation($input, $model_id)->getNormalized()->isFlagged()`, and throws `AiUnsafePromptException` when flagged. So moderation is currently: **pre-request only, input only, hard-stop, scoped by provider + tags**, invoked through the `moderation` operation type. Once #3586531 lands, a single configurable `moderation_guardrail` plugin can reproduce this behaviour as a guardrail (returning `StopResult` instead of throwing). This issue moves the *existing site configuration* onto that plugin and removes the old mechanism, so there is one system gating AI requests rather than two. This issue is the core-module sibling of the broader 2.0 consolidation work and pairs with the naming discussion in #3586471. ## Proposed approach ### 1. Update hook: migrate config → guardrail sets → global Add an update hook (e.g. `ai_update_N()` in `ai.install`) that reads `ai.external_moderation:moderations` and, for each entry: 1. creates one `ai_guardrail` config entity per moderation model (plugin `moderation_guardrail`, settings = provider/model); 2. groups the entry's guardrails into an `ai_guardrail_set` (label derived from provider/tags), placed in `pre_generate_guardrails` with a sensible `stop_threshold`; 3. registers the new set as a **global guardrail scoped to the entry's provider + tags** (per #3586529), preserving the current `matchConfigs()` semantics rather than applying moderation to every request; 4. once migrated, removes `ai.external_moderation` and the `ModeratePreRequestEventSubscriber` service (deprecate first if BC requires). This continues the migration path begun by `ai_external_moderation_update_10001` (which moved `ai_external_moderation.settings` → `ai.external_moderation`); the new hook is the next hop: `ai.external_moderation` → guardrail sets. ### 2. Deprecate the old runner After the update hook, `ModeratePreRequestEventSubscriber`, the `ai.external_moderation` config object/schema, and the moderation admin form become redundant and should be deprecated/removed per the 2.0 deprecation policy. ## Resolved decisions * **Scope mismatch** → current provider + tag scoping must be preserved. Global guardrails gain provider/tag conditions in **#3586529** (blocker). The update hook maps each moderation entry onto a guardrail set scoped to that entry's provider/tags. * **Plugin dependency** → the migration targets the `moderation_guardrail` plugin from **#3586531** (blocker); this issue does not define the plugin. * **Stop semantics** → migrated guardrails return a `StopResult` (with `stop_threshold`); the old `AiUnsafePromptException` path is dropped, no compatibility shim. ## Resources * Blocker (plugin): #3586531 (Moderation Guardrail plugin) * Blocker (scoping): #3586529 (scope global guardrails by provider/tags) * External moderation → core: #3479913 * Guardrails naming: #3586471 · Guardrails on Automators: #3586447 · During-generate modes: #3586491 * Current runner: `src/EventSubscriber/ModeratePreRequestEventSubscriber.php` * Current config: `ai.external_moderation` (key `moderations`) * Guardrail config entities: `src/Entity/AiGuardrail.php`, `src/Entity/AiGuardrailSet.php` * Global guardrails: `ai.settings:global_guardrails`, `src/EventSubscriber/GlobalGuardrailsEventSubscriber.php`, `src/EventSubscriber/GuardrailsEventSubscriber.php` ## Decision <!--Fill in before closing: summarise what was decided and the key reason. Leave empty until resolved.-->
issue