# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/where-must-an-instance-record-the-tool-call-boundary-when-opentelemetry-genai-semantic-conventions-disagree-with-the-agent-runtime-s-own-span-lifecycl.md
# Remnant Warrant:    https://corpusfetch.com/c/where-must-an-instance-record-the-tool-call-boundary-when-opentelemetry-genai-semantic-conventions-disagree-with-the-agent-runtime-s-own-span-lifecycl.warrant.json
# Markdown SHA-256:   26034271a0446efedf523b85d0615e4003e8c5f19953965a7e23cb7f8d9d1d4e
# Code AST SHA-256:   c7e4f68ea081ca291115ad7b6c0943333f6e41441631d64d59a387746789a468
# Verified Classes:   ToolCallBoundaryError, ToolCallSpan
# Defensive Guards:   4 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional


class ToolCallBoundaryError(RuntimeError):
    """Raised when a tool-call span is opened at executor entry rather than at call definition."""


@dataclass
class ToolCallSpan:
    call_id: str
    parent_chat_span_id: str
    started_at_call_definition: bool
    resolved: bool = False
    outcome: Optional[str] = None  # "executed" | "rejected" | "cancelled" | "expired"
    attempt_spans: list[str] = field(default_factory=list)


def open_tool_call_span(
    call_id: str,
    parent_chat_span_id: str,
    *,
    opened_at_executor_entry: bool,
) -> ToolCallSpan:
    if not call_id:
        raise ToolCallBoundaryError(
            "tool call span opened without gen_ai.tool.call.id; "
            "the id must be bound at call definition, not at execution"
        )
    if opened_at_executor_entry:
        # This is the silent-failure path: the span will be a sibling of the
        # chat span, orphaned from the model turn that produced the call, and
        # any call rejected/cancelled before execution will be dropped.
        raise ToolCallBoundaryError(
            f"tool call {call_id!r} span opened at executor entry; "
            "anchor the span at call definition (parent="
            f"{parent_chat_span_id!r}) so cancelled and retried calls are preserved"
        )
    return ToolCallSpan(
        call_id=call_id,
        parent_chat_span_id=parent_chat_span_id,
        started_at_call_definition=True,
    )


def resolve_tool_call(span: ToolCallSpan, outcome: str) -> ToolCallSpan:
    if outcome not in {"executed", "rejected", "cancelled", "expired"}:
        raise ToolCallBoundaryError(f"unknown tool call outcome {outcome!r}")
    if span.resolved:
        raise ToolCallBoundaryError(
            f"tool call {span.call_id!r} already resolved as {span.outcome!r}; "
            "retries must be child attempt spans, not re-resolutions of the logical call"
        )
    span.resolved = True
    span.outcome = outcome
    return span
