The log format¶
The log format is the product at this stage of the project — get it right before building anything on top of it. This page documents what's actually on disk.
A session is a directory¶
hansard.session() never opens a single shared file. It opens a
directory, and each writer (a client terminal, an agent process — one
per concurrent participant) appends to its own .jsonl segment inside it.
Concurrent appends from multiple OS processes to one file are not
portably atomic; a directory of per-writer segments makes that class of
corruption unreachable by construction instead of merely unlikely. See
Design decisions for the fuller reasoning.
A real session directory, captured from the Quickstart example, contains exactly one segment (one writer, in this case):
sessions/s_01KZVVGRNR3D5V708Z4NPGG11H/
└── w_01KZVVGRNRKM87Z6X6TXYQR7EX.jsonl
hansard.replay/verify/inspect read every *.jsonl segment in the
directory and merge them in (ts, w, seq) order into one ordered stream —
that merge is what lets three people's simultaneous terminals and one
agent process, four separate files, replay back as a single coherent
transcript.
One flat JSON object per line¶
Every event serializes to a single flat JSON object at the top level — one
line, no multi-line pretty-printing — so a line stays greppable and
readable in a plain editor. That does not mean every value inside the
object is a scalar: writer.start's meta is itself a small object, since
it groups everything about where a writer ran. This is real, unedited
output (with host/pid replaced by neutral placeholders — see below):
the first three lines of the segment above.
{"v":3,"sid":"s_01KZVVGRNR3D5V708Z4NPGG11H","w":"w_01KZVVGRNRKM87Z6X6TXYQR7EX","seq":1,"id":"w_01KZVVGRNRKM87Z6X6TXYQR7EX","ts":"2026-08-12T20:44:44.089961Z","mono":0.001907,"type":"writer.start","writer_id":"w_01KZVVGRNRKM87Z6X6TXYQR7EX","meta":{"agent":"support-bot","app":null,"hansard_version":"0.1.0","host":"demo-host","pid":12345},"redacted":[]}
{"v":3,"sid":"s_01KZVVGRNR3D5V708Z4NPGG11H","w":"w_01KZVVGRNRKM87Z6X6TXYQR7EX","seq":2,"id":"m_01KZVVGRNTBMSD610TK2GA9BFS","ts":"2026-08-12T20:44:44.090132Z","mono":0.002062,"type":"message","user_id":"priya","text":"restart the payments worker","client_ts":null,"redacted":[]}
{"v":3,"sid":"s_01KZVVGRNR3D5V708Z4NPGG11H","w":"w_01KZVVGRNRKM87Z6X6TXYQR7EX","seq":3,"id":"t_01KZVVGRNT05FRT0FDW3NQZ7Q6","ts":"2026-08-12T20:44:44.090201Z","mono":0.00213,"type":"turn.start","turn_id":"t_01KZVVGRNT05FRT0FDW3NQZ7Q6","context_message_ids":["m_01KZVVGRNTBMSD610TK2GA9BFS"]}
Envelope fields (every event carries these, and they are immutable)¶
| field | meaning |
|---|---|
v |
schema version (currently 3) |
sid |
session id — matches the containing directory's name |
w |
the writer (segment) that wrote this event |
seq |
monotonic sequence number within this writer's segment |
id |
this event's own id (ULID, prefixed by type — m_, t_, a_, e_, w_...) |
ts |
wall-clock ISO-8601 UTC timestamp |
mono |
a monotonic clock reading, for ordering within one writer even if wall-clock skews |
Event types¶
| type | what it records |
|---|---|
writer.start |
first event in every segment — self-describing (session, agent, host, pid, library version) so a segment recovered on its own still identifies itself |
participant.join / participant.leave |
a person entering/leaving the session |
message |
a message from a human participant |
turn.start |
the agent beginning a unit of work; carries turn_id and optionally context_message_ids |
agent.action |
a tool call the agent made; carries caused_by |
agent.action_result |
the outcome of that action (status, output/error) |
agent.output |
text the agent produced; carries caused_by |
turn.end |
the turn concluding, with a status |
note |
a free-text annotation |
correction |
an append-only correction to a field of an earlier event (see below) |
writer.end |
last event in a segment — nothing follows it in this file |
An event type this build doesn't recognize is preserved, not dropped, as an
UnknownEvent — a newer writer's log stays readable by an older reader
instead of erroring or silently losing what happened. The same applies at
the field level: unrecognized fields on a known event type round-trip
losslessly through extra, and readers are deliberately lenient — a
missing optional field falls back to a default rather than raising, so an
old writer's line never breaks a newer reader or vice versa.
null vs []: two different, non-interchangeable facts¶
TurnStart.context_message_ids and AgentAction/AgentOutput.caused_by
are all typed list[str] | None, and the two empty-ish values mean
opposite things:
null— no causality hint was supplied at all. The common case for an integrator who hasn't wiredcaused_by/contextthrough yet. Attribution falls back to inference (temporal,contested,cascade).[]— the agent recorded, as a fact, that nothing caused this action, or that it began the turn having seen no messages. That is itself a recorded fact — not a hint absence — and it is trusted at full confidence (recorded_no_cause,recorded_empty_context) rather than handed to the inference rules.
Collapsing the two into one "empty" case would make both of them a guess: the honest declaration "nothing caused this" would lose its status as a recorded fact, and "nothing was said" would lose its distinction from "something was said, just not to me." See Attribution for how each is scored, and Design decisions for why this distinction is treated as load-bearing rather than a minor implementation detail.
Corrections are appended, never rewritten¶
The log is append-only. A correction to an already-written event — for
example, a turn that raised after end() was called and needs its status
fixed — is a separate correction event appended afterward, never a
rewrite of the original line. hansard.corrections.apply_corrections
folds corrections into an effective view with provenance (which
correction(s) touched this event, and why), leaving the original records
on disk untouched.
A reader that ignores corrections sees exactly what was originally
recorded — that's the right default for an audit tool, since a consumer
must never see a modified value without knowing it was modified.
hansard replay --raw shows the log as originally written, skipping
apply_corrections; every other read (the default replay, verify,
inspect) applies them. Envelope fields (v, sid, w, seq, id,
ts, mono), type, and extra are never correctable — a correction may
change what an event says, never what it is.