One seam, one log
Everything a session records crosses one seam, once, into one durable log —
sessions/<n>/record.jsonl — and every durable artifact is a fold of that
log. This inverts which artifact is authoritative: the record is the
session, and the model’s context, the resumed scrollback, and the rendered
user.log are projections of it. events.jsonl retires. The inversion is
complete for the model view and for resume, and deliberately incomplete for
live display, which still rides the legacy Kind stream — the open half is
enumerated at the bottom, blocker by blocker, rather than rounded up to done.
The founding bug
exarch --resume restored the model’s memory of a session and none of the
user’s: events.jsonl replayed into chat history while the TUI opened on an
empty scrollback, because the two artifacts were written on two unrelated
paths — AgentLog’s record family under a mutex on the attend thread, and
the deliberately lossy bus channel the TUI projects. That is not a missing
feature; it is two authorities for one session — the debt
recording-follows-the-event
had already named two-record unification, with the bus as the blocker.
The decision
- One vocabulary, three classes.
Record { Protocol, Display, Forensic }(exarch/src/record.rs): verbatim provider payloads the model fold needs; commits — chopped, coalesced, reduced before the seam, so what is recorded is what the user saw — that the view fold needs; and breadcrumbs neither fold projects but worth keeping. The classes are sealed; a fourth cannot be minted outside the module.Transient(deltas, the thinking seat, chrome) is the channel’s disjoint other passenger and never touches the file — journaling a delta and publishing an unrecorded fact are both type errors. - Append-then-publish is ownership.
record::Emitter::emitis the only publisher; the fleet-channel sender lives inside the log’s mutex (record/log.rs), so a record can only be published while its append is held and channel order is log order by construction. The publisher is attachable — the log outlives any one bus — and weak, so an absent consumer costs nothing: the record is already durable, and a pressured consumer’s escape is the file, since every fact carries aSeq. A failed append is a session error, never a shrug. - The model view is a genuine fold.
record/model.rsfoldsProtocolrecords alone — the samestep, applied inline on the attend thread after eachemitand byrecord/replay.rsfrom disk.AgentLog’s only session state is this fold’sMemo; theevents.jsonl-backed engine is deleted whole. The 260812 laws migrate intact: no recorded fact is ever removed,/clearrotates rather than truncates, resume quarantines a torn tail, and the ledger indexes the protocol subsequence per record through eachStamp’s byte range, never by contiguous run. - The view fold exists and follows the same law.
record/view.rsfoldsDisplaycommits and theForensicrows a scrollback draws intoBlocks;Block’s constructor is private, soBlocksis unforgeable — onlyView::stepmay push a row, and a printer draws blocks it cannot mint. What a frontend builds from the vocabulary is its own fold, exhaustive overRecordandTransientunderdeny(clippy::wildcard_enum_match_arm), so a new variant breaks every frontend that must decide about it;replaycarries thefold == memoproof once, generically. A record no fold recognises is aRefusalthat refuses the session — the display vocabulary is a designed, validated protocol, not a byproduct. - Commits are authored worker-side.
record/commit.rsowns the coalescers that used to live in the frontend: the step’sStreamcuts the assistant’s delta stream at fence-safe paragraph breaks intoDisplay::Answercommits and seals each reasoning run as aDisplay::Thinking, andSurfaceBuffer(moved whole fromtui/surface.rs) groups observations into oneDisplay::ObservationGroupand coalesces diff hunks — so the screen still authors nothing, and 260623’s maxim survives the move: recording a commit is not the worker narrating the screen, it is a designed protocol recorded at the seam. - Observations ride the wire type. An io observation is the one display
content the protocol records cannot supply; it crosses as its total
FOValuewire form and the view fold rebuilds the card through the same mark builders the live path uses — a mark tree is a rendering, never a fact. - Consolidations bought on the way. A
Display::Resultnames its call byBlockIdinstead of a nearest-resident tail walk; the threeemit_context_editedcall sites collapse into one authority,AgentLog::apply_edit, whose published record is the one notification; and usage meters at the seam’s sink, so a display-muted child still counts.
What resume now does
tui_loop::run folds record.jsonl into Blocks via record::replay
before the worker spawns, seeds the viewport with one Viewport::sync
call, and restores cumulative usage from the replayed UsageDelta rows —
the “resumed” note is the boundary between replayed history and the live
session. The founding bug has a test (exarch/tests/resume.rs asserts the
view fold’s output across a kill and resume). A session recorded before
this change has no record.jsonl and is refused with a named error
rather than silently started empty; no migrator is written. user.log
becomes a regenerable render of the fold’s resident blocks, written whole at
flush points and never patched — which also removes the old incremental
tee’s data-loss bug, where a truncate-and-rebuild past eviction silently
deleted the session’s own evicted transcript from disk. Superseded 260816 by
the-window-is-not-the-transcript:
a render of the resident window loses the same history at the other end,
since past eviction it writes a tail and calls it the session. A block is now
written once, when eviction drops it, into a prefix that only grows.
What is deliberately not unified: live display
All four blockers below are since closed: the channel carries Signal { Fact, Transient } alone, and every frontend folds it. The reasoning stands
recorded because each blocker was a real design question, not an oversight.
The plan’s end-state — both printers driven live by Printer::sync(&Blocks)
over a live-folded stream — did not land, on purpose. The live dispatch
loop (tui_loop::ui_loop → App::handle) still runs entirely on the
Kind-tagged stream. The channel carries Signal { Event, Fact, Transient };
Signal::into_event is the transitional bridge that projects a seam fact
back to a legacy Kind for exactly the seven retired twins whose dual-write
emit sites the seam collapsed (Step, ContextEdited, Usage, Error,
Nudge, ProviderError, Stalled) — every other class keeps a live legacy
emit beside its record, so deriving a Kind there too would draw it twice.
Viewport and Headless implement Printer, exercised by tests and by
resume seeding only. Four real blockers, each wanting its own design
decision rather than a unilateral call mid-implementation:
- Live chrome has no interleaving mechanism. The banner,
/helpand/copyacks,/resourcesrows, and stop-reason lines are deliberately never recorded (“drawn, not recorded”), whilesyncrebuilds resident blocks wholesale from the fold — mixing the two on one live viewport erases the chrome. A designed merge of folded blocks and unrecorded rows does not exist yet. - UI-authored facts have vocabulary but no door.
Forensic::SystemNoteandForensic::ModelChangedexist in the record vocabulary with no production emit site: the UI thread has norecord::Emitterplumbed to it. This is the seam-side half of 260623’s “the screen never invents events” promise, still unbuilt — a model switch records in the transcript today, not in the one log, so the context-floor denominatorModelChangedwas minted for is not yet fed. - Some errors cannot record themselves by construction. The
worker-panic report in
bus/sink.rsand the seam’s own append-failure reporters emitKind::Errorwith no record behind it; a fold-only live view would silently lose exactly the failures it most needs to show. ContextEdited’s display row is bridge-only. Live, the row ridesrecord_kind’s projection of the protocol record; the view fold skipsProtocolentirely, so a resumed scrollback has no context-edit row. The plan’s own disposition assigns it “a notice commit from the producer”;NoticeFacthas no variant for it — a small frozen-surface gap left open rather than inventing vocabulary mid-implementation.
transcript.jsonl is likewise not retired to a filtered projection
(the plan’s step 8): it remains an independently written trace, fed at the
bus emit seam outside these parcels’ boundary, and says so in its own
header. The 260623 debt is therefore paid for the durable log and for
resume, and open for live display and the transcript.
Superseded: a-trace-is-a-fold deletes
transcript.jsonl outright rather than folding it — its three genuinely
unique facts move elsewhere (Entry.at_unix_ms per record, a child’s own
SessionStarted/SessionEnded for born/died, stop_reason already on
Protocol::AssistantMessage), and the fourth, /resources’ figures, is
named a loss rather than answered: no session keeps a pressure history, so
invariants/probe-convention.md’s “so transcript.jsonl keeps the figures”
clause is amended, not carried forward. record.jsonl is now the one durable
log with no independent sibling.
Accepted losses
- Committed text is stored more than once: assistant markdown verbatim in the protocol record and again chopped in its display commits; a tool result in up to three shapes, byte-identical by the standing law that the user never sees more of a result than the model did. Deriving display from protocol would need two choppers proven confluent and would re-couple the folds this decision exists to separate.
- Log growth rises as observations move in; 260812’s unbounded-growth
acceptance and deferred segment rotation carry over, with
/clearas the rotation boundary. Resume still reads the whole ledger — the same O(file) bill for time. (The space half of that bill was paid off in the 260816 correction below.) - “Recorded” means surrendered to the OS: per-record flush, never
fsync— process-crash durable, not power-loss durable; the quarantined tail covers the torn write either way. - A pre-plan session does not resume;
--no-logshas a fileless seam that still stamps and publishes, and no resumable history, unchanged. - Dial state and the provisional thinking seat are UI state, not records, and do not survive a resume.
Later corrections
-
260815 — a reasoning run commits where the prose after it begins. Authoring commits worker-side settled who records; it left one authoring site in the wrong place.
Display::Thinkingwas written at the step’s end, which was the answer’s end too until the chopper began cutting prose into paragraph commits — after which the step’s end falls between the paragraphs already committed and the tail not yet, and that is where the∴landed. A run seals at the seam where prose resumes instead, which is where it actually ended; a run no prose follows still seals at the boundary. Two things followed. The provider’s two stream callbacks became one overDelta::{Say,Think}: two independent callbacks are precisely the type that cannot say a run ended where the prose began. AndDisplay::Thinkinggave up itsanswer_chars— a commit that precedes the prose cannot carry that prose’s mass, so the view measures it from the answer run following the row, which also lets the deliberation grain fill as the answer accrues. The general lesson for this page’s producers: a commit’s position is a claim about when the fact ended, so a producer that only learns its content later must not also defer its authoring. -
260816 — a block is a run of records, so a cut needs no meaning. Cutting the prose into commits mid-stream is what lets a reader watch an answer arrive; the mistake was leaving each commit to stand alone as a block. Since nothing downstream rejoined them, the cut had to be semantically correct, and one requirement paid for three mechanisms: a fence-safe paragraph scanner in the producer, a rule that whitespace ride along so the commits stayed a partition of the stream, and — because the screen was then always ahead of the log — an arithmetic on the printer’s side (
Unaccounted: grow, retire, saturate) to track how much of what it drew no commit had accounted for, with the seat reduced to a bare magnitude because the text could not be trusted to line up.The fold now grows a block from consecutive records of one lane (
Blocks::push), exactly as a tool call’s result is patched onto its call. The cut falls at the last newline and means nothing, sosafe_paragraph_breakand the whitespace-partition rule go; a block’sSeqstays the one it opened with, so a reveal dial survives the growth. The printer keeps only the open line — the text past the last newline — because the worker cut at that same newline, and all ofUnaccounteddeletes. There is then one rendering path rather than two:Viewport::live_taildraws the open line inside the block that will absorb it, in that block’s own ink, so the record that completes the line changes the text and not the picture, and the markdown context the line sits in is the block’s own. Prose ends the reasoning run on the printer’s side exactly as it does on the worker’s, so at most one lane is ever open and the live tail is unambiguous. The answer reads as prose while it streams, and reasoning as reasoning, instead of as size bars. The general lesson: if a producer’s cut has to be meaningful, ask first whether the consumer could simply put the pieces back together. -
260816 — a fold reads its log as a stream. The accepted loss above put resume’s space bill at O(file), and the implementation earned it four times over: the log was read into one byte buffer, parsed into a
Vecof every record, filtered into a secondVecof the protocol subsequence, and only then folded — with the torn-tail scan reading the file whole a second time and the append cursor a third. None of that was needed.Log::readnow yields oneRecorded<Record>at a time off aBufReader,replayandmodel::resumetry_foldthat stream, the sequencing check became anAdmissionstepper that runs in the same pass rather than a batch over a collected slice,find_crash_tailscans backwards from the end in windows (a torn tail is one record long however long the log behind it is), andLog::append_tocounts lines withread_untilinstead of reading the file in. What a resume now holds is the memo it is building — residency at the addressed view, exactly the 260812 bound — and never the log it is building it from. Time stays O(file), which is irreducible: the fold must see every record. The general lesson: a fold is a streaming operation by construction, so collecting its input first is never the shape of the computation, only of the code that spells it.
See also
recording-follows-the-event (the debt this pays, half), context-is-a-projection (the fold law this generalises from the model view to the log), frontend (the as-built arm), residency (the viewport’s accumulator/memo split).