ADR 086 · Accepted
Guardrail enforcement gaps
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), and the AgentRun row reflects it rather than a spurious failure.
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.