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
ToolApprovalRequiredExceptionandBudgetExceededException; a guardrail verdict fell through to the genericcatch (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
GuardrailMiddlewarenever 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
AgentRunrow reflects it rather than a spurious failure since ADR-092, 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.