ADR-098: Input-guardrail screening for specialized prompts
- Status
-
Accepted
- Date
-
2026-07-21
- Authors
-
Netresearch DTT GmbH
Context
The chat send path screens an outgoing message list through the input
guardrails before it reaches a provider (ADR-087), so a REDACT
verdict rewrites a secret out of the prompt and a DENY / REQUIRE_APPROVAL throws
before the call. The output guardrails run inside the pipeline
(ADR-085), but they only ever see a model-generated
CompletionResponse — the prompt payload is not reachable there, which is why
input screening runs on the send path.
The specialized services — DALL·E, FAL, TTS, DeepL — now route their dispatch
through the pipeline (ADR-097), but the pipeline still cannot
screen their input: a specialized call sends a prompt string, not a
CompletionResponse, and a middleware could not rewrite that prompt back into
the terminal closure a REDACT verdict requires. So the same user-supplied text
that a chat message would have had screened reached the image / speech /
translation providers unscreened.
Decision
InputGuardrailScreener gains screenText(string): string — the single-
string sibling of screen(array $messages) — sharing one private
screenContent() loop so both apply identical guardrails and verdict handling.
AbstractSpecializedService takes the InputGuardrailScreener as a required
dependency (after pipeline) and exposes `protected screen`. Each service screens its user-supplied prompt on the send path, before
the request payload is built:
- DALL·E —
generate,generateMultiple(DALL·E 2 branch; the DALL·E 3 branch screens via its delegation togenerate()),edit. - FAL —
generate,generateMultiple. - TTS —
synthesize(synthesizeToFile/synthesizeLongdelegate to it, so every chunk is screened). - DeepL —
translate,translateBatch(each element).
Whisper is out of scope: its payload is audio, not a prompt. The optional transcription-hint field is not screened here — if that becomes a concern it is its own step.
Consequences
- Breaking:
AbstractSpecializedServicegained a requiredInputGuardrailScreenerconstructor parameter (afterpipeline, before the optional repositories). A subclass or manual construction must pass it; anInputGuardrailScreener([])with no guardrails is a valid pass-through for tests that do not exercise screening. Required, not optional, for the same reason as the budget gate (ADR-078): a secret / injection screener that silently disappears when unwired is fail-open on exactly the control it provides. - A specialized prompt is now screened identically to a chat prompt: a REDACT verdict rewrites the text that is sent (and, where the service echoes the prompt back in its result, what it echoes), and a DENY / REQUIRE_APPROVAL throws the same typed exception before any spend — every service screens the prompt before its budget pre-flight, so a denied prompt costs nothing: no budget-aggregation queries, no dispatch. A REDACT verdict that carries no replacement text fails closed (throws) rather than passing the original through, on the specialized and the chat path alike.
- Production wiring is unchanged: DI autowires the concrete
InputGuardrailScreenerinto every specialized service. With no input guardrails tagged,screenText()is a pass-through and behaviour is identical to before. - Still deferred: the fail-closed dispatch seam (
getSecureClient()throwing when no lifecycle context is active); and folding the per-service usage recording into a tagged pipeline extractor.