ADR 087 · Accepted
Input-side guardrails
Context
ADR-085 established the guardrail pipeline for provider responses: GuardrailMiddleware screens every non-streaming CompletionResponse and a guardrail returns an ALLOW / REDACT / DENY / RETRY / REQUIRE_APPROVAL verdict. It screens output only, and said so: the prompt payload is not on the ProviderCallContext (ADR-026 keeps the context payload-free), so a middleware cannot see — let alone rewrite — the outgoing messages.
The prompt is untrusted too. A user can paste a secret (an API key, a credential-bearing URL) that should not be forwarded verbatim to a third-party provider, and an operator may want to block a prompt on policy before spending a call.
Decision
An input guardrail is an ``InputGuardrailInterface`` whose checkInput() returns the same GuardrailResult verdict type as the output side. Implementers are auto-collected through the nr_llm.input_guardrail DI tag — separate from nr_llm.guardrail so an output-only guardrail (e.g. the provider content-filter, which reacts to a response attribute) is not forced to implement a meaningless input check.
Screening runs on the send path, not in the pipeline. An InputGuardrailScreener runs the input guardrails over the message list inside LlmServiceManager — before the pipeline, where the messages are reachable. This is the key difference from the output side: because the screener holds the payload, a REDACT verdict rewrites the prompt in place (a middleware could not). A DENY / REQUIRE_APPROVAL throws the same GuardrailViolationException / GuardrailApprovalRequiredException the output side uses, so a caller handles both identically; RETRY (re-ask the provider) has no meaning before the call and is ignored.
Screening is applied at the configuration-driven entry points — chatWithConfiguration, chatWithToolsForConfiguration, streamChatWithConfiguration — which is where chat() / streamChat() funnel once a default configuration resolves (ADR-034). The raw-string completeWithConfiguration (where complete() funnels) takes a prompt string rather than a message list, so it screens through a string variant that wraps, screens, and unwraps the prompt. For streaming, screening runs before the opener captures the messages, so a redaction reaches the provider and a DENY throws at call time, not on first iteration. The screener handles both typed ChatMessage and legacy array messages; a message with no string content (an assistant tool-call turn) passes through untouched.
SecretRedactionInputGuardrail ships as the reference implementation, applying the same secret masking to the prompt that SecretRedactionGuardrail applies to the response (shared via RedactsSecretsTrait). They are separate classes because a single class cannot implement both GuardrailInterface and InputGuardrailInterface — their TAG_NAME constants would collide.
Consequences
The prompt is screened and can be redacted before it leaves the extension — the input complement of ADR-085.
Guardrail execution is deliberately split: output in the pipeline (ADR-085), input on the send path (here). The alternative — threading the payload onto ProviderCallContext — was rejected to keep the context payload-free (ADR-026); the trade is two locations instead of one.
Screening covers both the configuration-driven surface and the ad-hoc pinned-provider-key path. The ad-hoc branches of chat() / complete() / chatWithTools() / streamChat() (an explicit provider, no DB configuration — the ADR-034 escape hatch) screen the prompt as well, so every message-carrying send path is covered.
Input guardrails support ALLOW / REDACT / DENY / REQUIRE_APPROVAL. RETRY is ignored on the input side.