ADR 282 · Accepted
An A2A result is stored with the run's completion
Context
A remote client of the server role (ADR-278) 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). 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): 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). 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
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) 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
●● 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
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.