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:
explicitis 1.0 because the agent recorded it — a fact, not an inference.contestedis 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."