Skip to content

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 wired caused_by/context through 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.