---
title: "ADR-088: Live streaming redaction with a holdback buffer"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-088@0.35"
source: "Adr/Adr088StreamingRedaction.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-088: Live streaming redaction with a holdback buffer

-   *Status:* Accepted
-   *Date:* 2026-07-18
-   *Authors:* Netresearch DTT GmbH

## Context

ADR-086 added an end-of-stream guardrail *audit*: once a stream finishes,
`StreamingDispatcher` screens the assembled completion and records any
non-ALLOW verdict. It is audit-only — the chunks have already been yielded to
the caller, so a secret a model streamed was recorded but not masked. The gap
left open was live redaction: masking a secret *before* the byte is sent.

The hard part is chunk boundaries. A secret can be split across two chunks
(`sk-abcdef012` \+ `3456789…`), so redacting each chunk in isolation misses
it. Catching it requires not emitting a byte until enough following bytes have
arrived to know it is not part of an in-progress secret.

## Decision

**Redact the raw buffer fresh, emit its stable prefix.** `drain()` accumulates
the raw completion and, each chunk, redacts the whole raw buffer
(`redactStream()` applies the output guardrails' REDACT verdicts) and emits
only the redacted prefix beyond the last `HOLDBACK_BYTES` (128); the remainder
is flushed at end-of-stream. Redacting the RAW text every time — never
re-processing an earlier redaction marker — is essential: a marker such as
`sk-***` breaks the pattern's own character class, so re-redacting
`sk-*** + continuation` would leave the continuation of a boundary-split key
unmatched and leak it. Because the raw buffer re-matches a secret in full on
every chunk, a complete secret always collapses to its marker; the holdback then
withholds only a match still in progress at the tail (whose reach-back is an
anchor plus the pattern minimum, far under 128), so no unredacted secret byte is
emitted, including one split across chunk boundaries.

-   **Only REDACT is actionable live.** DENY / REQUIRE_APPROVAL cannot retract a
    sent stream; they remain the job of the end-of-stream audit (ADR-086), which
    still runs and records them. The audit's log wording now reflects that REDACT
    was masked live.
-   **Only redaction-capable guardrails trigger the buffer.** A guardrail opts into
    live redaction by implementing the `StreamRedactableInterface` marker
    (`SecretRedactionGuardrail` does). When none is registered — or only
    policy-only DENY guardrails are — the loop passes chunks straight through with
    no buffer and no latency.
-   **Bounded (updated 2026-07-19).** Live redaction runs over a bounded, self-
    certifying sliding window (`StreamRedactionWindow`): it re-redacts only a
    window (block-coalesced, so the rescan is O(n) not O(n²)) and prunes the settled
    front at a cut it certifies clean — \``redact(head) . redact(tail) ===
    redact(window)`\` — so memory stays bounded on an arbitrarily long stream WITHOUT
    ever passing a raw byte through. (The earlier design flushed and passed the tail
    through raw past a 50 KB cap, which leaked a secret positioned beyond it; that
    passthrough is removed.) The separate ADR-086 audit buffer still caps at
    `MAX_GUARDRAIL_BUFFER_BYTES` (50 KB).
-   **Multibyte-safe.** The emit boundary is backed off a UTF-8 continuation run so
    a codepoint is never split across two yielded deltas.
-   **Usage/telemetry count the RAW provider output**, unchanged — the redacted
    emitted length is not the billable token count.

## Consequences

-   Streamed secrets in the common shapes (`sk-…` keys, `Bearer …` tokens,
    credential-bearing URL params) are masked before delivery, closing the
    streaming blind spot the ADR-086 audit only recorded.
-   Cost: the last `HOLDBACK_BYTES` of every stream that has a redacting
    guardrail arrive at end-of-stream rather than incrementally — a small,
    bounded latency on the stream tail. Accepted as the price of live masking.
-   Limits: a credential whose anchor / URL-param name alone exceeds the holdback
    window and straddles the final boundary can still partially leak. A single
    UNBROKEN match longer than the 1 MB hard cap (with no clean cut anywhere) has
    its interior bytes DROPPED — a data-completeness loss, never a raw passthrough —
    so no secret leaks; only an adversarial >1 MB unbroken payload loses data (a
    benign unbroken blob factorises trivially and is pruned, not truncated).
    Correctness relies on the redactor collapsing a complete secret to a marker
    outside its own character class (so a partial anchor at the tail is the only
    unstable region) — true for the shipped `SecretRedactionGuardrail` — and is
    verified by a randomised property test (`concat(deltas) === redact(fullRaw)`).
-   DENY on a stream stays unenforceable — a hard block needs the non-streaming
    path.
