# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-taint-from-surviving-mcp-tool-response-normalization-into-agent-reasoning-traces.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-taint-from-surviving-mcp-tool-response-normalization-into-agent-reasoning-traces.warrant.json
# Markdown SHA-256:   2af3402ef29c5dceb1c2d8d2b552618ce7fa5239935cc33904ddc0afab11e8d8
# Code AST SHA-256:   8e97f0f7833c2c3eac2d13b98746b3b62c29b59d1f926c086514b8407fa370c6
# Verified Classes:   TaintProvenanceError, ToolEnvelope, TraceObservation, Trace
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal


class TaintProvenanceError(RuntimeError):
    """Raised when a reasoning-trace observation cannot be tied to a tool-call envelope."""


@dataclass(frozen=True)
class ToolEnvelope:
    call_id: str
    taint: Literal["trusted", "untrusted"]
    content_blocks: tuple[str, ...]


@dataclass
class TraceObservation:
    text: str
    source_call_id: str | None  # must come from the envelope, never from model output


@dataclass
class Trace:
    observations: list[TraceObservation] = field(default_factory=list)


def emit_observation(
    trace: Trace,
    text: str,
    cited_call_id: str | None,
    envelopes: dict[str, ToolEnvelope],
) -> None:
    # Invariant: if any tool result is in context, every observation must cite a
    # call ID that resolves to a real envelope. Paraphrase is allowed; citation is not.
    if envelopes and cited_call_id is None:
        raise TaintProvenanceError(
            "observation emitted with tool results in context but no source_call_id; "
            "taint cannot be resolved at read time"
        )
    if cited_call_id is not None and cited_call_id not in envelopes:
        raise TaintProvenanceError(
            f"observation cites unknown call_id {cited_call_id!r}; "
            "provenance graph would contain a dangling edge"
        )
    trace.observations.append(TraceObservation(text=text, source_call_id=cited_call_id))


def read_time_taint(trace: Trace, envelopes: dict[str, ToolEnvelope]) -> set[str]:
    # Taint is resolved against the envelope, not the trace text.
    tainted: set[str] = set()
    for obs in trace.observations:
        if obs.source_call_id is None:
            raise TaintProvenanceError("uncited observation in trace; refusing to classify")
        env = envelopes[obs.source_call_id]
        if env.taint == "untrusted":
            tainted.add(obs.source_call_id)
    return tainted
