# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-instance-enforce-tool-call-authorization-at-the-policy-decision-point-rather-than-the-policy-enforcement-point.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-instance-enforce-tool-call-authorization-at-the-policy-decision-point-rather-than-the-policy-enforcement-point.warrant.json
# Markdown SHA-256:   b3c75bbd03b5a19cc7634c04adea75f6c6148acfca81abc1e5392d849a743c5d
# Code AST SHA-256:   04aac7d7c9186d6e56342ee49a3ef208bc2a20933702cdd52629881d84914bdc
# Verified Classes:   StaleAuthorizationDecisionError, ToolCallAuthorizer
# Defensive Guards:   4 fail-closed invariants (zero external deps)

class StaleAuthorizationDecisionError(RuntimeError):
    """Raised when a tool call would execute under a decision bound to a
    principal that no longer matches the presenting credential."""
    pass


class ToolCallAuthorizer:
    def __init__(self, pdp, revocation_sla_seconds: float):
        self.pdp = pdp
        self.revocation_sla_seconds = revocation_sla_seconds
        # Decisions are cached ONLY when the credential is immutable for the
        # cache window AND the cache key includes the full principal.
        self._decision_cache = {}

    def authorize(self, call, presenting_credential, tool_is_side_effecting: bool):
        principal = (presenting_credential.spiffe_id, presenting_credential.jti)
        cache_key = (principal, call.tool, call.args_digest)

        cached = self._decision_cache.get(cache_key)
        if cached is not None:
            if tool_is_side_effecting:
                raise StaleAuthorizationDecisionError(
                    f"side-effecting tool {call.tool} hit a cached decision for "
                    f"{principal}; side-effecting calls must be decided per-call"
                )
            if cached.ttl_remaining() > self.revocation_sla_seconds:
                raise StaleAuthorizationDecisionError(
                    f"cache TTL exceeds revocation SLA for {principal}"
                )
            return cached.decision

        decision = self.pdp.decide(
            principal=principal,
            tool=call.tool,
            args=call.args,
            context=call.context,
        )
        if not decision.allow:
            raise PermissionError(f"denied: {decision.reason}")

        if not tool_is_side_effecting:
            self._decision_cache[cache_key] = decision.with_ttl(
                min(decision.ttl, self.revocation_sla_seconds)
            )
        return decision


def invoke_tool(authorizer, call, credential, tool_is_side_effecting):
    try:
        authorizer.authorize(call, credential, tool_is_side_effecting)
    except StaleAuthorizationDecisionError as e:
        # Fail closed. Do not fall back to a cached allow.
        raise StaleAuthorizationDecisionError(
            f"refusing {call.tool}: {e}"
        ) from e
    return call.execute()
