---
title: "ADR-136: The write preview is produced when the run suspends"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@0.35"
source: "Adr/Adr136WritePreviewAtSuspend.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-136: The write preview is produced when the run suspends

-   *Status:*

    Accepted (its staleness section is overturned — see
    [ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@0.35))

-   *Date:* 2026-08-09
-   *Amends:* [ADR-122](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-122@0.35) (the deferred preview)
-   *Amended:* 2026-08-31 by [ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@0.35)
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-122](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-122@0.35) deferred the preview on two grounds, and both are
about placement rather than value:

> "The preview has no caller and no display. The one surface that could show it
> is the approval card \[…\] It would also have to run inside the reviewing
> administrator's request rather than the run's actor context, which
> [adr-083](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@0.35) forbids reading around."

Producing the preview **at suspend time** answers both in one move. The caller is
`ToolLoopService`, at the point it throws
`ToolApprovalRequiredException`. The display is the approval card, which by
then is guaranteed to exist — the pause is what creates it. And the context is
the run's own actor context, because the loop is still executing the run: no
administrator's request is involved, nothing is read around ADR-083.

The second half of the reason is what [ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.35) got half right.
It observed that `update_page_metadata`'s arguments ARE the new values, so the
card already shows them. What the card does not show is what those values
REPLACE. "Set the description to X" and "replace this hand-written description
with X" are different decisions, and only the second one is a decision.

## Decision

An opt-in `ToolPreviewInterface` — no base class, no
`ActionInterface`, the same marker shape `ToolEffectInterface` and
`RequiresApprovalInterface` already have. A tool that implements it returns
human-readable lines describing what the pending call would do, and answers
whether a given viewer may be shown them; the forty-odd read-only builtins are
untouched and render no preview line at all.

The lines are produced inside the approval scan, for every OFFERED call of the
suspending turn, and travel with the state:

-   **Persisted** as an additional optional field on `SuspendedRunState`. It
    is inside the same blob as the transcript, so it is encrypted at rest by the
    same codec ([ADR-114](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-114@0.35)) with no new plumbing.
-   **Degrading, not failing.** `fromArray()` treats a missing or malformed
    `callPreviews` as "no preview". A running installation has suspended runs in
    its database; every one of them must still resume, and it does — the card
    falls back to the arguments alone, exactly as before this ADR.
-   **Index-bound to its call, across rehydration.** A preview records the
    position of the call it describes. Rehydration drops pending-call entries that
    are not usable at all, which renumbers the rest, so the stored positions are
    translated onto the surviving list and a preview whose call did not survive is
    dropped. Without that translation a corrupt blob would move a preview one call
    along — silently, plausibly, and past the tool-name guard whenever the turn
    calls the same tool twice.
-   **Rendered** in `WaitingRunViewFactory::buildApproval()`, in the Fluid
    partial, and in BOTH playground responses (the JSON payload and the streamed
    `awaiting_approval` event) through the single `pendingTools()` helper the
    two share.
-   **Bounded** before it is persisted: twenty lines, 500 characters each,
    whitespace collapsed. A preview is model-triggered output like a tool result,
    and it goes into an encrypted column that is re-read on every resume.

### A failed preview is a line, never a blank and never a fatal

A tool whose preview throws must not kill the run — the loop catches
`Throwable`, logs it, and stores a line saying the preview failed, marked
with a `failed` flag the card renders as *"No preview — you are deciding
without one"*. An empty return is treated the same way.

The alternative — swallow the failure and show nothing — is the dangerous one:
an approver cannot tell a tool that has no preview from a tool whose preview
broke, and would take a missing warning for the absence of anything to warn
about.

The exception TEXT is deliberately not shown, only its class. This follows
`ToolLoopService::invoke()`, which withholds exception bodies for the same
reason: a DBAL failure carries `Access denied for user X@host`, and the
preview is persisted and rendered rather than discarded. The full exception goes
to the log, where the operator can already read credentials they own.

## What if the target changed between preview and execution

> [!NOTE]
> **Overturned by** [ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@0.35) **on 2026-08-31.** The trigger
> this section names below — a writing tool whose change is RELATIVE — arrived
> ten days after this record: `attach_file_to_content_element` appends, and
> its own preview line is an operand of the write rather than decoration on
> it. The
> reasoning here is kept because it is why the fence was not built earlier, and
> because ADR-184 answers it rather than ignoring it. What is no longer true is
> the conclusion.

**The preview is a snapshot of the pause, not a precondition for the write. A
target that changed in between does not block the approval.** The card said so,
in as many words: *"Captured when the run paused — the target may have changed
since."*

The reasoning, in the order it decided the question:

**A writing tool here sets absolute values.** `update_page_metadata` writes
`description = "…"`, not "append" or "increment". A concurrent human edit
therefore changes what the approver READ, never what the approval DOES. The
write that executes is the write that was shown; only its "before" column has
aged.

**A resource fence has no repair path.** Refusing the approval on a changed row
leaves the run suspended with no way forward: the model cannot re-issue the call
(it is not running), and the approver cannot edit the pending arguments. Every
unrelated edit to a busy page — by an editor who never heard of the run — would
dead-end an approval that a human had already decided was correct.

**The turn digest is not this.** [ADR-132](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-132@0.35) binds a decision to the
tool CALL that was reviewed, so a stale tab cannot authorise a turn nobody
looked at. That is a guarantee about the agent's proposal, and it is exact
because the loop owns both sides of it. The target resource is owned by TYPO3
and edited by people; binding to it would be a different guarantee with a
different failure mode, and extending the digest to cover it would silently
convert ADR-132's precise check into a lossy one.

**TYPO3 has no such fence either.** Two editors on the same page in the backend
overwrite each other, last write wins. A tool that a human explicitly approved
is not the place to invent an optimistic-locking regime the rest of the system
does not have.

**Revisit when a writing tool writes a RELATIVE change** — DISCHARGED on
2026-08-31. An append, an increment, a "remove the third paragraph" — for those,
the "before" is not decoration, it is an operand, and a snapshot stops being
sufficient. That tool needs a precondition token; this one does not, and
building the token first would be ADR-122's mistake repeated.

> The tool arrived on 2026-08-21 and the token was built:
> [ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@0.35). Every paragraph above this one is why the fence was
> not built sooner, not advice to defer it again.

## Who may read a preview

The preview is produced under the RUN OWNER's authority and read by the
APPROVER, and those are not always the same person. It is therefore authorised
twice over, once on each side:

1.  **At production.** The tool checks the EXPLICIT acting user of the run
    (ADR-083) — for `update_page_metadata`, `doesUserHaveAccess()` plus
    `checkLanguageAccess()`, the same checks and the SAME neutral refusal
    string `execute()` uses. A preview can never show a page the run itself
    could not have written, and cannot be used to probe the page tree for
    existence.
1.  **At reading.** The inbox is reachable with the `agent_approve` grant
    ([ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35)), which is tool-level and decides nothing about
    individual records. So the card asks the tool a SECOND question —
    `ToolPreviewInterface::mayViewerReadPreview()` — about the backend user
    the page is being rendered for. `update_page_metadata` answers it with the
    same record check it used at production, applied to the viewer. Where the
    answer is no, the card says the preview is withheld instead of showing the
    lines.

[ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)'s gate is NOT part of this. It sits in
`ResumeCoordinator::approve()`, on the DECISION, and never runs while the
list is rendered. Reading the card and pressing "Approve" are gated separately,
and only the second one passes through ADR-133.

**What the read gate does and does not buy.** It removes the disclosure this
feature would otherwise create: an approver whose remit is operations rather
than editing no longer reads the current metadata of pages they hold no rights
on. It costs one bounded row read per pending call per render, and it can leave
an approver with a partly blind card — they may still release a write whose
"before" they were not shown, because the authority to approve is tool-level and
this gate does not change that. That asymmetry is deliberate: withholding sight
is cheap and reversible, withholding the decision is [ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)'s
subject and a different change (issue #662, option 3).

Fail closed on every branch the card cannot resolve: no viewer, a tool that is
no longer registered, or a tool under that name that offers no preview contract.
The persisted preview outlives the registration that produced it, so "the tool
cannot be asked" is a normal state, not a corrupt one.

Two bounds remain on what a permitted viewer sees: only the fields the call
would write (never the whole row), each truncated to a 120-character excerpt.

The same limit stated the other way: a tool's preview must read only what the
run's acting user may read, and must answer honestly about what its viewer may
be shown. Both are contracts on implementors, written into
`ToolPreviewInterface`, not something the loop can enforce for them.

The playground's two preview surfaces are not gated this way. Every one of its
actions is admin-gated (`denyNonAdmin()`), and an admin passes every
record check by definition, so a second question would have exactly one answer.
The moment a non-admin reaches that module, it needs the gate the inbox has.

## Consequences

●● An approver sees what a write would replace, in the same card that asks them
to release it. For the one shipped writer this is the difference between reading
a model's proposal and reading a diff.

● The interface is opt-in and its absence is silent by design — a writing tool
that skips it costs its approver the comparison and nothing else. That is the
same trade as `ToolEffectInterface`, whose silence costs more.

◐ `SuspendedRunState` grew a tenth constructor parameter. It is optional,
last, and `@api`: the snapshot test records the new public property, and no
existing caller changes.

◐ `ToolPreviewInterface` carries two methods, not one: producing a preview
and releasing it to a viewer are different authorisations, and only the tool
knows which record its arguments name. The interface is not `@api`, so this
costs nothing outside the extension.

✕ A preview runs a tool's read path at suspend time, on the loop's clock. It is
one bounded read per previewing call in the turn, but it is work the loop did
not do before, and a slow preview delays the pause the operator is waiting for.

✕ Rendering the inbox now costs one further record read per previewing pending
call, for the read gate. The list is a handful of runs; a queue of thousands
would need the answer cached per viewer.

✕ An approver may still RELEASE a write to a page they hold no rights on — the
approval authority stays tool-level ([ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)). They now do it
without seeing the "before". Withholding sight without withholding the decision
is the deliberate half-measure; issue #662 option 3 is the other half.

## Revisit when

A writing tool needs a preview of a RELATIVE change, or a second surface starts
rendering previews outside the approval card. The first breaks the snapshot
argument above; the second would be the moment to ask whether the lines should
be structured data rather than text, which they deliberately are not today —
one string list has one rendering, and three surfaces render it identically.
