Skip to content

Design decisions

This project made several non-obvious calls. Their reasoning previously lived only in gitignored specs — this page exists so an adopter deciding whether to trust the tool doesn't have to take them on faith.

Per-writer segments, not a shared file

A Hansard session is a directory, not a single append-only file. Each writer — one per human participant's client, one per agent process — opens its own .jsonl segment inside that directory.

The reason is not aesthetic: concurrent appends from multiple OS processes to one file are not portably atomic. Two writers racing to append to the same file can interleave partial writes and corrupt the log — a real failure mode, not a theoretical one, for exactly the multi-writer scenario this tool exists to capture. Giving every writer its own file makes that class of corruption unreachable by construction, not merely unlikely with careful locking. replay/verify/inspect read every *.jsonl segment in the directory and merge them in (ts, w, seq) order into one coherent stream — see The log format.

Append-only corrections, never rewrites

A wrong record — a turn that raised after end() was called, a misattributed user_id — is corrected by appending a correction event, never by rewriting the original line. The answer to "the record is wrong" is always another record, not an edit to history.

This is what makes Hansard an audit tool rather than a mutable log with audit pretensions: a reader that ignores corrections sees exactly what was originally recorded, and a reader that applies them (the default for replay/verify/inspect) sees the effective view with provenance — which correction(s) touched this event, and why — never a silently modified value. hansard replay --raw shows the untouched original.

null vs [] causality

context_message_ids and caused_by are typed list[str] | None, and the two "empty" values are not interchangeable:

  • null — no causality hint was supplied. Infer.
  • [] — the agent recorded, as a fact, that there was nothing (no messages seen, no cause). Trust it.

Collapsing the two into one "empty" case would make both of them a guess. A caller that explicitly records "I began this turn having seen nothing" has told you something true and useful — that's a fact worth full confidence, not noise to be treated the same as silence. See The log format for the full explanation and Attribution for how each is scored (recorded_no_cause/recorded_empty_context at 1.0/0.9, versus falling through to the inference rules).

Confidence as an honesty contract

The confidence numbers attached to each attribution method are not tuning knobs to be nudged toward a better-looking average. They are a direct statement about how the verdict was reached:

  • explicit is 1.0 because the agent recorded it — a fact, not an inference.
  • contested is 0.3 (0.2 cross-segment) because the engine genuinely does not know which of several people caused the action, and that ignorance is worth stating exactly.

A cross-segment penalty (0.6→0.4 for temporal, 0.3→0.2 for contested) applies only when the evidence a rule used spans a writer boundary — because within one writer, seq makes ordering exact, while across writers only wall-clock ts exists, and clocks skew. Critically, this penalty applies only to inference, never to declarations: explicit and turn_context never take it, no matter which segment the cited message lives in. A segment boundary does not make a recorded fact less true — it only makes a guess about ordering less trustworthy. See Attribution for the full rule table.

Attribution derived at read time, never written

Nothing about "who caused this" is ever computed at write time and baked into the log. attribute() runs over the log's recorded facts every time it's called — from hansard replay, hansard inspect, or directly.

The consequence: improving the attribution engine improves every session ever recorded, retroactively, with no migration and no rewrite. A rule fixed today makes yesterday's sessions read more accurately tomorrow, for free.

Well-instrumented agents never reach the inference rules

turn_context (a declaration) is rule 3 in the eight-rule chain, and the chain is first-match-wins. temporal, contested, and cascade — the three inference rules — exist entirely for integrators who supply no causality hints at all.

This is worth stating plainly because it surprises people: it means the entire inference half of the attribution engine is, by design, a fallback for an unwired integration, not the tool's primary mode. The measured comparison on the Home and Attribution pages — 0.94 average confidence with hints, 0.33 without, on one real captured run of the identical scenario — is the direct, measured consequence of this rule ordering, not a separate claim about "how good the guessing is." Those two specific numbers are illustrative of the size of the gap, not universal constants (a different model or script will land elsewhere); the rule ordering that produces the gap is the load-bearing claim. The honest fix for a low-confidence session is almost always "pass caused_by/context," not "tune the inference rules harder."