Session record: one seam, one log
A session has one durable source: sessions/<id>/record.jsonl. Workers
author facts through one cloneable record::Emitter; the model projection, the
scrollback projection, and the rendered user.log are folds or presentations
of that record. The bus is delivery, not a second authority. This is the
operational path behind the frontend,
cards, and io-surface maps.
The seam
Emitter::emit is generic over the sealed record::Class set, so a live
producer can append only a Protocol, Display, or Forensic record. It
delegates to record::Log::append, whose mutex protects the whole critical
section:
- Wrap the record with its append timestamp and serialize the
Entryenvelope. - Write and flush the line when the session is durable;
--no-logskeeps the same seam with no writer. - Advance the
Seqand byte cursor and build theRecorded<Record>stamp. - Meter usage where applicable and publish
Signal::Fact(AgentId, recorded)through the attached weak fleet sink before releasing the lock.
Append-then-publish makes channel order log order. In a durable session, a missing or slow receiver cannot lose the fact: it is already in the file and a later replay can catch up. The sink is attachable because the session log outlives a TUI session bus and the headless per-exchange buses that are attached to it in turn.
Emitter::transient uses the same log mutex for ordering but never writes a
line or takes a sequence number. It publishes the other channel passenger,
Signal::Transient(AgentId, Transient). A Signal::Fact is the recorded,
stamped half (file-backed in durable sessions); a Signal::Transient is
live-only and has no replay path.
Three durable classes, one live edge
The outer Record vocabulary is closed:
Protocolis the provider-facing history: session bookends, prompts, context messages, step starts, assistant messages, tool results, and context edits. The model fold consumes this class alone.Displayis worker-authored presentation data: chopped prose and reasoning, tool calls and results, grouped observations, cards, notices, done outcomes, and other committed rows.record/commit.rsdoes the chopping and grouping before the fact reaches the seam. Cards and observations carry their round-trippable data, not a pre-rendered terminal image; the view fold builds the marks again.Forensicis durable evidence that is not model context: usage deltas, cancellation, provider/stall/error rows, nudges, and other breadcrumbs. The view fold admits the rows that belong on scrollback; the model fold ignores them.
Transient is deliberately outside Record: token and thinking deltas, state
changes, boundaries, child lifecycle, stop reasons, clear acknowledgements,
live pins/resources, and seam faults. These are drawn or routed while the
process is alive. They are not a hidden fourth record class, and no resume
attempts to reconstruct them.
The two folds
record::Fold gives both projections one driver. record::replay streams
record::Log::read one line at a time, reconstructs each Recorded<Record>,
and calls the fold’s exhaustive step; a parse error or an unrecognised
record is a Refusal, so replay does not silently skip a vocabulary change.
The model path is record::Model::step over Protocol records only. During a
live turn, AgentLog::advance applies that same step immediately after the
seam returns from emit. Its Memo owns the protocol state, exchange view,
and ledger. When context edits evict old protocol records, the ledger keeps
their Stamp byte ranges and reads those lines back from record.jsonl when a
refold needs them; no recorded protocol fact is deleted and the whole log is
never held in memory.
The provider-facing transcript is a persistent value
Memo::transcript() does not walk the ledger and materialise owned
genai::ChatMessages on every call. It returns a Transcript
(record/model.rs): Vec<Arc<[ChatMessage]>> segments plus a cached byte
length, private fields, clone is Arc bumps. The committed history is
immutable and append-only — one deliberation step adds one assistant message
and its tool results, and nothing already recorded ever changes — so the
memo caches each closed span’s rendering exactly once, keyed by span id and
its end index (SpanRender). A span id never recurs (a span opens only
strictly past the running maximum exchange), so (id, end) determines a
rendering globally and forever, and a stale cache entry is inexpressible: a
still-growing span simply misses the key it would need to hit.
The renderer is split along the one axis that actually varies. Only the last
span’s projection is retroactive — its omit/repair_end flags depend on
whether it is still live and on what the next fact does to it — so that
variation gets its own function with no cached path to leak into:
render_closed_entryrenders a closed span’s full range and repairs its end. It takes no flags, so nothing can vary one; the memo’s key is the whole of the function’s input.render_tailrenders only the live last span, carrying the two retroactive flags, and its result is never cached.
transcript() assembles the digest segment (replaced wholesale when a Fold
replaces the digest text), render_closed_entry per non-last span through
the memo, and render_tail fresh for the tail. history_bytes and
context_survey read each closed segment’s byte count from the same cache
entry; only the live tail is ever re-serialised. This preserves the model
fold’s recompute invariant rather than contradicting it: correctness never
reads the memo as authority, it is a memo of a pure function at immutable
arguments — droppable and reconstructible at any moment, never serialised,
rebuilt from nothing by this fold on resume. “Recomputed on every call”
becomes cheap instead of false.
The one remaining place an owned whole-history Vec<ChatMessage> is
materialised outside the wire is Memo::inherited_context_messages — the
context a mnemon child inherits at spawn, where ownership genuinely
transfers into the child’s own ledger. Every other crossing — the provider
seam, CompactionPlan.prefix — carries a Transcript by shared reference;
the-transcript-is-a-value is
the ADR, and the provider map describes the one door,
provider/wire.rs, where a Transcript is finally turned into an owned
genai::ChatRequest.
record::model::resume quarantines a torn crash tail, then streams the file
through admission and the model fold, checking the incrementally maintained
projection against a refold. A missing record.jsonl is a named refusal, not
an invitation to start an empty resumed session.
The view path is record::View::step over Display and Forensic; it skips
Protocol explicitly. Blocks::push joins consecutive records of one lane
into a block, while a different kind opens the next block. Block construction
is private to the fold, and the memo keeps a bounded resident window
(BLOCKS_WINDOW). A record::Printer receives Blocks, not raw records, so
the TUI and headless printers cannot invent a third block projection.
Live and replay use the same view fold. Signal::Fact reaches
Viewport::commit_fact or headless absorption, which steps the memo and calls
Printer::sync; Signal::Transient goes straight to the printer’s live edge.
The TUI’s App::fact and App::transient are therefore the two distinct
doors: durable rows are fold-backed, while an open answer/thinking line or
chrome row remains provisional until a later record or boundary resolves it.
Resume and the user view
On TUI resume, tui_loop replays record.jsonl into Blocks before the worker
starts, then Viewport::seed performs one sync and marks the resident rows as
already present in user.log. The resumed session appends after that seeded
prefix instead of writing the replayed window twice. Cumulative usage comes
from the replayed forensic deltas; the resumed note is the boundary between
history and new live signals.
user.log is the rendered user view, not the source of truth. The fold memo is
bounded independently from the viewport’s presentational caps. When
Viewport::enforce_window_caps evicts the oldest blocks, it renders them once
into the retired prefix and advances the prefix’s durable offset. Resident
blocks are provisional: Viewport::flush_log writes them past that prefix for
session-end output and /export, while the next retirement rewinds to the
prefix before extending it, so no block is duplicated. A tombstoned viewport
retires its remaining blocks before dropping its heap state; there is no
reload-from-user.log fold. Crash recovery remains the responsibility of
record.jsonl, which is flushed per record.
Clear and segment rotation
/clear cancels the in-flight exchange, resets the viewport (including its
user.log segment), and arms the frontend’s drain gate so straggler signals
from the old exchange cannot paint the new context. The Cleared transient, or
the next fresh prompt when that acknowledgement is lost, closes that gate.
The session record is rotated without replacing the seam. AgentLog::clear
renames the current segment, then Emitter::rotate asks the same shared
record::Log to open a fresh record.jsonl and reset its sequence/cursor while
retaining the attached FleetSink. Existing Emitter clones and the bus
coupled before the clear therefore continue publishing into the new segment.
The --no-logs branch rotates to the same mirror-only seam with no writer.
The resulting trust boundary is small: record.jsonl is the durable fact
stream, Record classes say which fold may project each fact, Signal::Fact
delivers stamped commits live, Transient carries only process-lifetime edges,
and user.log is the viewport’s rendered stream: a retired prefix plus a
provisional resident tail.
See one-seam-one-log for the seam and
fold law, a-trace-is-a-fold for the
single durable record, the-window-is-not-the-transcript
for retirement and incremental sync, and
session-lifetime-event-bus for
the live bus lifetime. The broader accumulator/fold distinction is in
residency, and the visual projection discipline is in
tui-transcript-as-graphic.
the-transcript-is-a-value is the
ADR for the persistent Transcript value and the closed-span render cache
described above; provider covers the one door that
turns it into an owned wire request.