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: Guardrail (priority 115)
screens every non-streaming Completion and, on a DENY or
REQUIRE_APPROVAL verdict, throws Guardrail /
Guardrail. 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
ToolandApproval Required Exception Budget; a guardrail verdict fell through to the genericExceeded Exception catchand returned an HTTP 500, so a policy outcome looked like a server error and the run row was recorded as a crash.(Throwable) - Streaming. A streamed response cannot go through the pipeline — a lazy
generator can't be the pipeline terminal (ADR-062) — so
Guardrailnever runs on streamed output. Streamed responses were an unscreened, unaudited blind spot.Middleware
Decision
Playground: a guardrail verdict is a policy outcome, not a 500. The
Tool catches Guardrail and
Guardrail before the generic Throwable in all
three run paths (batch run, resume, streamed stream).
The run is settled as blocked (so it is not left RUNNING) and a clean payload is
returned — HTTP 200 with success: and a status of
guardrail_ (DENY) or guardrail_
(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. Streaming collects
the guardrail iterator (nr_) and, once a stream drains
successfully, screens the assembled completion via check 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_, 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
Agentrow 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.Run - 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.