Attribution¶
This is the heart of the product. For any agent action, Hansard determines which person's instruction caused it — and when it genuinely cannot tell, it says so instead of guessing.
Attribution is derived at read time, never written to the log. The
rules below run over the log's own recorded facts (caused_by,
context_message_ids, message timing) every time you call attribute()
or run hansard replay/inspect/verify — nothing about "who caused
this" is ever baked into a session file. Improving the attribution engine
improves every session ever recorded, retroactively, without touching a
single byte on disk.
The measured comparison¶
Run the identical messy scenario — two people's messages landing close
together, one person's deploy failing and needing a retry — twice against
the same live agent (OpenRouter's openai/gpt-oss-20b): once with
caused_by/context hints wired through, once with --no-context-hints
so the agent declares nothing.
| average confidence | methods seen | |
|---|---|---|
with caused_by/context hints |
0.94 | explicit, turn_context |
without (--no-context-hints) |
0.33 | temporal, contested, cascade |
Both numbers are from one real, captured pair of live runs, not estimates
and not universal constants — a different model or script will land on
different numbers, but the direction (declaring causality turns inference
into a recorded fact) is the durable claim. Both sessions are tracked at
examples/ —
run hansard inspect examples/with-context-hints/s_x --json and hansard
inspect examples/without-context-hints/s_x --json yourself to reproduce
these exact figures. The rest of this page explains exactly how each
figure was reached.
With hints: explicit and turn_context¶
Session s_x, captured 2026-08-12, tracked at
examples/with-context-hints/
— priya's message lands, then sam's correction and jordan's deploy
instruction land close together while the agent is mid-turn; every turn
honestly declares what it saw:
21:55:39.188 priya Write notes.txt containing 'hello team'.
21:55:39.217 - turn begins
agent saw: priya's message
21:55:40.034 * write_file(path="notes.txt", content="hello team")
`- caused by priya | recorded by the agent | 1.0
21:55:41.359 * output: ✅ `notes.txt` has been created with the content: ``` hello team ``` Let me
know if you need anything else!
`- caused by priya | turn context | 0.9
21:55:41.359 - turn ends (ok)
21:55:51.194 sam Actually call it status.txt instead.
21:55:51.224 - turn begins
agent saw: sam's message
21:55:51.389 jordan Deploy it to prod now.
21:55:52.230 * write_file(path="status.txt", content="hello team")
`- caused by sam | recorded by the agent | 1.0
! arrived less than a second into the turn -- the agent had already committed to sam's
instruction
! possible conflict with jordan's message
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:55:54.943 * output: ✅ The content has been written to **`status.txt`**: ``` hello team ```
`notes.txt` was the original file; if you need it removed or updated, let me know!
`- caused by sam | turn context | 0.9
! arrived less than a second into the turn -- the agent had already committed to sam's
instruction
! possible conflict with jordan's message
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:55:54.943 - turn ends (ok)
21:55:54.944 - turn begins
agent saw: jordan's message
21:55:56.185 * deploy(target="prod")
`- caused by jordan | recorded by the agent | 1.0
21:55:56.186 x action failed (status=error): deploy to 'prod' failed: connection refused (attempt
1)
21:55:57.122 * deploy<|channel|>commentary(target="prod")
`- caused by jordan | turn context | 0.9
21:55:57.123 x action failed (status=error): unknown tool: 'deploy<|channel|>commentary'
21:55:58.413 * output: It looks like the deployment to **prod** failed due to a connection issue
(`connection refused`). You might want to check your network or deployment
environment settings and try again. Let me know if you need help diagnosing the
problem or if you'd like me to attempt the deploy again.
`- caused by jordan | turn context | 0.9
21:55:58.413 - turn ends (ok)
1.0 (recorded by the agent, a caused_by fact) and 0.9 (turn
context, a context_message_ids fact) — the engine trusting exactly what
the runtime recorded, even for sam's message that arrived mid-turn and
conflicted with jordan's.
Without hints: temporal, contested, and cascade¶
Same script, session s_x, captured 2026-08-12, tracked at
examples/without-context-hints/.
Nothing is declared, so the messages that landed close together are now
genuinely ambiguous evidence, not a disclosed fact:
21:56:13.880 priya Write notes.txt containing 'hello team'.
21:56:13.895 - turn begins
21:56:14.841 * write_file(path="notes.txt", content="hello team")
`- caused by priya | last message before the turn | 0.4
The last unconsumed message before the turn began was from priya, in another
writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:16.679 * output: 📄 `notes.txt` has been created with the contents: ``` hello team ```
`- caused by priya | last message before the turn | 0.4
The last unconsumed message before the turn began was from priya, in another
writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:16.679 - turn ends (ok)
21:56:25.879 sam Actually call it status.txt instead.
21:56:25.885 - turn begins
21:56:26.076 jordan Deploy it to prod now.
21:56:26.639 * write_file(path="status.txt", content="hello team")
`- caused by sam | last message before the turn | 0.4
The last unconsumed message before the turn began was from sam, in another writer's
segment -- ordering is wall-clock only.
! arrived less than a second into the turn -- the agent had already committed to sam's
instruction
! possible conflict with jordan's message
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.459 * output: ✅ `status.txt` has been created with the contents: ``` hello team ```
`- caused by sam | last message before the turn | 0.4
The last unconsumed message before the turn began was from sam, in another writer's
segment -- ordering is wall-clock only.
! arrived less than a second into the turn -- the agent had already committed to sam's
instruction
! possible conflict with jordan's message
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.459 - turn ends (ok)
21:56:27.459 - turn begins
21:56:27.911 * deploy(target="prod")
`- one of sam or jordan | ambiguous -- several candidates | 0.2
Multiple unconsumed messages arrived close together before the turn began, from sam
and jordan; which one the agent acted on cannot be determined. Some of that
evidence is from another writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.912 x action failed (status=error): deploy to 'prod' failed: connection refused (attempt
1)
21:56:31.233 * output: 🚨 Deployment to **prod** failed ❌ The attempt to deploy the current state
to the `prod` target was rejected with a "connection refused" error (attempt 1).
**What you can try next:** 1. **Verify network connectivity** – make sure the
deployment endpoint is reachable and that you have the necessary credentials (e.g.,
API key, SSH key, or OAuth token). 2. **Retry the deployment** – sometimes a
transient network issue can cause a connection error. 3. **Check the deployment
configuration** – confirm that the target `prod` is correctly defined (correct
namespace, environment, etc.). 4. **Review logs** – if you have an event log or
deployment history, look for any detailed error messages that might give more
context. 5. **Contact support** – if the problem persists, reach out to the
platform's support team or check status pages for outages. If you'd like, I can
attempt another deploy command now (e.g., `deploy target=prod`) after you've
verified the connectivity or fixed any configuration issues. Just let me know what
you'd like to do next!
`- one of sam or jordan | retry after a failed action | 0.2
This follows a failed action and is attributed the same as that action, to sam or
jordan. That earlier attribution was itself contested -- which one of them actually
caused it could not be determined, so this retry's cause is equally uncertain.
! this is a retry after the previous attempt on this turn failed
! the attempt this retries was itself contested -- which candidate actually caused it
could not be determined
21:56:31.233 - turn ends (ok)
contested at 0.2 says, in plain language, "two people spoke close enough
together that I cannot tell which one caused this" — an honest non-answer
instead of a confident, invented one. priya's and sam's isolated messages
resolve via inference instead: temporal (0.4) on both of their actions
and outputs, and the deploy retry lands on cascade (0.2 — inherited from
the contested verdict it followed, never a flat 0.85).
The eight rules¶
Rules run in order; the first match wins. Rules 1–4 are declarations — facts the integrator (or the agent runtime on its behalf) actually recorded — and never take a cross-segment confidence penalty. Rules 5–8 are inference — the engine's own best guess when nothing was declared.
| # | method | confidence | what it means |
|---|---|---|---|
| 1 | explicit |
1.0 (0.8 / 0.5 if a cited id doesn't resolve) | the action/output's caused_by named the exact message(s) that caused it |
| 2 | recorded_no_cause |
1.0 | caused_by == [] — the agent recorded that nothing caused this action |
| 3 | turn_context |
0.9 (0.7 / 0.5 if a cited id doesn't resolve) | the turn's context_message_ids named everything the agent could see; attributed to all of them |
| 4 | recorded_empty_context |
0.9 | context_message_ids == [] — the agent recorded it began the turn having seen no messages |
| 5 | cascade |
inherited confidence, capped at 0.85 | a retry with no interrupting message; inherits the failed attempt's own attribution |
| 6 | temporal |
0.6 (0.4 cross-segment) | the last unconsumed message before the turn began — a guess, labeled as one |
| 7 | contested |
0.3 (0.2 cross-segment) | two or more unconsumed messages arrived within the ambiguity window; every candidate is reported, none favored |
| 8 | unattributed |
0.0 | nothing else matched — said plainly, not silently omitted |
Well-instrumented agents never reach rules 5–8. turn_context is rule
3 and first-match-wins; the inference rules (temporal, contested,
cascade) exist entirely for integrators who supply no causality hints.
This is worth stating plainly because it surprises people: passing
context=[...] on every turn is enough, by itself, to keep a session out
of the inference path almost entirely — as the "with hints" run above
shows (explicit, turn_context only, no temporal/contested/cascade
anywhere in the transcript).
Markers: what a reader is being told beyond the number¶
An Attribution can also carry markers — structural flags, not a
confidence adjustment of their own:
| marker | meaning |
|---|---|
cross_segment |
some of the evidence used lives in a different writer's segment than the event being attributed — cross-writer ordering is wall-clock only, not exact (this is why temporal/contested apply their lower, cross-segment confidence tier) |
after_failed_action |
this is a cascade verdict: a retry, inheriting the failed attempt's own attribution |
inherited_contested |
a cascade whose inherited verdict was itself contested — candidates are mutually exclusive possibilities ("one of X or Y"), not a joint cause |
unresolved_reference |
a declaration (explicit/turn_context) cited a message id that doesn't resolve to a real message in this session (missing segment, malformed id) |
arrived_mid_turn |
a message landed strictly between this turn starting and the event being attributed |
possible_conflict |
an earlier instruction was still credited as "in flight" (a candidate predates the turn start) when a mid-turn message also arrived |
via_correction |
this attribution's verdict changed because of an appended correction event |
cyclic_retry_chain |
the log's own action_id retry chain loops back on itself — a diagnostic about log corruption, not an ordinary "no cause found" |
malformed_timestamp |
a timestamp needed to order this event couldn't be parsed — also a log-integrity diagnostic |
Reading a verdict¶
hansard replay's cause line reads:
`- caused by priya | recorded by the agent | 1.0
caused by priya is the resolved candidate(s); recorded by the agent
is the plain-English rendering of the method (explicit); 1.0 is the
confidence. When candidates are mutually exclusive rather than joint —
contested, or a cascade inheriting from one — the line reads one of X
or Y instead of X and Y, and a longer evidence sentence follows,
explaining exactly why the engine couldn't resolve further.
Confidence as an honesty contract¶
The numbers above are not tuning knobs to be adjusted for a better-looking
average. explicit is 1.0 because the agent recorded it as a fact, not an
inference. contested is 0.3 (or 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, not smoothing over. See Design
decisions for the full reasoning behind this
principle and the calls that follow from it.