ADR-034: The chat lists what an assignment changed
Status: Accepted
Date: 2026-11-13
Amends: ADR-030: A later turn reads what ran (the run record keeps the run's uuid) and ADR-017: The chat states what the run did, not what it said (the chat can show what the runs did, not only say it).
Context
An assignment can write more than one record: a page copy takes its content
along, a translation writes overlays, and a hook of the installation can write
anything. The chat showed the editor one card per decision and, afterwards,
the model's text. The only user-facing summary of what was saved existed for
guided processes (card_outcomes, ADR-023); an ordinary chat with several
tool calls, or a run that was resumed after an approval, had none. The run
record of ADR-030 listed every write of a segment, but identifiers only and
only for the next prompt.
nr-llm now records, per tool call, the records the DataHandler wrote
(nr-llm ADR-292, run step tool_changes): table, uid, action, workspace,
live uid, language, whether it is the record the tool named, the disable flag
and the time and group restrictions the row carries. It says honestly when it
does not cover a call (not_captured) and counts what it left out
(changesOmitted). The pending call it hands to the card carries workspace
and language as values, and a call refused by an invocation rule as such
(nr-llm ADR-293).
Two things were missing here. The conversation did not know its runs: it kept the uuid of the last run and of the run awaiting approval, and its run records did not name the run. And the approval card still found the workspace by cutting nr-llm's prose line apart.
Decision
The run record keeps the run's uuid. Each entry gets run beside
outcome (additive, in the existing JSON column, no schema change).
Conversation::getRunUuids() lists the distinct uuids, oldest first; a
run resumed after an approval has several entries and one uuid. Entries from
before have no run; the change view then uses the writes they list. The
uuid is not part of what a later prompt reads: RunRecordNotes reads the
fields it names. The limits of ADR-030 apply: the row keeps the newest 200
entries, and editing a message removes the entries of the turns it cuts, so
an assignment that was cut loses its changes from this view; the records
themselves stay in TYPO3.
A list per conversation, read from the runs' events.
ConversationChangeReader reads, for each run of the conversation that the
conversation's owner may read (AGENT_READ, as for a pending run), the
events through AgentRuntimeInterface::events() and decodes the
tool_changes events with ObservedChangeSet::tryFromEventPayload().
A record several calls changed is listed once: the later observation states
the state now, a record created earlier stays created, a delete wins, and
having been the tool's target once is kept. A call whose tool step failed
shows its list with a failure marker: the records it wrote before it failed
are the ones to check. The call's tool_write step adds to the observed
record, and no row of its own, what a tool that reports no error can still
state: a hook that failed after the write gives the same marker, and a write
the tool did not finish (writeCompleteness partial) marks the call's target
as partial. A run without any tool_changes event, recorded
before nr-llm wrote them, is read through its tool_write events instead:
the record each tool named, shown without action, workspace or dependent
records and marked as such. A run with tool_changes events is never also
read through tool_write, because both name the tool's target. Runs the
actor may not read, or that are gone, are counted and said so.
Every fact is its own badge, and the list never says complete. The view
states action, workspace (the one the row is stored in), language, hidden
(hidden=false is "not hidden" and no word for public), the restrictions
the row carries, whether the record is the step's target or was changed
along with it, and a failure after the write, each as a separate badge. Under
the list a note always says what it covers: the changes TYPO3 reported for the
steps of the assignment, and that a change made another way does not appear.
Further notes appear where nr-llm reports a capture that is not complete,
where entries were left out (nr-llm's limit of 50 per call and this list's
limit of 100, counted together), where records are withheld from the viewer,
where runs predate the capture and where runs could not be read.
The viewer's permission decides per record, here. nr-llm names table and
uid of records the acting user may not be allowed to read, and the events of a
run are readable by whoever may read the run (nr-llm ADR-292). The chat
therefore shows a record only to a viewer who may select its table, to whom
its page is shown (page permissions and mounts, as the page module checks
them), who may access its language and the workspace its row is stored in, and
only while the row exists. A file record (sys_file, sys_file_metadata)
needs read permission on that very file as core decides it
(ResourceStorage::checkFileActionPermission('read', $file)): the file's
folder lies in one of the viewer's file mounts, not merely some mount of the
storage. Everything else is withheld whole - no table, no uid,
no title - and only counted ("n weitere Datensätze werden nicht angezeigt").
A deleted row is checked like any other; a row that is gone for good cannot be
checked and is withheld. The title of a workspace the viewer may not use is
withheld too; its id is not.
Links where core offers one. The record title links to the backend edit
form when the viewer may modify the table; "Änderungen ansehen" opens the
record history (record_history) for a record that was not deleted. The
workspaces module has no URL for one workspace: it shows the workspace the
viewer works in. The link to it appears only when that is the workspace the
record is in. A link opens in the backend's content frame when there is one
(ContentContainer.setUrl) and follows normally otherwise, so the chat keeps
its state.
Delivery: a flag with the poll, the list when opened. The messages
response carries changesAvailable (a run that wrote a record or had a tool
fail, which may have written first; or an old entry that lists a write), so a
conversation that only read shows nothing. The list is its own request
(GET /ai-chat/conversations/changes), made when the reader opens the
"Änderungen dieses Auftrags" disclosure and again when a poll reports a change
while it is open, so a poll never pays for reading events. The disclosure is a
native details element: reachable and operable by keyboard, announced as
expandable, in both surfaces and with the chat's theme tokens (light and dark).
The approval card takes workspace and language from nr-llm's fields.
ApprovalCallPresenter no longer cuts nr-llm's workspace line out of the
preview lines. It words the chat's own sentence from workspaceId and
workspaceTitle, and says that the title is not visible to the reader when
nr-llm withheld it. The language comes from PendingCallView::$language:
none is stated where the tool takes no target language, which is not the
default language. The prose line stays among the preview lines for the cards
that show them. Constraint: nr-llm with these fields (the constraint in
composer.json names the release that has them).
A call refused by a rule is shown refused. The card marks a call with
refusedByRule as one that will not be carried out; its preview lines are
the rule's explanation. The approve button names the calls that are carried
out; when every call is refused, the card offers no approval and says so
(approveBlocked: allRefused). nr-llm's decision is the one it takes when
the call executes; the card never promises an execution.
The draft-workspace requirement is read from the run's configuration.
nr-llm's card view carries no require_draft_workspace (WaitingRunView
has the configuration's label only). requiresDraftWorkspace() reads
LlmConfiguration::getRequireDraftWorkspace() from the run's own
configuration, as requiresSecondApprover() does for the second-approver
setting, and the card says that the configuration requires a draft workspace
beside a call that would write live. It does not parse anything and is false
where the run or the configuration cannot be read; nr-llm refuses the write
itself in that case.
Consequences
- The conversation table does not change. Entries written by this version carry one more key; older entries decode as before.
- Opening the list reads the events of every run of the conversation once per opening and per reported change. A long conversation reads many runs; the list is capped at 100 records.
- A record changed through a path the DataHandler does not report is not
listed, and the view says that it cannot show it. For runs before nr-llm
wrote
tool_changes, only the tool's own record is known. - The change view is read-only. It cannot undo a change; the record history link is core's.
- The card's workspace sentence is the chat's wording, not nr-llm's; the two can differ in the preview lines of a plain card, which still show nr-llm's line.
- Requires the nr-llm release that contains nr-llm ADR-292 and ADR-293
(
tool_changesevents,PendingCallViewworkspaceId,workspaceTitle,languageandrefusedByRule).
Not decided here
- Several cards of one turn (the single pending call that
cardSnapshot()handles) is unchanged; the change view does not depend on card capture. - A hint before the first message that the configuration requires a draft workspace and the editor works live. Without a pending call the chat has no run, and so no run configuration to read; resolving the configuration of the chat's Task for that hint is a separate decision.