ADR 136 · Accepted
ADR-136: The write preview is produced when the run suspends
Context
ADR-122 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 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 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) 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
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 says 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 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. 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.
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:
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.
At reading. The inbox is reachable with the agent_approve grant (ADR-130), 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'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'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). 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.