---
title: "ADR-086: Guardrail enforcement gaps — playground verdicts and streamed output"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-086@0.35"
source: "Adr/Adr086GuardrailEnforcementGaps.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-086: Guardrail enforcement gaps — playground verdicts and streamed output

-   *Status:* Accepted
-   *Date:* 2026-07-18
-   *Authors:* Netresearch DTT GmbH

## Context

ADR-085 added the guardrail pipeline: `GuardrailMiddleware` (priority 115)
screens every non-streaming `CompletionResponse` and, on a DENY or
REQUIRE_APPROVAL verdict, throws `GuardrailViolationException` /
`GuardrailApprovalRequiredException`. Two gaps remained:

-   **Tool playground.** The tool loop (ADR-084) calls the provider through the
    same pipeline, so those guardrail exceptions can surface mid-run. The
    playground caught only `ToolApprovalRequiredException` and
    `BudgetExceededException`; a guardrail verdict fell through to the generic
    `catch (Throwable)` and returned an HTTP 500, so a *policy* outcome looked
    like a server error and the run row was recorded as a crash.
-   **Streaming.** A streamed response cannot go through the pipeline — a lazy
    generator can't be the pipeline terminal (ADR-062) — so `GuardrailMiddleware`
    never runs on streamed output. Streamed responses were an unscreened,
    unaudited blind spot.

## Decision

**Playground: a guardrail verdict is a policy outcome, not a 500.** The
`ToolPlaygroundController` catches `GuardrailViolationException` and
`GuardrailApprovalRequiredException` before the generic `Throwable` in all
three run paths (batch `runAction`, `resumeAction`, streamed `streamRun`).
The run is settled as blocked (so it is not left RUNNING) and a clean payload is
returned — HTTP 200 with `success:false` and a `status` of
`guardrail_blocked` (DENY) or `guardrail_approval_required`
(REQUIRE_APPROVAL), naming the deciding guardrail and its reason. The streamed
path emits the same as a terminal event instead of the generic `error`.

A full response-release resume for REQUIRE_APPROVAL (persisting the flagged
response and re-delivering it on approval, mirroring ADR-084 for a completion
rather than for pending tool calls) is **not** built here: the exception carries
only the guardrail and reason, not a resumable state. The verdict is surfaced
and recorded; releasing an approved response is a separate future step.

**Streaming: end-of-stream guardrail audit.** `StreamingDispatcher` collects
the guardrail iterator (`nr_llm.guardrail`) and, once a stream drains
successfully, screens the assembled completion via `checkOutput()` and records
any non-ALLOW verdict (a structured `warning`). This is an **audit**, not
enforcement: the chunks have already been yielded to the caller, so a DENY or
REDACT cannot retract or live-redact them. The buffer is bounded
(`MAX_GUARDRAIL_BUFFER_BYTES`, 50 000 bytes) so a pathologically long stream
keeps streaming's memory benefit; a model echoing a secret does so near where it
was given, so the leading window is enough to screen. Screening is fail-soft — a
broken guardrail never turns a delivered stream into an error.

## Consequences

-   A guardrail decision in the playground is a clean, distinguishable outcome
    (blocked vs flagged-for-approval). The `AgentRun` row reflects it rather
    than a spurious failure since [ADR-092](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-092@0.35), which stores the
    verdict as the run's termination reason — until then the row was written
    through the generic failure path and was indistinguishable from a crash.
-   Streamed output is no longer a guardrail blind spot: a leaked secret or a
    policy trip is recorded for audit even though it cannot be un-sent.
-   Live protection of a stream (redacting a secret *before* the chunk is sent, or
    denying mid-stream) needs a delta-oriented guardrail contract and per-chunk
    buffering — deliberately out of scope here and tracked as a follow-up.
-   Input-side screening (redacting or blocking the outgoing prompt) is a separate
    change on the send path, tracked in its own ADR.
