Scope global guardrails by request provider id, model and/or tags (conditional global guardrail sets)
## Summary
Allow guardrail sets — and their registration as **global guardrails** — to be **conditionally scoped by request provider id, model id and/or tags**, instead of always applying to every AI request. Today `ai.settings:global_guardrails` are attached unconditionally by `GlobalGuardrailsEventSubscriber` to every `PreGenerateResponseEvent`.
## Problem
Global guardrails are all-or-nothing: every set listed in `ai.settings:global_guardrails` runs on every request, regardless of which provider is being called, which model is being requested, or what tags the request carries.
This blocks a clean consolidation of **external moderation** into Guardrails (#3586528). The current moderation runner (`ModeratePreRequestEventSubscriber`) does **not** apply to every request — its `matchConfigs()` matches a moderation entry to a request only when:
- the request's **provider id** equals the entry's `provider`, and
- the entry's **tags** intersect the request's tags (empty tags = all).
If we migrate moderation config straight to unconditional global guardrails, we silently change behaviour: a moderation set configured for one provider (or one tagged code path) would start running on *all* providers and *all* requests. To preserve current semantics, guardrail sets need at least the same provider/tag scoping before #3586528's update hook can map moderation config onto them.
Beyond preserving moderation parity, site builders increasingly need **per-model** scoping: a guardrail that is only meaningful for one model (e.g. an unmoderated/open-weight model, a reasoning model, or a model with a specific safety profile) should be able to apply *only* when that model is the one being called — without firing on every other model the same provider exposes. Provider-level scoping alone is too coarse for this.
Who benefits: site builders who scope moderation/guardrails to specific providers, specific models, or specific call sites (via tags), and anyone migrating from external moderation who expects their existing scoping to be preserved.
## Proposed solution *(optional)*
Add optional **conditions** to a guardrail set's global application, extending `matchConfigs()` with a model condition:
- a **provider** condition (match one or more provider ids; empty = any),
- a **model** condition (match one or more model ids; empty = any), and
- a **tags** condition (match if the request tags intersect; empty = any).
All three conditions are AND-ed together; within a single condition the listed values are OR-ed (any match). An empty condition means "any", so a set with no conditions behaves exactly like today's unconditional global guardrail — full backward compatibility.
The `PreGenerateResponseEvent` already exposes everything needed via `AiProviderRequestBaseEvent`: `getProviderId()`, `getModelId()` and `getTags()`. So the match check is a straightforward extension of the moderation logic — note that moderation's `matchConfigs()` only gates on provider + tags today; **model matching is new** and does not exist for external moderation.
**Model identity / ambiguity:** model ids are only unique *within* a provider, so a bare model id is ambiguous across providers. Two reasonable encodings:
- store model as a `provider__model` pair (the same `provider__model` convention the moderation `config['models']` list already uses), or
- treat the model condition as scoped by the provider condition on the same set (model only checked when a provider condition is also present, or matched as provider id + model id together).
Recommend the `provider__model` encoding for the stored condition so a model can be targeted without a separate provider entry, and so it reads consistently with existing moderation config.
Storage options to decide:
1. Store the conditions on the `ai_guardrail_set` config entity (new `conditions`/`provider`/`model`/`tags` keys + schema), and have `GlobalGuardrailsEventSubscriber` evaluate them against the `PreGenerateResponseEvent` before attaching the set.
2. Or store the conditions in the global-guardrails registration itself (turn `ai.settings:global_guardrails` from a flat `sequence` of ids into a mapping of `{ set_id, provider, model, tags }`), leaving the set entity provider/model-agnostic and reusable.
Recommendation: **option 2** keeps guardrail sets reusable and puts the "where does this apply" decision at the point of global registration — but flagging for maintainer preference.
Either way, `GlobalGuardrailsEventSubscriber::attachGlobalGuardrails()` gains a match check (provider id + model id + tag intersection) that supersets `ModeratePreRequestEventSubscriber::matchConfigs()`.
## Workaround *(optional)*
None for global application. A set can only be attached unconditionally (global) or manually per call site (`AiGuardrailHelper::applyGuardrailSetToChatInput()`); there is no built-in provider/model/tag gating today. Per-model gating in particular has no workaround short of a custom event subscriber.
## Affected modules / components *(optional)*
aiCoreModule (Guardrails): `src/EventSubscriber/GlobalGuardrailsEventSubscriber.php`, `src/Entity/AiGuardrailSet.php`, `ai.settings:global_guardrails` schema. Match logic reads `PreGenerateResponseEvent::getProviderId()` / `getModelId()` / `getTags()` (inherited from `AiProviderRequestBaseEvent`).
Relates to #3586528 (this is a blocker for it) and the moderation `matchConfigs()` logic in `src/EventSubscriber/ModeratePreRequestEventSubscriber.php`.
issue