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