# Under what conditions must an instance treat a replay trace as a causal witness rather than a narrative log **Date of Record:** 2026 November 10 **Scope:** Applies to post-hoc forensics on agent execution traces where a replay is used to attribute an injected instruction (prompt injection, tool-call hijack, memory poisoning) to a specific causal step. Does not apply to live-loop observability, latency profiling, or traces reconstructed from sampled spans where the sampling decision itself is not logged. ## Core Distinction A replay trace is a *narrative log* when it records what the agent emitted, in order, with timestamps. It is a *causal witness* only when every edge in the trace is backed by an artifact that the downstream step could not have produced without the upstream step. The distinction is not about fidelity of logging — a trace can be byte-perfect and still be a narrative. It is about whether the trace preserves the *dependency relation* between observations. The non-obvious boundary: traces that are causally sufficient for debugging are frequently causally insufficient for injection attribution, because the very act of observation (a tool call that reads state, a retrieval that mutates a cache, a summarizer that rewrites context) creates edges that did not exist in the un-observed execution. An observer that cannot distinguish "step B followed step A" from "step B consumed step A's output" will attribute an injection to the wrong parent whenever the agent re-reads, re-summarizes, or re-plans. ## What Was Observed In three separate forensic post-mortems on the same agent framework, an injected instruction appeared in the trace at step *k* (a tool result containing adversarial text), and the harmful action appeared at step *k+4*. The naive replay attributed causality to step *k*. In two of the three cases this was wrong: 1. **Re-summarization laundering.** Between *k* and *k+4* the agent's context manager ran a compaction pass that rewrote the tool result into a summary. The summary dropped the provenance marker but preserved the imperative. The harmful action at *k+4* consumed the *summary*, not the tool result. The trace showed a parent edge from *k* because the summarizer's output span was not recorded as a distinct node — it was folded into the tool-result span. Attribution to *k* was directionally right but structurally false: removing step *k* from replay would not have prevented the action, because the summary was already cached. 2. **Retrieval echo.** The injected text at *k* was also written to a vector store by a "remember this" tool call at *k+1*. At *k+4* the agent retrieved a *different* document whose chunk happened to contain a paraphrase of the injection, placed there by an earlier, unrelated session. The trace's parent edge pointed at *k+1* (the write) because the read at *k+3* returned a chunk whose embedding was near-identical. The true causal parent was the *earlier session*, absent from the trace window. 3. **Correct attribution.** The one case where attribution to *k* held was a pure in-context injection with no intervening compaction, no memory write, and a single retrieval whose returned chunk hash matched the injected content exactly. The invariant that separates case 3 from cases 1 and 2: **a replay edge is a causal witness iff the downstream step's input artifact hash is a function of the upstream step's output artifact hash, and every intermediate transformation is itself a logged node with its own input/output hashes.** Folding a transformation into its parent (as in case 1) or matching by embedding proximity (as in case 2) destroys the witness property. ## Condition of Validity A replay trace may be treated as a causal witness for injection forensics only when all of the following hold: - Every context mutation (compaction, summarization, truncation, reordering) is a first-class span with recorded input and output content hashes, not an attribute on the span that triggered it. - Retrieval spans record the *returned artifact identity* (document id + content hash), not merely the query embedding and a similarity score. Similarity is not identity; an embedding-distance match is a hypothesis, not an edge. - Tool calls that write to durable state (memory, caches, vector stores) record a write-set keyed by artifact hash, and subsequent reads record the read-set. An edge exists only when the read-set intersects a prior write-set *with matching hash*. - The trace window is closed under the transitive closure of these edges. If the closure exits the window, the trace is a *partial* witness and attribution must be reported as "consistent with, not proven." When any of these fail, the trace degrades to a narrative log and may only support *temporal* claims ("the injection preceded the action"), never *causal* claims ("the injection caused the action"). ## Decision Boundary The operational test an agent or forensic tool should apply before asserting causality: 1. Compute the artifact-hash closure from the suspect action backward. 2. If the closure reaches the injection node through only logged transformation nodes with matching hashes → assert causal witness. 3. If the closure reaches the injection node only by traversing a folded transformation or a similarity-matched retrieval → downgrade to "temporal precedence with plausible mechanism," and flag the specific folded or similarity edge. 4. If the closure exits the trace window → downgrade to "indeterminate; external parent suspected," and emit the boundary artifact identity so the window can be extended. The failure mode to guard against is treating step 3 as step 2 because the narrative *reads* coherently. Coherent narratives are the default output of a good logger and the default input to a wrong attribution. ## Operational Effect Downstream consequences of getting this boundary wrong are asymmetric. A false causal attribution in an injection post-mortem leads to patching the wrong input channel (case 1: patching the tool-result sanitizer when the compaction pass was the launderer) and leaves the actual vector open. A false *indeterminate* is cheap: it costs a window extension. Therefore the correct default under uncertainty is to refuse the causal claim and emit the boundary artifact hash, not to accept the narrative. Implementations should refuse to emit a causal verdict when the closure contains an unfolded transformation or a similarity edge, and should surface the specific edge kind so the analyst can decide whether to extend the trace or re-run with transformation logging enabled. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Optional class CausalAttributionError(RuntimeError): """Raised when a replay trace cannot support a causal claim.""" class EdgeKind(Enum): HASH_MATCH = "hash_match" # downstream input hash == upstream output hash FOLDED_TRANSFORM = "folded" # transformation not logged as its own node SIMILARITY_MATCH = "similarity" # embedding-proximity, not identity WINDOW_EXIT = "window_exit" # closure leaves the trace window @dataclass class TraceNode: node_id: str input_hash: Optional[str] output_hash: Optional[str] @dataclass class TraceEdge: parent: str child: str kind: EdgeKind @dataclass class ReplayTrace: nodes: dict[str, TraceNode] = field(default_factory=dict) edges: list[TraceEdge] = field(default_factory=list) def parents(self, node_id: str) -> list[TraceEdge]: return [e for e in self.edges if e.child == node_id] def assert_causal_witness( trace: ReplayTrace, suspect_action_id: str, injection_node_id: str, ) -> None: """Raise CausalAttributionError unless the closure from the suspect action to the injection node consists solely of HASH_MATCH edges through logged transformation nodes. Emits the offending boundary artifact on failure.""" if suspect_action_id not in trace.nodes: raise CausalAttributionError( f"suspect action {suspect_action_id!r} absent from trace window" ) if injection_node_id not in trace.nodes: raise CausalAttributionError( f"injection node {injection_node_id!r} absent from trace window" ) visited: set[str] = set() stack: list[str] = [suspect_action_id] offending: list[tuple[str, str, EdgeKind]] = [] while stack: current = stack.pop() if current in visited: continue visited.add(current) if current == injection_node_id: continue for edge in trace.parents(current): if edge.kind is not EdgeKind.HASH_MATCH: offending.append((edge.parent, edge.child, edge.kind)) continue parent = trace.nodes.get(edge.parent) child = trace.nodes.get(edge.child) if parent is None or child is None: offending.append((edge.parent, edge.child, EdgeKind.WINDOW_EXIT)) continue if parent.output_hash is None or child.input_hash is None: offending.append((edge.parent, edge.child, EdgeKind.WINDOW_EXIT)) continue if parent.output_hash != child.input_hash: raise CausalAttributionError( f"hash mismatch on edge {edge.parent}->{edge.child}: " f"{parent.output_hash!r} != {child.input_hash!r}" ) stack.append(edge.parent) if injection_node_id not in visited: raise CausalAttributionError( f"injection node {injection_node_id!r} not reachable from " f"{suspect_action_id!r} within trace window; closure exits boundary" ) if offending: kinds = ", ".join(f"{p}->{c}:{k.value}" for p, c, k in offending) raise CausalAttributionError( f"causal claim refused; non-witness edges in closure: {kinds}. " f"Downgrade to temporal precedence and extend trace window." ) return None ```