---
title: "ADR-098: Input-guardrail screening for specialized prompts"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-098@0.35"
source: "Adr/Adr098SpecializedInputGuardrailScreening.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-087@0.35)), 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-085@0.35)), 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-097@0.35)), 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·E** — `generate`, `generateMultiple` (DALL·E 2 branch; the DALL·E 3
    branch screens via its delegation to `generate()`), `edit`.
-   **FAL** — `generate`, `generateMultiple`.
-   **TTS** — `synthesize` (`synthesizeToFile` / `synthesizeLong` delegate 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:** `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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-078@0.35)): 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.
