A log records what happened. A journal makes a promise about what will happen next. That distinction sits at the center of this chapter.
When an agent turn crashes mid-execution — after the model has streamed a response, after tool calls have been issued, but before the transcript is committed — recovery can only succeed if there is a durable record of exactly how far the turn progressed. Not a timestamp. Not a boolean flag. A precise, monotonically advancing phase marker that tells the recovering attempt: start here, not from the beginning. The v2 journal is that record. Understanding it means understanding what durability actually guarantees in a system where turns span multiple network calls, a streaming response, and a tree-structured transcript.
Chapter 5 introduced the journal from the Coordinator's perspective — the four phases that wrap drive_run, and the state-machine view of what happens when a process dies mid-turn. This chapter goes inside the machinery: what each phase stores, how the segment table preserves streaming output without re-querying the model, how leaf IDs anchor recovery to the exact transcript position, and what enforces single-writer semantics across process restarts. The Coordinator you saw in Chapter 5 calls primitives; this chapter explains what those primitives do.
An agent turn is not atomic. Between "user sends a message" and "agent response is committed," several irreversible things happen in sequence. The model streams tokens. Tool calls are dispatched and results are collected. A transcript delta is written. If the process dies anywhere in that chain, naive recovery has no choice but to re-run the whole turn from scratch — re-querying the model, re-executing tools, potentially re-sending messages to external systems. That's expensive, potentially incorrect, and in the case of side-effectful tools, dangerous.
The journal exists to make wholesale re-execution unnecessary. It checkpoints exactly which phase completed so a recovering attempt can skip what already happened and continue from where the interrupted attempt stopped. This is the same insight behind write-ahead logging in databases: you commit intent before you act, so that if you crash mid-action, recovery can replay from the committed intent rather than guessing at the partial state.
But there's a subtlety here that WAL doesn't capture on its own. A database WAL records individual operations. An agent turn is a multi-step workflow with heterogeneous sub-operations — some cheap to replay (tool calls), some expensive and non-idempotent (LLM queries), some that write to external state (transcript commits). The journal needs to distinguish between these phases, not just record that "something happened." That's why the journal is a finite state machine, not a log.
Before diving into the journal itself, you need the structural picture. The v2 durable layer is two logical stores with six primitives between them.
The AgentSubmissionStore owns everything related to submission lifecycle and turn execution: the submission row itself (with its queued → running → settled status), the turn journal (one slot per submission), stream-segment chunks, attempt markers, and deletion markers. The SessionStore owns the transcript tree — the history of entries the model and tools produce, persisted as a linked list of nodes where each entry knows its parentId.
SubmissionStore SessionStore
───────────────── ──────────────────────
AgentSubmission (status CAS) session entry tree
└─ AgentTurnJournal slot ├─ input entry
phase (4-step FSM) ├─ assistant message
streamKey └─ tool result entries
toolRequest (leaf-addressed)
checkpointLeafId
committedLeafId
AttemptMarker
DispatchReceipt
DeletionMarker
The key design choice: the journal slot lives inside the submission store, not the session store. This is deliberate. The session store is the shared history of the conversation. The journal is execution metadata — it belongs to the attempt, not the session. If you kept them together, you'd be mixing the "what the agent said" record with the "how far the attempt got" record, which makes both harder to reason about and harder to clean up independently.
The two stores also have different consistency requirements. The session store is append-only: entries are written once and never mutated. The journal slot is mutable: it advances through phases. Separating them lets each store optimize for its actual access pattern.
Every agent turn that runs under a submission gets exactly one journal slot. That slot starts at phase before_provider and advances in one direction:
[*] → before_provider (beginTurnJournal — slot created)
→ provider_started (updateTurnJournalPhase + streamKey)
→ tool_request_recorded (updateTurnJournalPhase + toolRequest + checkpointLeafId)
→ committed (commitTurnJournal + committedLeafId)
provider_started → committed (text-only turn, no tool call — skips tool_request_recorded)
Each phase transition stores the data the next phase needs. beginTurnJournal creates the slot with the turn's identity fields (submissionId, sessionKey, kind, attemptId, operationId, turnId) and sets phase to before_provider. At this point, the system has made a durable promise: a turn is about to start, owned by this attempt.
updateTurnJournalPhase to provider_started adds the streamKey — the handle for the segment table where the streaming response will land. Without this transition, a crash after the stream starts but before anything else is committed leaves recovery with no way to know whether the model was even queried. With it, recovery can look up the stream segments and decide whether to replay them or re-query.
updateTurnJournalPhase to tool_request_recorded adds both toolRequest (the raw tool call the model issued) and checkpointLeafId (the transcript leaf the current attempt was working from). These two fields together let recovery know: "the model asked for this tool, and the transcript was here when it happened." A crash after this phase means recovery has everything it needs to synthesize an interrupted marker for the tool call and let the model decide whether to retry — without re-executing the tool or touching the model provider again.
commitTurnJournal closes the loop. It advances to committed and records the committedLeafId. After this, the slot is frozen — the phase itself is the terminal marker. Any write from any attempt — including the one that just committed — returns false. The journal is a contract, not a log. Once fulfilled, it's done.
There is a fifth operation that doesn't advance the phase: markStreamConsumed. It stamps streamConsumedAt on the slot while it's in provider_started. This is a one-time write — once stamped, it can't be stamped again. Its purpose is to track whether the stream has been fully consumed by the current attempt, so that recovery knows whether the segments are complete or might be a partial capture. Section 4 returns to this.
Between provider_started and tool_request_recorded lies the most expensive operation in the turn: waiting for the model to stream. The response might take seconds. The process might die partway through. The streamKey — minted as submissionId:turnId:attemptId before the stream opens — is the durability handle for that stream.
As tokens arrive, segments are written to an insert-once chunk table keyed on (streamKey, segmentIndex). "Insert-once" is the key invariant: if a segment write is replayed — because the attempt crashed after writing to the chunk table but before the database acknowledged the write — the second write is a safe no-op. It doesn't overwrite. It doesn't duplicate. The stored segment wins and the replay returns false.
stream arrives chunk table
───────────────────────── ──────────────────────────────────
token batch 0 ─────────────► (streamKey, 0) → "The experiment"
token batch 1 ─────────────► (streamKey, 1) → " showed a 12%"
token batch 2 ─────────────► (streamKey, 2) → " lift in..."
[CRASH]
recovery reads: getStreamChunkSegments(streamKey)
→ ["The experiment", " showed a 12%", " lift in..."]
If the process crashes mid-stream, recovery calls getStreamChunkSegments with the streamKey from the journal slot and replays the captured segments rather than re-querying the provider. This is the right trade-off: segment writes are insert-once and cheap. Re-querying the model is expensive, non-deterministic, and potentially billed twice. You always want to prefer the captured output.
The markStreamConsumed stamp tells recovery whether the stream ran to completion before the crash. If streamConsumedAt is present, the captured segments are the full response. If it's absent, the segments might be partial — recovery knows to check whether the partial output is sufficient to continue or whether a re-query is needed.
The journal stores two leaf ID pointers: checkpointLeafId and committedLeafId. These are the mechanism by which the journal anchors itself to the transcript tree, and they're worth understanding carefully because they're what make recovery position-safe.
The session transcript is a tree of entries. Each entry has an id and a parentId. The "active path" is the chain from root to the current leaf. When a turn begins, the current leaf is the last entry in the active path — typically the user's message or the previous assistant response. checkpointLeafId records that leaf at the moment the turn records its tool request. It's the "before" snapshot: the transcript state as it existed when the turn started doing real work.
committedLeafId is the "after" snapshot: the leaf after the turn's output — the assistant message and any tool results — has been written to the tree. Together, these two pointers define the exact transcript range a turn occupies.
transcript tree (active path)
─────────────────────────────────────────────────
root
└─ [user message]
└─ [assistant response, turn N-1]
└─ [tool result, turn N-1]
└─ [user message, turn N] ← checkpointLeafId
└─ [assistant response, turn N] ← committedLeafId
Recovery uses checkpointLeafId to re-attach to the exact history node the interrupted attempt had. Without it, recovery would have to scan the transcript tree to find where the turn started, and if the tree has partial writes from the crashed attempt, it might attach to the wrong parent. With checkpointLeafId, recovery has a stable reference that can't be moved. The setLeaf method on SessionHistory implements this: it rewinds the active path to a specific entry, so the recovery attempt builds its context from the right position.
The two-pointer design also makes double-append detection trivial. If committedLeafId is present on the slot, the turn committed. No recovery attempt should try to commit it again. The check is a direct field read, not a scan.
When the reconciler detects that an attempt's lease has expired, it needs to hand the uncommitted journal slot to a new attempt. replaceTurnJournalAttempt is the single primitive for this.
The call takes the current (submissionId, attemptId) pair, a nextAttemptId, and optionally a new lease. It atomically: moves the submission's running state from the old attempt to the new one, re-points the journal slot's attemptId to nextAttemptId, increments attemptCount, and clears any pending recovery request. If the submission isn't running under the specified attempt, it returns null without writing anything.
After replaceTurnJournalAttempt succeeds, the old attempt is stale. Any further call from it — updateTurnJournalPhase, markStreamConsumed, commitTurnJournal — will check the journal's attemptId field, find that it no longer matches, and return false. The old attempt's writes go nowhere. This is the mechanism by which the journal enforces single-writer semantics across process boundaries.
The new attempt reads the journal slot's current phase and continues from there. A slot in provider_started means the provider was reached but the turn did not complete — the current implementation re-drives the turn rather than reconstructing from captured stream chunks. (The streamKey and markStreamConsumed primitives exist in the store, but stream-segment reconstruction is not yet wired into the recovery path.) A slot in tool_request_recorded means the tool call was recorded before the crash; the new attempt skips the model entirely and synthesizes an interrupted result for any unresolved tool calls (see Chapter 7's repair path). A slot in before_provider means the crash happened before the stream started; the new attempt re-runs the full turn.
One subtlety: beginTurnJournal performs an in-place replace, not an insert. If you call it on a submission that already has a journal slot, it resets the slot to the new turn's identity and increments the revision counter. The slot is a single row per submission, not a row per attempt. This means the revision field is the observable indicator of how many times the slot has been replaced — useful for debugging, and for detecting cases where two processes both try to begin a journal for the same submission.
The v1 SessionHistory class in apps/runtime-v1/src/durable/history.ts has appendMessage. You call it, it creates a new MessageEntry with the current leafId as parent, appends the entry, and advances the leaf. That's the entire persistence model for turn output: push messages, move the leaf forward.
There is no journal slot. There is no phase marker. There is no streamKey persisted to the store. When a v1 turn ran, it called the model, collected the response in memory, and then called appendMessage once or several times to write the output. The turn's internal state — "has the model been queried yet?", "is the stream complete?", "which tool call was in flight?" — lived entirely in the running process.
A crash after the model responded but before appendMessage completed meant the response was lost. A crash after appendMessage but before the submission was marked complete meant the submission would be retried, and the retry would query the model again — producing a second response on top of the first, or worse, writing a duplicate to the transcript. There was no mechanism to detect which phase the crashed attempt had reached.
The v2 journal is the structural answer to this gap. It externalizes the turn's internal phase into the store, making that phase readable by any process, not just the one that started the turn. The four phases correspond directly to the four irreversible operations in a turn: before provider, during stream, after tool request, after commit. Each phase transition is a durable write. Recovery reads the phase and knows exactly what has and hasn't happened.
This is the same principle that separates a transactional system from a best-effort one. In a best-effort system, you try to do the work and hope the process stays alive. In a transactional system, you write your intent durably before each step, so that recovery has a roadmap. The journal is that roadmap.
The journal's correctness depends on one invariant: at most one attempt writes to a slot at a time. If two processes could both advance the phase and both commit, you'd get two committed turns, two committedLeafId values, and a transcript that branched where it should have been linear. The single-writer guarantee prevents this.
The guarantee is enforced by checking attemptId ownership on every write. updateTurnJournalPhase checks that the calling attemptId matches the journal slot's current attemptId before writing. commitTurnJournal performs the same check, and additionally checks that committed is still false. markStreamConsumed checks ownership and also checks that the streamKey matches. Every write is gated on ownership, and ownership can only be transferred by replaceTurnJournalAttempt.
Single-Writer Enforcement via attempt_id
Attempt A (process 1) Attempt B (process 2)
─────────────────── ───────────────────
journal.attempt_id = A replace_turn_journal_attempt(A → B)
journal.attempt_id = B
update_turn_journal_phase(...)
→ checks attempt_id update_turn_journal_phase(...)
→ A ≠ B → checks attempt_id
→ returns false ✗ → B == B
→ write is no-op → returns true ✓
→ phase advances
The revision field makes conflicts observable. Each call to begin_turn_journal increments it. If two processes somehow both call begin_turn_journal on the same submission concurrently, one will win and the other's subsequent writes will fail the attempt_id check. You can tell from the stored revision how many times the slot was reset.
Every phase advance is a single conditional write — UPDATE … WHERE attempt_id = $expected, returning false on mismatch. The compare and the write must be one atomic statement; a read-then-write in application code has a TOCTOU race under concurrent reconcilers.
This is compare-and-swap applied at the business-logic level rather than the storage level. You don't need a distributed lock. You don't need a two-phase commit protocol. You need a single authoritative row with an ownership field, and writes that check that field before proceeding. The row is the lock. The attemptId is the token. replaceTurnJournalAttempt is the token transfer. And because the token transfer is atomic in the store, there's no window where two attempts both think they own the slot.
The journal's design reflects a general principle: durability is not about storing more data, it is about storing the right data at the right granularity. A timestamp tells you when something happened. A boolean tells you whether it finished. A phase marker tells you where to start next. The journal stores phase markers because recovery is a continuation problem, not a detection problem. You need to know where to pick up, not just that something went wrong.
The next chapter shows what recovery does with everything the journal recorded — how a new attempt reads the phase, reconstructs the right context from the transcript tree, and continues the turn from the exact position where the crashed attempt stopped. That chapter also makes visible why the journal was worth building: the recovery classifier is a pure function, testable offline, precisely because the journal externalizes everything the classifier needs to read.
apps/runtime/src/durable/types.ts— Durable substrate types for the v2 runtime, including theAgentTurnJournalinterface with all fields and theSessionEntry/SessionStoretypes that define the transcript tree.apps/runtime/src/durable/store.ts— The v2AgentSubmissionStoreinterface (ported from flue), defining all six journal lifecycle primitives (beginTurnJournal,updateTurnJournalPhase,commitTurnJournal,markStreamConsumed,replaceTurnJournalAttempt) and the stream-segment operations.apps/runtime-v1/src/durable/history.ts— The v1SessionHistoryclass showingappendMessage— the flat append-and-advance model that had no phase tracking, no journal slot, and no durable record of turn-internal state.