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 screenPrompt(string): string`. Each service screens its user-supplied prompt on the send path, before the request payload is built:

  • DALL·Egenerate, generateMultiple (DALL·E 2 branch; the DALL·E 3 branch screens via its delegation to generate()), edit.
  • FALgenerate, generateMultiple.
  • TTSsynthesize (synthesizeToFile / synthesizeLong delegate to it, so every chunk is screened).
  • DeepLtranslate, 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: AbstractSpecializedService gained a required InputGuardrailScreener constructor parameter (after pipeline, before the optional repositories). A subclass or manual construction must pass it; an InputGuardrailScreener([]) 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 InputGuardrailScreener into 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.