The log is the source, not a copy
Everything the model sees must be in the log first. Context is never stored beside it — it is derived, and that is the difference between a source and a copy.
The ordering decision
There are two ways to write an agent, and they differ in the order of two operations.
The first: call the model, take the reply, then write down what happened. The log is a record of the conversation.
The second: write the event to the log first, then build context from the log and call the model. The log is the source.
The first is easier and fails in a way that is hard to see. If the log is a copy, then two places hold the truth — the array in memory and the file on disk. Every edit to one means remembering to edit the other. Forget once and the two diverge, and the divergence only shows up when you resume.
The harness I read chose the second and says so plainly:
A Session is an append-only log of typed SessionEvents — the single source of truth for an agent’s whole interaction history. The LLM message history is derived from the log, never stored separately; replay is re-derivation from the same events.— dsh, docs/subsystems/session.md
Three clauses. Append-only. Single source of truth. And the third is the one that decides: replay is re-derivation, not reading back a saved copy.
Appended does not mean durable
This is the detail I find most worth remembering here, and it is the one most of us get wrong.
append does not promise survival. Verbatim:
Persistence is best-effort: on resolution the batch is accepted, ordered, and visible to reads on this backend instance, but only a resolved flush promises it survives a crash — a backend may buffer or batch physical writes behind append.— dsh, docs/subsystems/persistence.md
Read that closely: once append resolves, the event is readable in this process. But it may still sit in a buffer. Only flush is the durability barrier.
The documentation gives flush a very clear name: the durability barrier. And it states the condition for use: Callers that must survive a crash flush.
Which means whoever needs to survive a crash must call it. Nothing does it for you.
This is the easiest thing to get wrong when building your own harness. You call append, you see the promise resolve, and you believe the data is safe. It is not safe. It is merely somewhere the current process can read.
Crash repair belongs to the reader
A log that crashed mid-turn ends with an open turn/start and no turn/end.
My first instinct was that the storage layer would trim that broken tail. Wrong:
Persistence does not truncate or repair it — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash.— dsh, docs/subsystems/persistence.md
The reason is practical. A turn may hold hundreds of already-durable events. Trimming it throws away good data just because one closing line is missing.
So the storage layer returns a physically valid contiguous log, and the repair belongs to the reader. On resume, the loop reads the stored log, computes the missing closing events, and appends them through the same handle:
interruptedTurnClosers // three kinds:// - missing tool errors// - any open step/end// - a synthetic turn/end, reason: { kind: 'interrupted' }And there is a notable constraint: repair only writes when you hold write ownership. Another process opening the same session for writing is rejected with SessionAlreadyOwnedError, rather than letting two parties race to repair.
Seven ways a turn can end
A turn does not end in only one way. There are seven branches, and two of them no loop ever emits:
| Branch | Meaning |
|---|---|
completed | Finished normally |
aborted | A cancellation request arrived |
blocked | Stopped by policy |
error | Failed, with structured failure facts |
max-tokens | Some step hit its output ceiling |
interrupted | A crash-orphaned turn, closed after the fact |
forked | An open turn closed at the fork boundary |
The last two are where I stopped for a while. No loop emits them. interrupted is written by resume, or synthesized by the query layer on a cold read. And forked appears only in a fork seed.
The type is also merge-extensible — a plugin adds its own branch by merging into the map. So every switch over it must carry a default. The documentation gives the reason: a plugin-added variant is a valid unknown value.
One log, two projections
This is the most interesting consequence of a log-as-source design, and I have not seen it in another harness.
From the same log, there are two separate views:
- Surface fold: Builds the message list sent to the model. When a section is compacted, the surface only keeps the new summary and hides older events.
- Append-origin: Rebuilds the full history for humans to inspect. It keeps every original event intact, even those hidden from the model.
One single source of truth produces two distinct views for two different audiences.
View diagram data
type: sequence
title: One turn across five components
desc: The loop coordinates everything by writing to the log, calling the model, and recording tool results
nodes:
- id: user
label: User
kind: input
- id: loop
label: The Loop
focal: true
- id: log
label: Durable Log
kind: store
focal: true
- id: model
label: Model
kind: external
- id: tool
label: Tool
edges:
- [user, loop]
- [loop, log]
- [loop, model]
- [model, loop]
- [loop, tool]
- [loop, log]The price
There is no second copy to read quickly.
Every question about the past is a rebuild. You have no array to print, no table to query directly. To find out what the third step of the second turn contained, you run the fold over the log.
And because the log is append-only, you cannot edit the past either. To “delete” something already written, you append a replacing event — which is exactly how compaction works.
In exchange you get three things. One source of truth. A pure function from log to request. And the ability to replay exactly what happened.
Do this now
- Pick the order before writing the first lineWrite then call, or call then write. Choose the second and you already have two sources of truth, and you will be reconciling them forever.
- Separate append from flush in your APIDo not let one function both accept data and promise durability. Callers need to know which side of the barrier they are on.
- Let the reader repair, not the writerA crash leaves the log open. Trimming it throws away good data. Let the next resume compute what is missing and write it back.
- Name every way it can endSeven branches sounds like a lot, but each is a situation you will meet. Collapsing them into
errorloses the ability to tell a rare case from a common one.
The rule
Choosing the log as the source is choosing a constraint: no other place may hold the truth. That constraint is annoying at first and pays off later.
The log is not for debugging. The log is the context. If it is only a side record, you have two sources of truth and no idea which one is right.— tusgino
- Write to the log before calling the model; do it the other way round and the log is a copy, and copies drift.
appendis best-effort; onlyflushpromises survival across a crash. If you need durability, you must flush.- The storage layer deliberately neither truncates nor repairs a broken log; repair belongs to the reader on resume.
- Seven branches end a turn, and
interruptedandforkedare emitted by no loop. - One log, two projections: surface for the model, append-origin for the reader — and they differ on purpose.