# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-trace-reconstruction-across-a2a-hops-when-otel-genai-spans-are-correlated-by-trace-id-alone.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-trace-reconstruction-across-a2a-hops-when-otel-genai-spans-are-correlated-by-trace-id-alone.warrant.json
# Markdown SHA-256:   05c8441900e548741b83e4456285b5f2656819580e88e61f467e87165240429e
# Code AST SHA-256:   85373dc8573d22d9fe5bf0439e16a86802f90d7817ba223030c65bf45d06ba08
# Verified Classes:   TraceReconstructionError, Span
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import os
from dataclasses import dataclass
from typing import Optional


class TraceReconstructionError(RuntimeError):
    """Raised when an A2A hop severs the causal edge required for end-to-end reconstruction."""
    pass


@dataclass(frozen=True)
class Span:
    trace_id: str
    span_id: str
    parent_span_id: Optional[str]
    operation: str
    context_id: Optional[str] = None


def assert_causal_edge(caller: Span, callee: Span) -> None:
    """Validate that the callee's first span is a child of the caller across an A2A hop."""
    if caller.trace_id != callee.trace_id:
        raise TraceReconstructionError(
            f"trace_id split across A2A hop: caller={caller.trace_id!r} "
            f"callee={callee.trace_id!r}; propagator likely missing 'tracecontext' "
            f"(check OTEL_PROPAGATORS on the callee process)"
        )
    if callee.parent_span_id != caller.span_id:
        raise TraceReconstructionError(
            f"parent edge broken: callee.parent_span_id={callee.parent_span_id!r} "
            f"!= caller.span_id={caller.span_id!r}; callee may have started a new root"
        )


def reconstruct(spans: list[Span]) -> dict[str, list[Span]]:
    """Group spans by trace_id and verify every non-root parent resolves within its trace."""
    by_trace: dict[str, list[Span]] = {}
    for s in spans:
        by_trace.setdefault(s.trace_id, []).append(s)

    for trace_id, group in by_trace.items():
        ids = {s.span_id for s in group}
        for s in group:
            if s.parent_span_id is not None and s.parent_span_id not in ids:
                raise TraceReconstructionError(
                    f"dangling parent in trace {trace_id!r}: span {s.span_id!r} "
                    f"references parent {s.parent_span_id!r} not present in trace; "
                    f"reconstruction is a forest, not a tree (do not stitch by context_id)"
                )
    return by_trace


def check_propagator_env(env: dict[str, str]) -> None:
    propagators = env.get("OTEL_PROPAGATORS", "")
    if "tracecontext" not in [p.strip() for p in propagators.split(",")]:
        raise TraceReconstructionError(
            f"OTEL_PROPAGATORS={propagators!r} lacks 'tracecontext'; "
            f"every A2A hop will start a new root span and break end-to-end correlation"
        )
