ADR-106: Per-configuration guardrail policies
- Status
-
Accepted
- Date
-
2026-07-22
- Authors
-
Netresearch DTT GmbH
Context
Guardrails (ADR-085 output, ADR-087 input, ADR-088 streaming) are applied GLOBALLY: every tagged guardrail runs on every provider response. Different use-cases need different policies — a public-facing configuration wants the provider content filter, an internal tooling configuration may not — but there was no way to vary the set per configuration. The P1 roadmap asks for exactly that, without ever letting a configuration weaken the security-critical guardrails.
Decision
A configuration selects which OPTIONAL guardrails apply; MANDATORY guardrails (secret redaction) always run and are never selectable.
- Identity + classification. A shared
Guardrailinterface (parent of bothIdentity GuardrailandInterface Input) addsGuardrail Interface get(a stable slug, SHARED across the input and output sides — the two secret-redaction classes report the sameIdentifier () secret-) andredaction is(the per-class authoring signal). An abstract method, not a marker, so every implementer makes a PHPStan-checked conscious choice — a forgotten marker would default insecure.Mandatory () - Identifier-level authority, fail-closed.
Guardrailcollects both tagged iterators and computes mandatory-ness PER IDENTIFIER. Any side mandatory ⇒ the identifier is mandatory; a cross-side disagreement (mandatory on one side, optional on the other) THROWS at build — a copy-paste error that flips one secret-redaction class to optional fails the container rather than shipping a one-sided leak. TheRegistry Guardrailreads the registry's verdict, never a raw per-instance flag.Policy Resolver - The filter.
Guardraildrops a guardrail only when the configuration has a NON-EMPTY selection AND the identifier is not registry-mandatory AND not in the selection. A null configuration or an empty selection runs everything (unchanged from before this ADR); a mandatory guardrail is kept against ANY selection value (empty, partial, unknown, or all-unknown). One filter, applied at all three points:Policy Resolver:: filter () Guardrail(output),Middleware Input(input),Guardrail Screener Streaming(live redaction + end-of-stream audit) — each reading the configuration already in scope (Dispatcher Provider/ the streamed configuration), no re-plumbing.Call Context - Storage. A
allowed_CSV column onguardrails tx_(mirroringnrllm_ configuration allowed_) with atool_ groups selectTCA field whose items are DISCOVERED fromCheck Box Guardrail(optional-only; mandatory guardrails are never listed). The migration is additive, defaultedRegistry:: selectable Identifiers () NOT NULL DEFAULT ''— existing rows read''= run all, byte-identical to today.
Fail-closed rules
- A degenerate/empty schema of selectable ids never means "run nothing": the mandatory floor is kept unconditionally, and an all-unknown selection keeps exactly the mandatory set.
- A guardrail identifier is IMMUTABLE API — a rename silently drops the
guardrail for configurations that stored the old value (the mandatory floor is
unaffected; opted-in optional protections would silently stop). Introduce a
new guardrail instead; a genuine rename requires an Install Tool
Upgradethat rewrites storedWizard allowed_CSVs.guardrails
Consequences
- BREAKING API change.
Guardrail/Interface Inputare documented public extension points (theGuardrail Interface nr_/llm. guardrail nr_tags). Addingllm. input_ guardrail get+Identifier () isfatals an out-of-tree implementer at container compile. See Upgrading.Mandatory () Guardrailis public (Category E, for the TCA itemsProcFunc), raising the audited public-service count 35 → 36 (Adr101 remains the count authority and is updated in the same change).Registry - Content filter is optional.
provider-enforces the provider's own policy block (content- filter finish→ DENY), not secret leakage, so a configuration may select it out; secret redaction stays mandatory. Flip itsReason=content_ filter istoMandatory () trueif a deployment treats suppressing a provider safety block as a security concern — the registry then makes the identifier mandatory and drops it from the picker automatically. - Input axis is inert today. The only input guardrail is the mandatory
secret redaction, so per-config input filtering changes nothing yet. The
Inputaccepts the configuration and applies the same filter;Guardrail Screener Llmpasses no configuration for now. When an OPTIONAL input guardrail is added, thread the configuration at the config-bound entrypoints (a localised follow-up).Service Manager - Eval unaffected.
allowed_is a new column defaultingguardrails '', so every existing configuration (including eval targets) runs all guardrails exactly as before.
Upgrading
A third-party guardrail implementing Guardrail or
Input must add get (a stable kebab-case
slug) and is (true only for a security-critical always-on
guardrail). Input and output classes sharing a concept MUST return the same
identifier and the same is value, or the container fails closed.