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.
Provider,
:: and
:: each minted a fresh
Uuid:: 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_ rows that nothing tied together, and nothing tied any of
them to the run.
tx_ was worse: it HAS an agentrun_ 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_
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:
Agent and
:: are implemented and
authorised (
Ai with
Service) and had zero callers in Classes/.
Decision
No new column: the run's uuid IS the correlation id.
tx_ and tx_ are both an
RFC 4122 uuid in a varchar. A correlation_ column on the run would
have stored a second identifier for the same thing and needed a mapping nobody
asked for.
Agent 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.
:: 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.
:: and
:: 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.
Tool 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
Tool — three
methods, one of them thirteen parameters long — unchanged.
The uid travels as pipeline metadata. agentrun_ is an int a middleware
needs, which is what the metadata map is for (be, idempotency,
the cache key).
Call produces the key
Guardrail, 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
Tool 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
Provider exists, so there is no trace id to write. Its
agentrun_ is the join key instead.
The view renders metadata, and only metadata.
Agent goes through the runtime — so the
authorisation is the runtime's and an unreadable run is indistinguishable from
an unknown one — and
Run 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.
Run 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_ and
queued_ — stored verbatim, bypassing the filter — are never assigned to
the view;
Agent 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
Ai grants the human equivalent of
Service 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.
Terminal asks the same
may 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_ 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_. The one DDL addition is an index
(agentrun_, 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_.
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.