Skip to content

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.