ADR-153: A run's uuid is the correlation id of everything it does 

Status

Accepted

Date

2026-08-11

Amends

ADR-058 (telemetry rows gain a run to belong to), ADR-081 (the persisted run gains a read surface)

Authors

Netresearch DTT GmbH

Context 

An agent run made N provider calls and produced N unrelated traces. ProviderCallContext::for() , ::forConfiguration() and ::forService() each minted a fresh Uuid::v4() internally, and a caller had no way to pass one in — only the raw constructor and the with*() copies preserved an existing id. So a five-round run wrote five tx_nrllm_telemetry rows that nothing tied together, and nothing tied any of them to the run.

tx_nrllm_governance_event was worse: it HAS an agentrun_uid column and no code ever wrote a non-zero value. All three write points passed 0. The column was a declaration with no writer, and the comment above its correlation_id sibling claimed the id linked a row "via the run's correlation, to its agent run" — which was never true, because runs had no correlation.

The read side of a run timeline already existed and was unused: AgentRuntimeInterface::events() and ::status() are implemented and authorised ( AiActorContext::mayActOnRun() with ServiceAccountScope::AGENT_READ ) and had zero callers in Classes/.

Decision 

No new column: the run's uuid IS the correlation id. tx_nrllm_agentrun.uuid and tx_nrllm_telemetry.correlation_id are both an RFC 4122 uuid in a varchar(36). A correlation_id column on the run would have stored a second identifier for the same thing and needed a mapping nobody asked for. AgentRunReference::correlationId() states the equality in one place; the join key already existed on both sides.

The context is widened, not rewritten, and only where a caller exists. ::forConfiguration() takes an optional ?string $correlationId; null — every caller that has no wider trace — mints per call exactly as before. An EMPTY string also mints: '' is the "no trace" marker an unpersisted run leaves behind, and adopting it would collide every such call into one bucket.

::for() and ::forService() were deliberately left alone. No agent-run path reaches either — a run drives a configuration — so widening them for symmetry would add an argument nothing passes, which is the shape this project refuses. Widen them when a run driving a configuration-less or a specialized-service call exists; it is the same three lines.

The run travels on the execution context. ToolExecutionContext is already built once per run, from the run's actor, and already reaches the loop, the resume paths and the tool gate through one parameter. Adding the run there kept ToolLoopServiceInterface — three methods, one of them thirteen parameters long — unchanged.

The uid travels as pipeline metadata. agentrun_uid is an int a middleware needs, which is what the metadata map is for (beUserUid, idempotencyKey, the cache key). CallMetadataFactory::agentRun() produces the key GuardrailMiddleware::METADATA_AGENT_RUN_UID , disjoint from the other three producers so the + merge at every call site keeps working.

All three governance write points are attributed, and 0 keeps a meaning. The tool gate reads the run off the execution context; the guardrail middleware reads the uid off the metadata; the input-context gate is handed it by the manager, the same way it is handed the backend user. 0 now means "this decision did not happen inside a run" — a plain provider call, or a bare ToolLoopServiceInterface consumer driving the loop without persistence — rather than "the identity was available and dropped".

Of the three, the input-context gate is the one that also keeps correlation_id = '' by construction: it runs BEFORE the ProviderCallContext exists, so there is no trace id to write. Its agentrun_uid is the join key instead.

The view renders metadata, and only metadata. AgentRunController::showAction() goes through the runtime — so the authorisation is the runtime's and an unreadable run is indistinguishable from an unknown one — and RunTimelineFactory widens the released run with the telemetry rows carrying its correlation and the governance rows carrying its uid or correlation. What a step contributes is an ALLOW-LIST of non-content payload keys. RunStepPrivacyFilter already drops content at the default level; the allow-list means an installation running at REDACTED or FULL does not silently turn this page into a transcript viewer. suspended_state and queued_request — stored verbatim, bypassing the filter — are never assigned to the view; AgentRuntimeInterface::status() strips them before the controller sees them.

The view is read-only. No approve, no retry, no cancel. Those exist on the inbox list, where they are authorised per run and per turn.

The link is offered only where the read would succeed. The inbox list is deliberately wider than the read: an approval-grant holder sees every user's run, because AiActorContext::mayActOnRun() grants the human equivalent of ServiceAccountScope::AGENT_APPROVE and of no other scope (ADR-130). Read therefore stays owner-or-admin, and offering the row a Timeline link that can only redirect back would be an affordance for an authorisation nobody holds. TerminalRunView::$openableByViewer asks the same mayActOnRun() the controller will ask, so the two cannot drift; widening the read to the approval grant would be a change to the runtime, not to the template.

Consequences 

✓ One run, one trace: its rounds, the synthesis completion, the fallback hops inside them and the governance decisions taken along the way all resolve from the run's uuid.

tx_nrllm_governance_event.agentrun_uid has a writer and a reader in the same change. So does the timeline the read surface was built for.

✓ No schema change to tx_nrllm_agentrun. The one DDL addition is an index (agentrun_uid, crdate) for the query this record introduces.

◐ Streaming is not correlated. It bypasses the pipeline (ADR-062) and settles its own telemetry; a streamed run's rows still carry a per-call id. Nothing in this record blocks it — the dispatcher takes the same reference — it is simply not wired.

◐ A row written before this change keeps its per-call id and agentrun_uid = 0. Historic runs therefore show their steps but no calls; there is no backfill, because nothing recorded which call belonged to which run.

✕ The timeline orders by crdate (second resolution) with the step sequence as the tiebreak, so a call and the step that made it land in the right order but two calls within one second only order by insert order. Sub-second ordering would need a column the log tables do not have.

Revisit when 

The streaming path needs the same attribution, or the timeline needs sub-second ordering — the latter is a column change to two append-only tables and a purge window's worth of mixed data.