Recording follows the event, not its emitter
Amended by a-trace-is-a-fold: transcript.jsonl,
the second record this page mints, is deleted outright rather than folded —
its one genuinely unique field, a per-line clock, becomes record.jsonl’s
own Entry.at_unix_ms. The “recording follows the event, not its emitter”
law itself survives unchanged; only the two-record mechanism below is
superseded, by record::Emitter::emit alone.
What lands in a durable log must be decided by what an event is, not by which
thread happened to emit it. A session owns two records — events.jsonl, the
append-only log whose fold is the model view, and transcript.jsonl, the
operational view (what the agent did) — and both are written for every durable
session, root and each forked child, in headless exactly as in the TUI.
--no-logs is the explicit transient exception. The operational
trace is fed at the one emit seam (Emitter::emit),
so a fact is recorded because it is an operational event, regardless of who
raised it. The screen is a consumer of that stream — it never invents events;
the worker never narrates the screen.
This is the same discipline tui-transcript-as-graphic applied to the rail and surface-carries-documents applied to kit content, now applied to the event vocabulary and where it is recorded: name the role of a thing, not its appearance, and record it once at the point it is announced.
All of this is implemented.
Superseding note: the model record is the log
The context design context-is-a-projection
supersedes the old shorthand “events.jsonl is the model view”. events.jsonl
is now the durable event log whose left fold determines the model view. Its
membership test is therefore: does the event determine the projection, or is it
a forensic breadcrumb? Both are legitimate log records. ContextEdited (now Protocol::Evicted) is the
new example: its Fold/Drop operation and authority determine future context,
while a Nudge may be retained as evidence without becoming a model message.
The old Compacted record exposed the failure mode — it said that a compaction
happened but did not record its cut, so replay could not reconstruct the view.
The two durable records remain separate. Resume pays their rent through coupled
rotation, a shared wall-clock stamp as the cross-file join key,
Transcript::rotate swapping a writer inside a shared Arc<Inner>, and
ContextEdited being recorded in both. The debt is named two-record
unification. Events that project to no messages now provide the dissolving
mechanism: the forensic record could become a filtered projection of the one
log. What blocks deletion is the bus — emitters are cloned across threads,
while AgentLog appends under one mutex on the attend thread. Keeping the split
costs coupled rotation and its half-failure policy, timestamp/join machinery,
shared writer indirection, duplicate edit emission, and two durable readers
whose ordering must still be related. The next design should price those costs
against deleting a record, not add a third trace by habit.
Superseding note: the two-record debt is half paid
one-seam-one-log pays the debt for the
durable log: events.jsonl is deleted, absorbed into record.jsonl, whose
fold is both the model view and the resumed scrollback. The named blocker —
“the bus: emitters are cloned across threads, while AgentLog appends under
one mutex” — resolved exactly the way this page’s mechanism predicted, by a
multi-thread single appender at the emit seam, with the channel sender inside
the log’s mutex so publish order is log order by construction. The maxim
above is met head-on: display commits are a designed, validated protocol
recorded at the seam by worker-side producers — the screen still authors
nothing, and the worker still never narrates the screen.
Two halves stay open, both named in the superseding page: live display
still rides the Kind stream (the printers fold the log only on resume), and
transcript.jsonl remains an independently written trace, not the filtered
projection the dissolving mechanism predicted. One promise of this page is
also still seam-incomplete: the UI-thread recording emitter built here feeds
the transcript, but the one log’s own vocabulary for UI-authored facts
(Forensic::SystemNote, ModelChanged) has no production emit site yet — a
model switch records in the trace, not in the log.
Superseding note: the transient exception is withdrawn
--no-logs — named above as “the explicit transient exception” — is deleted
outright, and with it the second code path it forced through the whole record
layer: an optional writer, an optional Ledger.source, a NoLog break in the
lineage walk, a two-branch fork and clear, and a head marker that had to
say the store was unreachable. Every session now has a record.jsonl, so
“written for every durable session” reads simply as every session.
It is withdrawn because it bought nothing. The flag’s own help offered
transience, not secrecy — the session cannot be resumed after Exarch exits —
and a session directory inside a TempDir, which AgentLog::for_test already
builds, gives exactly that: nothing survives the process, and the record layer
does not have to know. Transience is a property of where the session
directory lives, not a mode the seam and both folds must carry. What the
exception actually cost was the door: a departed exchange under the flag was
gone for good, and every read path had to carry the refusal that said so
(context-rollover). Emitter::none()
survives as a #[cfg(test)] recorder that stamps and publishes with no store,
which is a different thing from a session mode.
The diagnosis
The bus began life as a display stream with exactly one consumer — the frontend that draws it. Under one consumer, “on the bus” and “shown to the user” were the same fact, and three shortcuts were harmless that are not harmless now.
-
Recording-worthiness was decided by origin. The worker can only speak through
Emitter::emit, and emit now also writes the trace — so everything the worker says is recorded, even a no-op acknowledgement. The TUI, conversely, draws by synthesising anEventand feeding its ownhandle(exarch/src/tui.rs:3088,:3096) — bypassing the emit seam — so nothing the UI says is recorded, even a real state change. The line between “logged” and “not logged” fell where the threads happened to sit, not where meaning is. Two concrete wrongs result: a model switch (tui.rs:3088) is operational but the UI raises it, so it is lost from the trace; “nothing to compact” (exarch/src/session.rs:813) is a no-op but the worker raises it, so it is recorded. -
The screen invents events.
apply_model_switch(tui.rs:3057), the slash legend (tui.rs:2840), the clipboard ack (tui.rs:2876), the export ack (tui.rs:2906), andnote_error(tui.rs:3096) all buildEvent { kind: … }values and push them throughApp::handle. Some of these are view decoration that should never be recorded (legend, clipboard, export); one is a genuine event that should (model switch). The same mechanism serving both is why neither outcome is correct. -
The vocabulary names appearance, not role.
Kind::Dimnames how a thing is drawn (dimly), where every other operationalKind—Step,ToolCall,Usage,StopReason,Nudge,Error— names what happened. The cost was invisible while the bus only fed a renderer; the moment the trace became a second consumer, the operational log began carrying{"kind":"dim"}— an appearance word in a record meant to say what the agent did. A post-mortem reader learns the colour, not the meaning. -
Operational notes leak into the model view.
note_dim(session.rs:922) writes bothevents.jsonl(viarecord_dim,exarch/src/event.rs:704→SessionEvent::Dim,:227) and the bus. But a compaction notice or a model switch is not a message the model saw; it has no place in the artefact whose job is exact model-context replay.
The decision
A per-session operational trace, fed at the emit seam
transcript.jsonl is owned by the Session,
created in Session::assemble beside its events.jsonl, so the root and every
forked child get one — in headless and the TUI alike. It is a
Transcript handle the
Emitter carries; Emitter::emit tees every event
to it before broadcasting. Recording therefore rides the emitter, not the bus
channel’s lifetime: a headless child muted from the display (its receiver dropped,
exarch/src/tools/agent.rs) still records its full trace, because session_lived
now governs only streaming to a live tab, not persistence. The trace records
operational events only — the rendering-only Card, the card half of a
Kind::Io, and Kind::Phase project to nothing (exarch/src/transcript.rs
event_record); their place is the rendered user.log.
Nudges are operational
A driver nudge is the harness re-prompting the model; it is something that
happened, not a thing drawn. Kind::Nudge { used, max, cause }
(exarch/src/bus.rs) is recorded in the trace at the emit seam; the display
chooses whether to surface it (a [nudge n/m: cause] line in headless, quiet on
the TUI rail). Its events.jsonl twin (SessionEvent::Nudge) stays a forensic
breadcrumb in the log. This proved out the principle: “off the display” and “off the
trace” are now distinct, decided per consumer.
Name the role, not the appearance: Kind::Dim → Kind::SystemNote
Kind::Dim becomes Kind::SystemNote(String): an operational note the agent’s
driver issued — a truncation recovery, a compaction step. The display still
renders it dim; dimness is the renderer’s choice, not a fact in the vocabulary.
The trace records {"kind":"system_note"}, which says what it is.
No operational notes in the model view
SessionEvent::Dim and record_dim (event.rs) are removed. note_dim
(session.rs:922) becomes note, which emits Kind::SystemNote through the
emitter — recorded in the trace only. Operational notes are uniformly
transcript-only; the model projection is determined by the event log, which may
also retain forensic breadcrumbs. This is the
model/operational split made total: the same kind of fact lives in one place
whoever raised it.
The screen never invents events; the worker never narrates the screen
The UI stops synthesising Events. It does exactly two honest things — draw the
events that arrive, and draw its own local decoration — and each former
App::handle(Event{…}) injection sorts into one of them:
- Model switch is a real event.
apply_model_switch(tui.rs:3057) is given a recording emitter (minted on the UI thread from the bus, carrying the root’stranscript(), cloned before the worker takes the session) and emitsKind::SystemNote("[switched to …]"). It records in the trace and draws through the normal path — no UI-invented event, no special save. - View chrome and UI errors — the slash legend, clipboard ack, export ack, and
note_error— are view decoration, never recorded. They draw straight to the viewport through a small helper (App::push_note/App::push_error) instead of building a counterfeitEvent. Drawn, not recorded — which is correct, and now they no longer masquerade as events. - “Nothing to compact” (
session.rs:813) is dropped. It is a no-op acknowledgement, not an event; the absence of acompactednote already says nothing happened, and the worker has no honest way to draw view-only chrome.
Why this shape
- One axis, decided on purpose. “Is this recorded?” becomes a property of the event’s kind, evaluated once at the emit seam, instead of an accident of which thread holds which handle. The rule is legible: operational kinds record; view decoration does not; the screen draws both but authors neither.
- The vocabulary stops lying. A
Kindanswers what happened; the renderer answers how it looks.SystemNote+ a dim rendering restores that split, the same one surface-carries-documents draws between a mark’s level of measurement and its visual variable. - Two records, one job each.
events.jsonlis the event log a replay or the protocol state machine folds into the model view;transcript.jsonlis the operational view a post-mortem reads. Pulling ordinary notes out of the log keeps the split honest, while projection-neutral breadcrumbs remain allowed. - Recording survives muting. Because the trace rides the emitter and not the channel, every forked child records its own trace whether or not anyone watches it — the property “all children are recorded” holds by construction.
Alternatives considered
- Give the UI a way to save (the first instinct). Rejected: it makes the screen a second author of durable state, the exact conflation this ADR removes. The UI should cause events, not save them.
- Keep
Kind::Dim; let the trace filter it out. Rejected: it leaves an appearance word in the operational vocabulary and pushes the role/appearance confusion downstream into every consumer. - Keep operational notes in
events.jsonltoo. Rejected: it would make ordinary operational notes part of the event vocabulary without making them determine the projection. Forensic breadcrumbs are a different category and are retained by the superseding context decision. - A second emit path, “broadcast without recording,” for worker-side view chrome (to keep “nothing to compact” on screen). Rejected as YAGNI for a single no-op message; dropping it is the honest move, and the only genuine worker view-chrome case does not exist once that one is gone.
- Record the view chrome too (legend, clipboard, export). Rejected: they are decoration of the view, not facts about the run; recording them would re-import the noise the model/operational split exists to keep out.
Consequences
- A model switch now appears in the trace; “nothing to compact” no longer does — recording finally tracks meaning.
Kind::Dim,SessionEvent::Dim,record_dim, and the four UIEventinjections retire; the UI gainspush_note/push_errorand a recording emitter. The bus vocabulary shrinks by an appearance word and gains a role word.- The trace is uniform: every operational note is
system_note, transcript-only, whoever raised it;events.jsonlholds projection-determining turns and its forensic breadcrumbs (Nudge,ProviderError). - The screen is a pure consumer again: it draws events and its own chrome, and can no longer fabricate durable state.
Implementation plan
Landed in order; each compiled and the suite passed between steps.
1 Rename Kind::Dim -> Kind::SystemNote; render dim in tui + headless; trace projects "system_note"
2 Model view delete SessionEvent::Dim + record_dim; note_dim -> note (emit only); update event.rs tests
3 UI causes UI-thread recording emitter; apply_model_switch emits SystemNote; drop the app.handle inject
4 UI chrome App::push_note / push_error; legend, clipboard, export, note_error draw direct (no Event)
5 Drop remove the "nothing to compact" note entirely
No new tests; the event.rs Dim round-trip test was rewritten to assert only
the model-view breadcrumbs (Error, Nudge) that remain. Threading the
UI-thread emitter through the command path bundled the picker handles into a
CommandCtx (exarch/src/tui.rs), so ui_loop → route_submit → pick_model
→ apply_model_switch carry one context rather than a fistful of handles.
See also
tui-transcript-as-graphic (name the
datum’s role, never its appearance — the principle this extends from the rail to
the event vocabulary), surface-carries-documents
(the Card/Mark split between data and presentation; events.jsonl as a
structured machine record), run-turn-host-loop
(core carries raw Value and names no rail vocabulary; this is an exarch-only
concern), and the as-built arm it re-grounds: frontend,
map: exarch.