---
title: "ADR-292: A run records the records its tool calls changed"
manual: "LLM Foundation"
version: "0.45"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-292@0.45"
source: "Adr/Adr292ARunRecordsTheRecordsItsToolCallsChanged.rst"
rendered: "2026-11-13T14:44:39+00:00"
---

# ADR-292: A run records the records its tool calls changed {#adr-292}

-   *Status:* Accepted
-   *Date:* 2026-11-12
-   *Amends:* [ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45) (its two revisit triggers, a second reader and a call that writes more than one record), [ADR-198](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-198@0.45) (the copy and translation read-back limits, for what a reader can learn of them), [ADR-206](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-206@0.45) (the answer of a call that failed after it wrote)
-   *Authors:* Netresearch DTT GmbH

## Context {#context}

A write tool names one record ([ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45)), and the run keeps
that one identity as a `tool_write` step. Core writes more than the record a
tool names. A copy of a page copies its content, a translation of a page or
element writes overlays, and a hook of the installation can write any record
it likes ([ADR-198](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-198@0.45), amendment of 2026-11-06). The copy and
translation tools read back the main record only and say so; what core wrote
along appears in no field a consumer can read.

Two of the three triggers ADR-182 gave for revisiting its shape have come true
in effect. A second reader exists: the observed outcome joins `sys_history`
against the `tool_write` steps ([ADR-185](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-185@0.45)), and nr-mcp-agent
lists the writes of a run segment for the next prompt. And a call does write
more than one record, through core.

Two gaps follow. A tool that fails after it wrote returns an error, which is
fail-closed and carries no target ([ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45),
[ADR-206](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-206@0.45)): the records it wrote before the failure appear
nowhere, and the model retries a write that half happened. And the approval
card states the workspace and language of a write only as prose, which a
consumer has to parse.

TYPO3 13.4 and 14.3 dispatch no PSR-14 event for a DataHandler record
operation. The DataHandler's hooks are the only way to hear of a write while
it happens.

## Decision {#decision}

**A run step lists what the DataHandler runs of one call wrote.**
`ToolLoopService` arms `ToolChangeCollector` around
`ToolInterface::execute()` and takes it afterwards, on every way out of the
call. Two global DataHandler hooks feed it, `processDatamap_afterDatabaseOperations`
and `processCmdmap_postProcess`; they run for the nested DataHandlers core
creates for a copy or a translation, and do nothing while no call is armed.
When the call ends, the records the hooks saw are joined with the
`sys_history` rows of the same DataHandler chain, which share one correlation
scope; the history adds what no hook names, such as the content and subpages a
page delete takes along. `sys_history` has no index on the correlation id, so
the rows are read only from the uid the table had when the first DataHandler
run of the call began, taken by a hook before that run writes: a range of the
primary key as long as the call's own writes. A call whose DataHandler runs
never reached that hook reads no history. `history_data` is never read. A step of the new kind
`tool_changes` (event kind `TOOL_CHANGES`) follows the tool step and the
write step of the call. A call that wrote nothing records no step.

**An entry is an identity and stored facts.** Table, uid, action (created,
updated, moved, deleted, restored; a record created and then updated is
created), the workspace of the row, the live uid it is a version of, its
language, whether it is the record the tool named (`writeTarget`), its
disable flag and the time and group restrictions the row itself carries. They
are read from the row, not from the tool's text, and the workspace is the one
the row is stored in, not the one the writer works in: a user in a draft
workspace writes a table without versioning live. They are separate fields: a
row that is not hidden can still be a draft, and nothing states that it is
public. Frontend visibility is not checked and has no field. The list holds at
most 50 entries, keeps the tool's own target among them, and counts the rest in
`changesOmitted`; a history chain of more than 5000 rows is cut off and counted
as at least one more. No title and no
field value is stored, so the run-step privacy filter has nothing to strip at
any level, and the step is persisted as it is.

**The list never says complete.** The coverage is `datahandler`: this is what
DataHandler runs reported. A write that bypasses the DataHandler, a query of a
hook or a file written through FAL, is invisible, and the absence of a record,
or of the whole step, says nothing about it. The coverage is `not_captured`
when the capture failed, or when the tool stated a write target that no
DataHandler run reported; whatever was seen is still listed. A reader must
read the record itself in that case.

**A call that failed keeps its list.** The result stays an error with no target
([ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45), [ADR-206](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-206@0.45)). The step is recorded
beside the failed tool step, and the error the model reads names what the call
changed before it failed, ten identities at most, so it checks them before it
tries again.

**\`\`ToolResult\`\` does not change.** The list is a property of the call, taken
where the call executes, and not of the result. The loop rebuilds results
([ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45)), and a field added to `ToolResult` is a field the
rebuild can lose.

**The approval card states workspace and language as values.**
`PendingCallView` carries `workspaceId`, `workspaceTitle` and
`language`. The workspace is the one the run writes into as its initiator
pinned it ([ADR-260](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-260@0.45)), 0 for live. The title is read for the card
and is withheld from a viewer who may not work in that workspace; the id is
not. The language comes from the new `PendingLanguageInterface`, which only
a tool that takes a target language implements, here `create_translation_draft`;
a card without one states none, which is not the default language. The prose
lines stay as they are.

**Reading it.** `AgentRuntimeInterface::events()` returns the new event kind
beside the others; a consumer that filters by kind ignores it until it asks.
`ObservedChangeSet::tryFromEventPayload()` decodes the payload without the
caller parsing arrays, skips an entry it cannot read and reads an unknown
coverage as `not_captured`.

## Consequences {#consequences}

-   ● A consumer can list what a run changed, with the dependent records of a
    copy or a translation, without parsing prose.
-   ● A call that fails after it wrote no longer hides what it wrote.
-   ● Workspace and language reach the approval card as values.
-   ◐ The ADR-182 triggers are answered by a second structure beside the first;
    the observed outcome keeps joining the `tool_write` steps.
-   ◑ Two more global DataHandler hooks run on every record write of the
    installation, and return at once outside a tool call.
-   ◑ A call that writes reads one row per listed record, one query for the
    watermark and one history query per DataHandler chain over a range of the
    primary key. A call that runs no DataHandler costs nothing.
-   ◑ An update that changed no column is listed as updated: the DataHandler ran
    it, and the list states that and not what the row now holds.
-   ◑ The list and the note the model reads name table and uid of records the
    acting user may not be able to read, and the run's events are readable by
    whoever may read the run. A consumer shows a record only to a viewer who may
    read it; nr-mcp-agent decides that where it builds its view.
-   ◑ The list is as old as the history it joins: where `sys_history` is off
    or pruned, the hooks' records stand alone, a delete of a row that no longer
    exists is not listed (a command for a uid that never existed leaves the same
    trace), and the inline children of a
    delete are missing.
-   ✕ A tool that names a target and writes through a query is shown as
    `not_captured` even when its write is correct. Such a tool reports no
    more than it did before.

Net Score: +2

## Alternatives considered {#alternatives-considered}

**A list on \`\`ToolResult\`\`.** Rejected: the rebuild trap of
[ADR-182](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-182@0.45), and the error result would have to carry a target
that its fail-closed rule forbids.

**Only \`\`sys_history\`\`.** Rejected: it is empty where logging is off, and it
cannot tell the tool's own record from the others. The hooks alone miss what
core takes along without a command of its own. The union is what the run can
state.

**A coverage of \`\`complete\`\` where history and hooks agree.** Rejected: both
see only DataHandler writes, so their agreement says nothing about the writes
neither sees.

**Frontend visibility as a field.** Rejected: no tool computes it, and a field
nothing fills reads as an answer.
