ADR 085 · Accepted

Guardrail pipeline for provider responses

Context

nr-llm has good, targeted safety mechanisms (skill/tool egress, budgets, secret sanitisation on logged errors), but no general content-policy layer over a provider response. LLM output is untrusted content, and any editor-facing or autonomous use needs to be able to inspect it, rewrite it, block it, or route it for review — with a richer answer than a boolean.

Decision

A guardrail is a `GuardrailInterface` whose checkOutput() returns a GuardrailResult verdict — ALLOW, REDACT, RETRY, REQUIRE_APPROVAL, or DENY. Guardrails are auto-collected through the nr_llm.guardrail DI tag (the same pattern as nr_llm.tool), so a new guardrail is active simply by existing under Classes/.

`GuardrailMiddleware` runs them in the existing ADR-026 provider pipeline, at priority 115 — outermost, above Telemetry (110) (superseded — see the 2026-07-19 update under Consequences: the guardrail moved to priority 90, inside the persistence layers). It screens every non-streaming CompletionResponse after the downstream chain produces it:

  • ALLOW passes on; REDACT rewrites the content and keeps screening (a later guardrail may still deny); DENY throws GuardrailViolationException; REQUIRE_APPROVAL throws GuardrailApprovalRequiredException; RETRY re-asks the provider once and re-screens the fresh response (capped at one retry).

  • It sits above Telemetry deliberately: a guardrail denial is a policy outcome, not a provider failure, so provider telemetry stays accurate.

  • Non-CompletionResponse results (embeddings, vision, raw payloads) pass through untouched.

Two reference guardrails ship active: SecretRedactionGuardrail (REDACT — masks secret-shaped strings a model may have echoed, reusing ErrorMessageSanitizerTrait plus API-key/Bearer patterns) and ProviderContentFilterGuardrail (DENY — turns a silent content_filter response into an explicit, catchable denial).

Consequences

Update 2026-07-19 — corrected pipeline placement (priority 115 → 90). Placing the guardrail outermost meant it redacted the response only AFTER IdempotencyMiddleware (105) had already serialised the unredacted CompletionResponse into the nrllm_idempotency cache (24h, possibly a shared backend) — so a model that echoed a secret leaked it into persistence even though the caller saw the redacted value. The guardrail now runs at priority 90, INSIDE both persistence layers (Idempotency 105, Cache 100) and above the behavioural stack, so it redacts (or blocks) before anything is stored; a DENY/REQUIRE_APPROVAL throws before the store, so a blocked response is never a replayable result. Telemetry accuracy is preserved differently now that the guardrail sits inside Telemetry (110): the two guardrail exceptions implement a GuardrailPolicyException marker, and TelemetryMiddleware records such a policy outcome as a successful provider run (the provider produced a response; the guardrail refused to release it) — so a denial still does not distort the provider failure-rate. A side benefit: a RETRY now genuinely re-runs the provider instead of replaying the idempotency-cached response.

  • Consumers get allow/redact/deny/retry/require-approval over model output, not true/false. A denial is a typed, catchable exception (mirroring BudgetExceededException).

  • Behaviour change: a provider content_filter response now raises GuardrailViolationException instead of returning silently — the degraded/empty response surfaces rather than passing through.

  • Output only, for now. This screens the response. Screening the prompt (input redaction / injection detection) is a separate step: the prompt payload is captured in the pipeline's terminal closure, not on the immutable ProviderCallContext, so input guardrails require threading the messages through the context first — a scoped follow-up, not built here.

  • Streaming is not covered. StreamingDispatcher bypasses the middleware pipeline (ADR-062), so streamed responses are not screened. Covering them is a separate integration in the streaming path.

  • The REQUIRE_APPROVAL verdict is the seam to human review: on a plain completion it raises GuardrailApprovalRequiredException (distinct from a denial) so a consumer with a run/review context (the human-in-the-loop epic, ADR-084, or a review queue) can route it to approval instead of an error.

← All ADRs