---
title: "ADR-282: An A2A result is stored with the run's completion"
manual: "LLM Foundation"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-282@main"
source: "Adr/Adr282AnA2aResultIsStoredWithTheRunsCompletion.rst"
rendered: "2026-11-12T15:41:35+00:00"
---

# ADR-282: An A2A result is stored with the run's completion {#adr-282}

-   *Status:* Accepted
-   *Date:* 2026-11-10
-   *Amends:*

    [ADR-234](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-234@main) (an A2A task's run keeps its result in the
    completing transaction, also after an approval); [ADR-242](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-242@main)
    (a fourth store follows the run's answer ceiling)

-   *Authors:* Netresearch DTT GmbH

> [!NOTE]
> The decision also covers the A2A event journal that polling, streams and
> push read; the title names the part the other records amend.

## Context {#context}

A remote client of the server role ([ADR-278](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-278@main)) learns the result
of its task by `GetTask`, a stream or a push notification, any time after the
task reports COMPLETED. A COMPLETED task without its artifacts is a wrong
answer, not a missing one.

nr-llm keeps one kind of result today. `QueuedRunCoordinator::keepAnswer()`
stores the final answer of a queued run with an origin that COMPLETED, in
`tx_nrllm_agentrun.answer`, sealed ([ADR-234](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-234@main)). It runs after
the run has been settled, and a failure there is only logged, so a run can be
COMPLETED without its answer. A run that went through an approval keeps none.

What may be stored is decided by two ceilings per run
([ADR-270](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-270@main)): the audit level for events and the answer level for
answers, read by `RunPrivacyLevelResolver::forRunAnswer()`. The answer
level follows the global level, except that the global default `metadata`
keeps a full answer, and a provider or task tightens it
([ADR-242](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-242@main)). ADR-270 tightens it per actual provider attempt,
including a fallback, under a run-row lock, and scrubs a stored answer when it
tightens; a later write rereads the ceiling under that lock.

## Decision {#decision}

**An A2A task's result is written in the transaction that completes its run.**
The artifacts and the final status message go to `tx_nrllm_a2a_task` (sealed,
like the answer column), and the task's state and the run's status change to
COMPLETED in the same transaction, under the run-row lock ADR-270 uses for
answer writes. If the result cannot be written, the run is not completed: it
fails, and the task says FAILED. A COMPLETED task without its stored result
cannot exist.

**Everything a remote party sees comes from one journal, written after the
commit.** `AgentRunExecutor` hands each step to its live observer before
it records it ("emit-before-persist"), and text deltas reach the observer only;
an A2A stream or webhook fed from there could announce what was never stored.
So `GetTask`, `ListTasks`, SSE and push read the task view and an A2A event
journal (`tx_nrllm_a2a_event`) instead. A projector writes both after the
completing transaction, from the committed state. It is idempotent per run
revision: the same revision projected twice yields its events once. A projector
failure is retried from the committed state and never executes the model or a
tool again. The journal holds only what may leave the installation (state,
status message, the stored artifacts), never a prompt, a tool argument, raw
provider output or an audit row.

**The store follows the answer ceiling, not the audit level.** The result is
what the task exists for, like a background answer. `full` keeps it,
`redacted` keeps the redacted text, as ADR-234 does. The audit level of the
events does not matter. This does not bypass ADR-270: the answer ceiling is
read under the lock at completion, and a tightening that scrubs answers scrubs
this store as well.

**A profile that cannot keep a result is not executed.** A publication profile
whose configuration resolves to an answer level of `metadata` (set explicitly
on its provider or task) or `none` is refused before a task is created, with
`UnsupportedOperationError` (`-32004`) and an `ErrorInfo` reason
`RESULT_NOT_RETAINABLE`. This applies to the blocking mode too: a blocking
call that reaches its cap ([ADR-283](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-283@main)) is continued by
`GetTask`, which needs the store.

**A ceiling that tightens during the run fails the task, distinguishably.** A
fallback can move the answer ceiling to `metadata` or `none` after the task
was accepted. Then nothing is stored, the run ends FAILED, and the task says
FAILED with a status message saying the result could not be kept under the
installation's privacy policy, and `Task.metadata` carrying the reason
`RESULT_NOT_RETAINABLE`. No path returns the content, the blocking response
included. A task that COMPLETED with no artifacts is a different, legitimate
state.

**An A2A run keeps its result after an approval.** ADR-234's "a run that goes
through an approval keeps none" does not apply to a run that belongs to an A2A
task: the remote client has no other way to learn what was done.

**The task's history follows the same rule.** `Task.history` holds the
client's messages and the agent's status messages; a later turn in the same
context reads it, and `GetTask` returns it. It is sealed, kept at the answer
level like the result (`redacted` keeps redacted text), and stored nowhere
else. A profile that cannot keep a result cannot keep a history either, so
the refusal above covers both.

**The result is purged with its run.** The task row and its sealed columns are
removed by the run purge (`privacy.retention.agentRun`); afterwards
`GetTask` answers `TaskNotFoundError`, which the specification allows for a
purged task.

## Consequences {#consequences}

-   ●● A task that says COMPLETED always has its result.
-   ●● Polling, streams and webhooks show the same states in the same order,
    and none of them shows a state that was not committed.
-   ● One privacy rule for every answer an installation keeps; nothing new to
    configure.
-   ◑ A profile on a provider or task tightened to `metadata` cannot be
    published; the backend says why when the profile is saved.
-   ◑ A fallback to a stricter provider fails a task that would otherwise have
    completed.
-   ✕ The completion of an A2A run takes a lock and a second table write in its
    transaction.
-   ✕ A stream lags the run by the projector's latency.

Net Score: +5

## Alternatives considered {#alternatives-considered}

**Feed streams from the executor's live callbacks.** Rejected: they fire before
the step is stored, so a client could see a state the database never held.

**Use** `QueuedRunCoordinator::keepAnswer()`. Rejected: it writes after
completion and only logs a failure, keeps nothing after an approval, and stores
one text where a task has artifacts.

**Keep the result regardless of the answer ceiling.** Rejected: it would undo
ADR-270 for one store, and an operator who tightened a provider would find its
output stored anyway.

**Refuse only non-blocking calls under a no-storage profile.** Rejected: a
blocking call that reaches its cap needs the store just the same.
