ADR-0020: Turn journal (the agent turn as an append-only, hash-chained event log)¶
Status: Proposed. First slice implemented behind a flag (app.turn-journal.enabled, default false).
Origin: bet 1 in ARCHITECTURE-BETS-2026-10.md.
Context¶
An agent turn is observable today only through separate records: llm_call_log, tool-call logs, audit entries, step
events, approvals. Debugging, replay, evals, compliance evidence and cost control each reconstruct the turn from a
different source. We want one ordered record per turn that can be rendered, replayed against a stubbed model and
verified for tampering.
Decision¶
Add a TurnJournal SPI in the agents module (com.agenticai.core.agents.turnjournal):
TurnEvent(turnId, agentId, seq, type, payloadHash, summary, prevHash, hash, createdAt).- Types:
INTAKE, GUARD, MODEL_CALL, TOOL_CALL, APPROVAL, VERIFY, COMMIT. hash = SHA-256(prevHash | turnId | agentId | seq | type | payloadHash | summary | createdAtMillis);prevHashof seq 0 is 64 zeros.TurnHashChain.verifyrecomputes the chain and reports the first broken seq (edit, removal, insertion, reorder).- Implementations:
InMemoryTurnJournal(tests, replay) andJdbcTurnJournal(Postgres, tenant tableturn_journal_event, migration V498, primary key(turn_id, seq), appends retry on a lost seq race, anUPDATEtrigger makes rows append-only;DELETEstays possible for retention and DPDP erasure).
What is written, and what never is¶
Payloads are never stored. payloadHash is the SHA-256 of the user message (INTAKE), of the tool arguments (TOOL_CALL),
of the final answer (COMMIT). summary is a short structural string (tool=lookup status=ok resultSha=ab12.. ms=40,
provider=.. model=.. route=primary), passed through TurnRedaction (emails, bearer tokens, key-like tokens, long digit
runs and long opaque tokens are masked; 240 characters max). Tool results contribute only a 12 character digest.
Where it hooks in¶
ChatTurnExecutor.streamFast (hook calls only): INTAKE at entry, MODEL_CALL for the primary call, the tools-disabled retry
and the provider fallback, TOOL_CALL through a JournalingToolCallback that decorates each tool, GUARD after the outbound
guardrail audit, VERIFY for the fabricated-generation-URL check, COMMIT once per turn (completed, error or cancelled).
APPROVAL is written by JournalingToolCallback when a call is held for a human (see "What a tool call's status means" below)
and, on the turn that continues the task, as the human's decision. The executor is built by hand, so it reaches the
journal through TurnJournalGateway, installed by TurnJournalConfig. Disabled, begin() returns a no-op recorder:
no wrapping of tools, no extra operators, no writes. The recorder captures TenantScope on the request thread and
re-applies it on whichever thread writes; a failing journal is logged and never reaches the turn.
Every depth, and sub-agent turns¶
The streamed depth (FastStreamTurn), the buffered depths (BufferedTurnRunner: CAREFUL, RIGOROUS, PLANNED) and the turns a
delegated agent or a specialist runs for another agent's tool call (SubAgentTurn) all begin a recorder with
TurnJournals and write the same stages. A sub-agent turn is its own record (own turn id, own throwaway session); its
INTAKE carries parent=<turn id> when the tool call that started it ran inside a journaled turn on the same thread, so a
reader can follow the link from one record to the other. Sub-agent turns also get what a buffered turn gets (inbound
screening, the delegate's own content guardrails, model-level hooks, finish-reason handling and the continuation policy),
see SubAgentTurn.
What a tool call's status means¶
JournalingToolCallback wraps the tools from outside the guard chain, so the status is worked out from what the platform says
about the result, not from the fact that the callback returned: ok (a result the platform did not write), error (the
callback threw), blocked (a guard, dispatch gate, hook, argument policy, circuit breaker, output withholding or the tool
budget answered instead of the tool) and parked (held for a human; an APPROVAL event with decision=pending and the
approval request=<id> precedes it). The platform's answers are recognised by ToolResultMarker (core/guardrails): the layer
that answers registers its exact text with a typed kind, so a configured wording (the approval-park text is configurable) or
a wrapped one (an MCP refusal is a JSON object) is still recognised, and the old wording prefixes remain a fallback for a
producer that did not register itself. The turn that continues a task after the human decided writes decision=decided:completed|failed|denied
with the same request id in its first events.
Finding a turn¶
The turn's id is made when the turn's TurnContext is built (TurnContext#turnId, never taken from the caller). While the
journal is on, the chat response carries it in X-Journal-Turn-Id (exposed to browsers by the CORS config), and every event
of the turn also carries a digest of the chat session id (session_hash, tenant migration V666; the same digest is in the
INTAKE summary, inside the hash chain). GET /api/agents/{agentId}/turns/{turnId}/journal returns the events plus
chainIntact, brokenAtSeq; GET /api/agents/{agentId}/turn-journal?sessionId=...&limit=... lists a session's turns, newest
first, with the status of each (completed, error:..., cancelled, refused, open). Authorization for both:
PERM_VIEW_AUDIT_LOG (explicit rules in AdminIntegrationRules, two lines in the authorization snapshot) and VIEWER on the
agent. The reads work whether or not the flag is on (they serve what was recorded). Turns written before V666 have no session
digest and are found by id only.
Replay¶
TurnReplay runs a turn against a stubbed ReplayModel through the same tool decorator and journal, and
sameBehaviour(recorded, replayed) compares the tool-call sequence (name plus argument hash) and the final-answer hash.
Because the journal stores hashes, replay needs the stub (or a fixture) to supply the decisions; the journal supplies the
expected sequence.
Configuration¶
| Key | Default | Meaning |
|---|---|---|
app.turn-journal.enabled |
false |
write the journal for chat turns on every depth and for delegated / specialist turns |
app.turn-journal.max-list |
100 |
most turns one by-session listing returns |
app.wiring.strict-gateways |
false |
fail the start (instead of logging an ERROR) when a switched-on feature's static gateway was never installed |
Consequences¶
- Writes are synchronous JDBC from the thread that finishes each stage. This is acceptable behind a flag; before enabling by default, measure with the 32-user kind benchmark and move writes to a bounded queue (tenant scope captured) if needed.
- Workflow runs and agent-job turns that do not go through
AgentChatServiceare not journaled (a job's chat turns are, like any turn). The journal is not linked tollm_call_logrows by id; the session is the common key. - A hosted-chain failover runs the turn again on the next provider with the same turn id: the record then holds two attempts
(two INTAKE events, an
error:COMMIT for the first, the final COMMIT for the last).listBySessionreports the status of the last COMMIT. - The Turn Inspector page (ADR-0021) opens a turn by agent id and turn id and, given a session id, lists that session's turns through the by-session endpoint.
- Nothing purges
turn_journal_event(the table is append-only by trigger and no job deletes from it), so it grows without bound until an operator prunes it. TurnReplayis exercised by tests only; no production code path calls it.- The chain is per turn. Chaining turn heads into the Ed25519 audit seals (the bet's acceptance) is not done yet.
- A privileged database user can rewrite a whole turn consistently (recompute all hashes); sealing the head hash is what closes that.
Not in this slice¶
Seal integration, CLI, retention job.