JSONL Is the Truth, SQLite Is the Mirror
You closed the terminal yesterday. Today the list is still there, and you can keep editing the chat. Those two facts look like one store. On disk they ride two tracks. The upper track appends line by line. The lower track only copies the cover. After a mid-run power cut, what you can pick up is always the lines that already cleared the gate.
- Session hands the item to LiveThread; failure is log-onlysession/mod.rs L3753
- LiveThread hands the raw slice to the storelive_thread.rs L203
- The allowlist drops ephemeral EventMsg; exec markers always staypolicy.rs L9
- One JSON line plus a newline; write_all, then flushrecorder.rs L1968
- The gate wins first; only then does Paginated project thread_historylive_writer.rs L337
- Projection failure is warn-only; next time it resumes from the byte offsetlive_writer.rs L345
- Watch the filtered result, then stamp a literal metadata patchlive_thread.rs L212
- Resume decodes the file line by line; it does not rebuild history from the threads tablerecorder.rs L1009
- If the list has no DB or the DB errors, fall back to scanning the sessions directoryrecorder.rs L547
The list has to be fast. Resume has to be right. One file rarely does both. JSONL is cheap to append and jq can read it. Filter by workspace, pin, or archive, and it stops fitting. SQLite is good at those filters — and should not be where you rebuild model input on resume.
Two accidents that look opposite point at the same rule. Delete state_5.sqlite and the list goes empty, then grows back; the chat is still there. Hand-edit a title and cwd in the DB: after refresh it sometimes follows, sometimes snaps back. The mirror can be rebuilt, and it can be overwritten by the original. Lose the original and the mirror cannot save you.
Codex splits into two tracks. Session never touches the file itself. It hands an already-built item to the current LiveThread. No live handle, or append fails: the turn itself does not abort. The error only goes into the log.
Source:codex-rs/core/src/session/mod.rs lines 3753–3759
LiveThread first filters an observation copy by policy. What it hands the store is still the raw slice. The store runs the allowlist again. Streaming deltas, approvals, warnings — those ephemeral EventMsg never enter JSONL. Compacted, TurnContext, WorldState, SessionMeta always stay.
Write order is fixed. JSONL lands first, then the projection into SQLite. Projection can retry next time. If JSONL fails, SQLite does not get to jump ahead.
Append writes and random lookups want different physical shapes. Bind them to one format and either the list slows down, or every append rewrites the whole document. Split them, and the write path can lock the original first, then repair the mirror. Rewrite in another language and the contract is still this sentence.
Write SQLite first and backfill JSONL later, and if the process dies between the two steps the list will show sessions you cannot open. The user sees a title, clicks in, and there is no matching line. That inconsistency is harder to debug than a briefly empty list.
In Paginated mode the comments call SQLite a rebuildable view. The flush barrier must win first. The projection may lag; it may not lead. Only after durable_write returns Ok may materialize_to_sqlite start. Projection errors are warn-only.
Source:codex-rs/thread-store/src/local/live_writer.rs lines 335–347
At the byte step: one JSON line plus a newline, write_all, then flush. That flush is tokio’s file buffer. The source never calls sync_all. Kill the process instantly and the last few lines may still sit in the kernel page cache. Next open pads a newline; a broken half-line counts toward parse_errors.
Source:codex-rs/rollout/src/recorder.rs lines 1968–1974
Resume, fork, and compact replay read JSONL only. The list prefers SQLite. Missing DB, open failure, or unfinished backfill: fall back to scanning ~/.codex/sessions/ and increment the stable metric codex.sqlite.fallback.count.
Source:codex-rs/rollout/src/recorder.rs lines 547–559
Anything rebuildable is allowed to drop. It is not allowed to jump the queue. Bind both sides into one transaction and a slow mirror blocks the original too. Lag allowed, lead forbidden — that is the generic contract of a log plus a derived table.
Compact replaces a stretch of history. If Compacted, WorldState, and TurnContext live only in memory or only in SQLite, deleting the DB drops the window number and the baseline together. The user thinks the model forgot. The loader just never saw those three lines.
Compact edits in-memory history first, then lands Compacted, WorldState, TurnContext in that order. WorldState must follow the replacement history — it is the baseline of that new history.
Source:codex-rs/core/src/session/mod.rs lines 3417–3427
Resume reads it backwards. Scan from the tail. Hit a Compacted with replacement_history and cut the earlier suffix, clearing earlier TurnContext baselines. Then replay WorldState forward: a full snapshot resets the baseline; patches merge on top.
Source:codex-rs/core/src/session/rollout_reconstruction.rs lines 155–188
Those three types are almost useless to the list. apply_rollout_item no-ops on Compacted and WorldState. The mirror is not a full-text index. It is the fields the list and filters need. Titles come from UserMessage, not guessed from the model’s ResponseItem.
Source:codex-rs/state/src/extract.rs lines 14–34
The resume contract is written into the file. Change the persistence shape and you change the resume contract. The repo-root AGENTS.md puts “resume a session from an existing rollout” on the breaking-change checklist. If the file is still there, the session can replay by contract.
DSH: one log, two backends
DeepSeek Harness’s persistence unit is the in-memory SessionEvent. JSONL and SQLite implement the same SessionPersistence seam. Swap the backend and you swap storage primitives, not log semantics. The header carries SESSION_FORMAT_VERSION = 0. Wrong version, or an unknown event not marked ignorable: refuse to parse. The error is SessionFormatUnsupportedError.
Silent truncation is harder to debug than an error, so DSH errors. Codex tries to open anyway. Unknown shapes fail in serde and count toward parse_errors; if any items remain it still tries to build a builder.
Claude Code: one JSONL, no session-mirror DB
The current session path is projects/<project>/<sessionId>.jsonl. Append is synchronous appendFileSync: one JSON line plus a newline, mode 0o600. The list uses getSessionFilesLite, reading file heads and tails, never going through SQLite.
A single-file read has a 50 MB cap. The comments say a session JSONL can grow to several GB, so the caller must bail first or it blows memory. Codex moves that scan cost into startup backfill. Both sides admit JSONL will grow: one refuses to load the whole file past the cap; the other has a backfill worker recopy the cover into the DB.
Source checked on both sides · 2026-08-22Cut power halfway through compact
A session has already written SessionMeta, one user message, and one assistant reply. Compact starts. Compacted has flushed; WorldState has not. Power dies there.
Walk three things: which stretch of history resume can pick up; whether the list-card title changes; and what the missing baseline line becomes on replay.