"""
Tenuo Temporal Integration - Warrant-based Authorization for Durable Workflows

Compatibility:
    Temporal SDK: 1.4.0+
    Python: 3.9+

Setup (required):
    Tenuo's core library (``tenuo_core``) is a PyO3 native module that
    cannot be re-initialised inside Temporal's workflow sandbox.  You
    **must** declare ``tenuo`` and ``tenuo_core`` as passthrough modules
    when creating the worker::

        from temporalio.worker.workflow_sandbox import (
            SandboxedWorkflowRunner, SandboxRestrictions,
        )

        worker = Worker(
            client,
            task_queue="my-queue",
            workflows=[MyWorkflow],
            activities=[my_activity],
            interceptors=[TenuoInterceptor(config)],
            workflow_runner=SandboxedWorkflowRunner(
                restrictions=SandboxRestrictions.default.with_passthrough_modules(
                    "tenuo", "tenuo_core",
                )
            ),
        )

    Without this, ``tenuo_execute_activity()`` and ``AuthorizedWorkflow``
    will fail with ``ImportError: PyO3 modules compiled for CPython 3.8
    or older may only be initialized once per interpreter process``.

    See ``examples/temporal/demo.py`` for a complete working example of
    both transparent activity authorization and ``AuthorizedWorkflow``.

Overview:
    This module provides seamless integration between Tenuo's warrant-based
    authorization and Temporal's durable workflow orchestration. Activity
    execution is transparently authorized against the workflow's warrant.

Key Concepts:
    - Warrants propagate via Temporal headers, no code changes to activities
    - TenuoInterceptor enforces authorization at the activity boundary
    - KeyResolver abstraction for secure key material management

Security Philosophy (Fail-Closed by Default):
    - Missing warrant headers: Denied (require_warrant=True by default)
    - Invalid warrant: Raises ChainValidationError
    - Expired warrant: Raises WarrantExpired
    - Constraint violation: Raises TemporalConstraintViolation
    - PoP failure: Raises PopVerificationError (PoP is always mandatory)
    - Local activity without @unprotected: Raises LocalActivityError

Usage Patterns:
    **AuthorizedWorkflow (recommended for most cases)**::

        @workflow.defn
        class MyWorkflow(AuthorizedWorkflow):
            @workflow.run
            async def run(self, arg: str) -> str:
                return await self.execute_authorized_activity(
                    my_activity, args=[arg],
                    start_to_close_timeout=timedelta(seconds=30),
                )

    **tenuo_execute_activity (for delegation / multi-warrant)**::

        @workflow.defn
        class PipelineWorkflow:
            @workflow.run
            async def run(self, data_dir: str) -> str:
                return await tenuo_execute_activity(
                    my_activity, args=[data_dir],
                    start_to_close_timeout=timedelta(seconds=30),
                )

Proof-of-Possession (PoP) Challenge Format:
    PoP ensures that only the entity holding the private key matching the
    warrant's ``authorized_holder`` can invoke a tool.  Each activity call
    produces a fresh 64-byte Ed25519 signature over a deterministic challenge.

    Challenge construction (implemented in tenuo-core)::

        domain_context = b"tenuo-pop-v1"
        window_ts      = (unix_now // 30) * 30          # 30-second bucket
        challenge_data = CBOR( (warrant_id, tool, sorted_args, window_ts) )
        preimage       = domain_context || challenge_data
        signature      = Ed25519.sign(signing_key, preimage)   # 64 bytes

    Field details:
        - ``warrant_id``:  Hex-encoded warrant ID (``warrant.id``).
        - ``tool``:        Activity / tool name as a string.
        - ``sorted_args``: Key-sorted ``[(name, ConstraintValue), ...]`` pairs.
        - ``window_ts``:   Unix timestamp floored to 30-second windows for
          replay tolerance.  Signatures are valid for 4 windows (2 minutes).
        - CBOR (RFC 8949) is the canonical serialisation format.

    Wire encoding:
        The outbound workflow interceptor computes the PoP signature
        transparently for every ``workflow.execute_activity()`` call using
        ``warrant.sign(signing_key, tool, args_dict, timestamp=workflow.now())``.
        The deterministic timestamp ensures replay safety. The signature is
        base64-encoded and injected into activity headers as
        ``x-tenuo-pop``, which the activity interceptor decodes and passes
        to ``Authorizer.check_chain()`` or ``Authorizer.authorize_one()``.

Troubleshooting:
    ``ImportError: PyO3 modules ... may only be initialized once``
        You forgot to configure passthrough modules.  See **Setup** above.

    ``TenuoContextError: No Tenuo headers in store``
        The workflow was started without ``tenuo_headers()``.  Make sure the
        client calls ``TenuoClientInterceptor.set_headers_for_workflow(
        workflow_id, tenuo_headers(...))`` before ``client.execute_workflow()``.

    ``TemporalConstraintViolation: No warrant provided (require_warrant=True)``
        The activity interceptor received no warrant.  Common causes:
        (a) ``set_headers()`` / ``set_headers_for_workflow()`` was never
        called, (b) headers were cleared before start, or (c) the
        ``TenuoClientInterceptor`` is missing from the client's interceptor
        list.

    ``TemporalConstraintViolation: ... Incorrect padding`` or ``signature must be 64 bytes``
        PoP/header encoding mismatch. Ensure the worker has
        ``TenuoInterceptor(...)`` registered and that Tenuo headers are
        injected at workflow start. ``workflow.execute_activity()`` is
        supported when the interceptor is installed.

    ``WarrantExpired: Warrant '...' expired at ...``
        The warrant's TTL has elapsed.  Mint a new warrant with a longer
        ``ttl()`` or refresh the warrant before starting the workflow.
"""

from __future__ import annotations

import base64
import gzip
import hashlib
import json
import logging
import threading
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Literal, Optional, TypeVar

from .exceptions import ApprovalGateTriggered

import inspect as _inspect

F = TypeVar("F", bound=Callable[..., Any])

logger = logging.getLogger("tenuo.temporal")

# =============================================================================
# Header Constants
# =============================================================================

TENUO_WARRANT_HEADER = "x-tenuo-warrant"
TENUO_KEY_ID_HEADER = "x-tenuo-key-id"
TENUO_COMPRESSED_HEADER = "x-tenuo-compressed"
TENUO_POP_HEADER = "x-tenuo-pop"
TENUO_CHAIN_HEADER = "x-tenuo-warrant-chain"
TENUO_ARG_KEYS_HEADER = "x-tenuo-arg-keys"
TENUO_WIRE_FORMAT_HEADER = "x-tenuo-wire-format"
TENUO_APPROVALS_HEADER = "x-tenuo-approvals"

# Wire format versions:
#   "1" (or absent): legacy base64(cbor) warrant encoding
#   "2": raw CBOR bytes (no intermediate base64 layer)
_WIRE_FORMAT_V2 = b"2"

# PoP timestamp validation window (seconds). The scheduled_time must be
# within this window. This is not configurable — security is non-negotiable.
POP_WINDOW_SECONDS = 300

# =============================================================================
# Module-level stores for transparent authorization
# =============================================================================
# Tenuo authorization is completely transparent - developers use standard
# workflow.execute_activity() and the interceptor handles everything.
#
# The TenuoClientInterceptor populates _workflow_headers_store when a workflow
# starts. The outbound workflow interceptor (in the sandbox) reads this store,
# computes PoP signatures inline using deterministic timestamps, and injects
# them into activity headers. No queue machinery needed.
#
# _workflow_headers_store: workflow_id → {warrant, key_id}
# _pending_child_headers:  child_wf_id → attenuated headers
# _pop_dedup_cache:        dedup_key → timestamp (replay protection)
# _workflow_config_store:  workflow_id → TenuoInterceptorConfig
#
# Thread safety: _store_lock protects all mutations. Temporal workers
# may execute activities from different workflows concurrently on
# separate threads.

_store_lock = threading.Lock()
_workflow_headers_store: Dict[str, Dict[str, bytes]] = {}
_pending_child_headers: Dict[str, Dict[str, bytes]] = {}
_pop_dedup_cache: Dict[str, float] = {}
_pop_dedup_last_evict: float = 0.0
_DEDUP_EVICT_INTERVAL: float = 60.0
_DEDUP_MAX_SIZE: int = 10_000
_workflow_config_store: Dict[str, "TenuoInterceptorConfig"] = {}
_pending_activity_fn: Dict[str, Any] = {}  # workflow_id → activity function ref
_pending_activity_approvals: Dict[str, List[Any]] = {}  # workflow_id → SignedApproval list


# =============================================================================
# Exceptions
# =============================================================================


class TenuoTemporalError(Exception):
    """Base exception for tenuo.temporal module."""


class TenuoContextError(TenuoTemporalError):
    """Raised when Tenuo context is missing or invalid."""


class LocalActivityError(TenuoTemporalError):
    """Raised when a protected activity is used as local activity."""

    error_code = "LOCAL_ACTIVITY_BLOCKED"

    def __init__(self, activity_name: str) -> None:
        self.activity_name = activity_name
        super().__init__(
            f"Activity '{activity_name}' cannot be used as local activity. "
            "Protected activities must be executed as regular activities for "
            "authorization enforcement. Mark with @unprotected to allow local execution."
        )


@dataclass
class PopVerificationError(TenuoTemporalError):
    """Raised when Proof-of-Possession verification fails.

    Attributes:
        reason: Why PoP verification failed
        activity_name: The activity that failed PoP
        error_code: Wire format error code
    """

    reason: str
    activity_name: str
    error_code: str = field(default="POP_VERIFICATION_FAILED", init=False)

    def __str__(self) -> str:
        return f"PoP verification failed for '{self.activity_name}': {self.reason}"


@dataclass
class TemporalConstraintViolation(TenuoTemporalError):
    """Raised when an activity violates warrant constraints.

    Attributes:
        tool: The tool/activity that was denied
        arguments: The arguments that were checked
        constraint: The constraint that was violated
        warrant_id: The warrant that denied the action
        error_code: Wire format error code
    """

    tool: str
    arguments: Dict[str, Any]
    constraint: str
    warrant_id: str
    error_code: str = field(default="CONSTRAINT_VIOLATED", init=False)

    def __str__(self) -> str:
        return f"Activity '{self.tool}' denied: {self.constraint} (warrant: {self.warrant_id})"


@dataclass
class WarrantExpired(TenuoTemporalError):
    """Raised when the warrant has expired.

    Attributes:
        warrant_id: The expired warrant
        expired_at: When the warrant expired
        error_code: Wire format error code
    """

    warrant_id: str
    expired_at: datetime
    error_code: str = field(default="WARRANT_EXPIRED", init=False)

    def __str__(self) -> str:
        return f"Warrant '{self.warrant_id}' expired at {self.expired_at}"


@dataclass
class ChainValidationError(TenuoTemporalError):
    """Raised when warrant chain validation fails.

    Attributes:
        reason: Description of the validation failure
        depth: The depth at which validation failed
        error_code: Wire format error code
    """

    reason: str
    depth: int
    error_code: str = field(default="CHAIN_INVALID", init=False)

    def __str__(self) -> str:
        return f"Warrant chain invalid at depth {self.depth}: {self.reason}"


@dataclass
class KeyResolutionError(TenuoTemporalError):
    """Raised when a signing key cannot be resolved.

    Attributes:
        key_id: The key ID that could not be resolved
        error_code: Wire format error code
    """

    key_id: str
    error_code: str = field(default="KEY_NOT_FOUND", init=False)

    def __str__(self) -> str:
        return f"Cannot resolve key: {self.key_id}"


# =============================================================================
# Audit Event
# =============================================================================


@dataclass
class TemporalAuditEvent:
    """Audit event emitted for each authorization decision.

    Compatible with tenuo.audit.AuditEvent pattern.
    """

    # Temporal context
    workflow_id: str
    workflow_type: str
    workflow_run_id: str
    activity_name: str
    activity_id: str
    task_queue: str

    # Authorization decision
    decision: Literal["ALLOW", "DENY"]
    tool: str
    arguments: Dict[str, Any]

    # Warrant info
    warrant_id: str
    warrant_expires_at: Optional[datetime]
    warrant_capabilities: List[str]

    # Denial details (populated if denied)
    denial_reason: Optional[str] = None
    constraint_violated: Optional[str] = None

    # Metadata
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    tenuo_version: str = ""

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for structured logging."""
        return {
            "workflow_id": self.workflow_id,
            "workflow_type": self.workflow_type,
            "workflow_run_id": self.workflow_run_id,
            "activity_name": self.activity_name,
            "activity_id": self.activity_id,
            "task_queue": self.task_queue,
            "decision": self.decision,
            "tool": self.tool,
            "arguments": self.arguments,
            "warrant_id": self.warrant_id,
            "warrant_expires_at": (self.warrant_expires_at.isoformat() if self.warrant_expires_at else None),
            "warrant_capabilities": self.warrant_capabilities,
            "denial_reason": self.denial_reason,
            "constraint_violated": self.constraint_violated,
            "timestamp": self.timestamp.isoformat(),
            "tenuo_version": self.tenuo_version,
        }


# =============================================================================
# Observability: Metrics (Phase 4)
# =============================================================================


class TenuoMetrics:
    """Prometheus metrics for Tenuo-Temporal authorization.

    Collects metrics for monitoring authorization decisions:
    - activities_authorized: Counter of allowed activities
    - activities_denied: Counter of denied activities
    - authorization_latency_seconds: Histogram of auth check duration

    Args:
        prefix: Metric name prefix (default: "tenuo_temporal")

    Example:
        metrics = TenuoMetrics()
        config = TenuoInterceptorConfig(
            key_resolver=resolver,
            metrics=metrics,
        )

        # Metrics available at /metrics:
        # tenuo_temporal_activities_authorized_total{tool="read_file"}
        # tenuo_temporal_activities_denied_total{tool="write_file",reason="expired"}
        # tenuo_temporal_authorization_latency_seconds_bucket{...}
    """

    def __init__(self, prefix: str = "tenuo_temporal") -> None:
        self._prefix = prefix
        self._authorized_count: Dict[str, int] = {}
        self._denied_count: Dict[str, int] = {}
        self._latencies: List[float] = []

        # Try to use prometheus_client if available
        self._prom_authorized: Optional[Any] = None
        self._prom_denied: Optional[Any] = None
        self._prom_latency: Optional[Any] = None

        try:
            from prometheus_client import Counter, Histogram  # type: ignore[import-not-found]

            self._prom_authorized = Counter(
                f"{prefix}_activities_authorized_total",
                "Total authorized activities",
                ["tool", "workflow_type"],
            )
            self._prom_denied = Counter(
                f"{prefix}_activities_denied_total",
                "Total denied activities",
                ["tool", "reason", "workflow_type"],
            )
            self._prom_latency = Histogram(
                f"{prefix}_authorization_latency_seconds",
                "Authorization check latency",
                ["tool"],
                buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
            )
            logger.info(f"Prometheus metrics enabled with prefix: {prefix}")
        except ImportError:
            logger.debug("prometheus_client not available, using internal counters")

    def record_authorized(
        self,
        tool: str,
        workflow_type: str,
        latency_seconds: float,
    ) -> None:
        """Record an authorized activity."""
        key = f"{tool}:{workflow_type}"
        self._authorized_count[key] = self._authorized_count.get(key, 0) + 1
        self._latencies.append(latency_seconds)

        if self._prom_authorized:
            self._prom_authorized.labels(tool=tool, workflow_type=workflow_type).inc()
        if self._prom_latency:
            self._prom_latency.labels(tool=tool).observe(latency_seconds)

    def record_denied(
        self,
        tool: str,
        reason: str,
        workflow_type: str,
        latency_seconds: float,
    ) -> None:
        """Record a denied activity."""
        key = f"{tool}:{reason}:{workflow_type}"
        self._denied_count[key] = self._denied_count.get(key, 0) + 1
        self._latencies.append(latency_seconds)

        if self._prom_denied:
            self._prom_denied.labels(tool=tool, reason=reason, workflow_type=workflow_type).inc()
        if self._prom_latency:
            self._prom_latency.labels(tool=tool).observe(latency_seconds)

    def get_stats(self) -> Dict[str, Any]:
        """Get current metrics as a dict (for testing/debugging)."""
        return {
            "authorized": dict(self._authorized_count),
            "denied": dict(self._denied_count),
            "latency_count": len(self._latencies),
            "latency_avg": (sum(self._latencies) / len(self._latencies) if self._latencies else 0.0),
        }


# =============================================================================
# Key Resolver
# =============================================================================


class KeyResolver(ABC):
    """Abstract interface for resolving key IDs to signing keys.

    Implementations should fetch keys from secure storage
    (Vault, KMS, HSM, etc.) and cache appropriately.
    """

    @abstractmethod
    async def resolve(self, key_id: str) -> Any:  # Returns SigningKey
        """Resolve a key ID to a signing key (async).

        Args:
            key_id: The key identifier

        Returns:
            The signing key (tenuo_core.SigningKey)

        Raises:
            KeyResolutionError: If key cannot be resolved
        """
        ...

    def resolve_sync(self, key_id: str) -> Any:  # Returns SigningKey
        """Resolve a key ID to a signing key (synchronous).

        This method handles the async->sync conversion and is safe to call
        from both sync and async contexts, including from within running
        event loops (e.g., Temporal workflows).

        Default implementation:
        - If no event loop is running: creates temporary loop and runs resolve()
        - If event loop is running: spawns thread pool to run resolve() in new loop

        Subclasses can override this for more efficient sync implementations.

        Args:
            key_id: The key identifier

        Returns:
            The signing key (tenuo_core.SigningKey)

        Raises:
            KeyResolutionError: If key cannot be resolved
        """
        import asyncio
        import concurrent.futures

        try:
            asyncio.get_running_loop()
            # We're in a running loop (e.g., Temporal workflow)
            # Must use thread pool to avoid "loop already running" error
            with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
                return pool.submit(self._resolve_in_new_loop, key_id).result()
        except RuntimeError:
            # No running loop - safe to create one
            return self._resolve_in_new_loop(key_id)

    def _resolve_in_new_loop(self, key_id: str) -> Any:
        """Helper to run resolve() in a new event loop."""
        import asyncio
        loop = asyncio.new_event_loop()
        try:
            return loop.run_until_complete(self.resolve(key_id))
        finally:
            loop.close()


class EnvKeyResolver(KeyResolver):
    """Resolves keys from environment variables.

    For development/testing only. Do not use in production.

    Expects: TENUO_KEY_{key_id}=<base64-encoded-key>

    Args:
        prefix: Environment variable prefix (default: "TENUO_KEY_")
        warn_in_production: Emit a WARNING log at first resolution if the
            environment does not look like a development setup (i.e.
            the ``TENUO_ENV`` env var is not ``"development"`` or
            ``"test"``).  Default: True.

    For Temporal workflows:
        Call `preload_keys()` before creating the worker to cache keys
        and avoid os.environ access inside the workflow sandbox:

            resolver = EnvKeyResolver()
            resolver.preload_keys(["agent1", "agent2"])  # Cache before workflow
    """

    _DEV_ENVS = {"development", "dev", "test", "testing", "local"}

    def __init__(self, prefix: str = "TENUO_KEY_", *, warn_in_production: bool = True) -> None:
        self._prefix = prefix
        self._warn_in_production = warn_in_production
        self._warned = False
        self._key_cache: Dict[str, Any] = {}  # Pre-loaded keys for Temporal workflows

    def _maybe_warn(self) -> None:
        """Emit a one-time production warning if not suppressed."""
        if self._warned or not self._warn_in_production:
            return
        import os
        env = os.environ.get("TENUO_ENV", "").strip().lower()
        if env not in self._DEV_ENVS:
            logger.warning(
                "EnvKeyResolver is designed for development and testing only. "
                "In production, use VaultKeyResolver, AWSSecretsManagerKeyResolver, "
                "or GCPSecretManagerKeyResolver to fetch keys from secure storage. "
                "Set TENUO_ENV=development to suppress this warning in local environments."
            )
        self._warned = True

    async def resolve(self, key_id: str) -> Any:
        """Resolve key from environment variable."""
        import os

        self._maybe_warn()

        env_name = f"{self._prefix}{key_id}"
        value = os.environ.get(env_name)

        if value is None:
            raise KeyResolutionError(key_id=key_id)

        try:
            from tenuo_core import SigningKey

            key_bytes = base64.b64decode(value)
            return SigningKey.from_bytes(key_bytes)
        except Exception as e:
            logger.error(f"Failed to decode key from {env_name}: {e}")
            raise KeyResolutionError(key_id=key_id)

    def preload_keys(self, key_ids: list[str]) -> None:
        """Pre-load keys from environment to cache for Temporal workflows.

        Call this before creating the Temporal worker to avoid os.environ access
        inside the workflow sandbox, which is blocked as non-deterministic.

        Args:
            key_ids: List of key IDs to pre-load (e.g., ["agent1", "agent2"])

        Raises:
            KeyResolutionError: If any key cannot be loaded
        """
        import os

        for key_id in key_ids:
            env_name = f"{self._prefix}{key_id}"
            value = os.environ.get(env_name)

            if value is None:
                raise KeyResolutionError(key_id=key_id)

            try:
                from tenuo_core import SigningKey

                key_bytes = base64.b64decode(value)
                self._key_cache[key_id] = SigningKey.from_bytes(key_bytes)
            except Exception as e:
                logger.error(f"Failed to decode key from {env_name}: {e}")
                raise KeyResolutionError(key_id=key_id)

    def resolve_sync(self, key_id: str) -> Any:
        """Resolve key from cache or environment variable synchronously.

        Overrides base class to avoid ThreadPoolExecutor, which is blocked
        by Temporal's workflow sandbox.

        For Temporal workflows, use preload_keys() before creating the worker
        to cache keys and avoid os.environ access inside the sandbox.
        """
        # Check cache first (for Temporal workflows)
        if key_id in self._key_cache:
            return self._key_cache[key_id]

        # Fall back to os.environ (for non-workflow contexts)
        import os

        self._maybe_warn()

        env_name = f"{self._prefix}{key_id}"
        value = os.environ.get(env_name)

        if value is None:
            raise KeyResolutionError(key_id=key_id)

        try:
            from tenuo_core import SigningKey

            key_bytes = base64.b64decode(value)
            return SigningKey.from_bytes(key_bytes)
        except Exception as e:
            logger.error(f"Failed to decode key from {env_name}: {e}")
            raise KeyResolutionError(key_id=key_id)


class VaultKeyResolver(KeyResolver):
    """Resolve keys from HashiCorp Vault.

    Production-ready key resolver using Vault's KV secrets engine.

    Args:
        url: Vault server URL (e.g. "https://vault.example.com:8200")
        mount: Secrets engine mount path (default: "secret")
        path_template: Path template with {key_id} placeholder
            (default: "tenuo/keys/{key_id}")
        token: Vault token. If None, uses VAULT_TOKEN env var.
        cache_ttl: Cache TTL in seconds (default: 300)

    Example:
        resolver = VaultKeyResolver(
            url="https://vault.company.com:8200",
            path_template="production/tenuo/{key_id}",
        )
    """

    def __init__(
        self,
        url: str,
        mount: str = "secret",
        path_template: str = "tenuo/keys/{key_id}",
        token: Optional[str] = None,
        cache_ttl: int = 300,
    ) -> None:
        self._url = url.rstrip("/")
        self._mount = mount
        self._path_template = path_template
        self._token = token
        self._cache_ttl = cache_ttl
        self._cache: Dict[str, tuple[Any, float]] = {}
        self._cache_lock = threading.Lock()

    async def resolve(self, key_id: str) -> Any:
        """Resolve key from Vault."""
        import os
        import time

        now = time.time()
        with self._cache_lock:
            if key_id in self._cache:
                cached_key, cached_at = self._cache[key_id]
                if now - cached_at < self._cache_ttl:
                    logger.debug(f"Vault cache hit for key: {key_id}")
                    return cached_key

        # Get token
        token = self._token or os.environ.get("VAULT_TOKEN")
        if not token:
            raise KeyResolutionError(key_id=key_id)

        # Build path
        path = self._path_template.format(key_id=key_id)

        try:
            import httpx

            async with httpx.AsyncClient() as client:
                resp = await client.get(
                    f"{self._url}/v1/{self._mount}/data/{path}",
                    headers={"X-Vault-Token": token},
                    timeout=10.0,
                )

                if resp.status_code == 404:
                    raise KeyResolutionError(key_id=key_id)

                resp.raise_for_status()
                data = resp.json()

                key_b64 = data["data"]["data"]["key"]
                from tenuo_core import SigningKey

                key_bytes = base64.b64decode(key_b64)
                key = SigningKey.from_bytes(key_bytes)

                with self._cache_lock:
                    self._cache[key_id] = (key, now)
                logger.debug(f"Vault resolved key: {key_id}")
                return key

        except KeyResolutionError:
            raise
        except Exception as e:
            logger.error(f"Vault key resolution failed: {e}")
            raise KeyResolutionError(key_id=key_id)


class AWSSecretsManagerKeyResolver(KeyResolver):
    """Resolve keys from AWS Secrets Manager.

    Secrets Manager handles both storage and encryption (via KMS under the hood).
    Store your signing key as a binary secret.

    Args:
        secret_prefix: Prefix for secret names (default: "tenuo/keys/")
            Full secret name will be: {secret_prefix}{key_id}
        region_name: AWS region (default: uses boto3 default)
        cache_ttl: Cache TTL in seconds (default: 300)

    Example:
        resolver = AWSSecretsManagerKeyResolver(
            secret_prefix="prod/tenuo/",
            region_name="us-west-2",
        )

        # Store key in AWS CLI:
        # aws secretsmanager create-secret --name prod/tenuo/my-key-id \\
        #     --secret-binary fileb://signing_key.bin
    """

    def __init__(
        self,
        secret_prefix: str = "tenuo/keys/",
        region_name: Optional[str] = None,
        cache_ttl: int = 300,
    ) -> None:
        self._secret_prefix = secret_prefix
        self._region_name = region_name
        self._cache_ttl = cache_ttl
        self._cache: Dict[str, tuple[Any, float]] = {}
        self._cache_lock = threading.Lock()

    async def resolve(self, key_id: str) -> Any:
        """Resolve key from AWS Secrets Manager.

        The boto3 client is synchronous; this runs it in an executor to avoid
        blocking the event loop.
        """
        import asyncio
        import time

        now = time.time()
        with self._cache_lock:
            if key_id in self._cache:
                cached_key, cached_at = self._cache[key_id]
                if now - cached_at < self._cache_ttl:
                    logger.debug(f"AWS Secrets Manager cache hit for key: {key_id}")
                    return cached_key

        secret_name = f"{self._secret_prefix}{key_id}"

        try:
            import boto3  # type: ignore[import-not-found, import-untyped]

            loop = asyncio.get_running_loop()
            response = await loop.run_in_executor(
                None,
                lambda: boto3.client(
                    "secretsmanager", region_name=self._region_name
                ).get_secret_value(SecretId=secret_name),
            )

            if "SecretBinary" in response:
                key_bytes = response["SecretBinary"]
            elif "SecretString" in response:
                key_bytes = base64.b64decode(response["SecretString"])
            else:
                raise KeyResolutionError(key_id=key_id)

            from tenuo_core import SigningKey

            signing_key = SigningKey.from_bytes(key_bytes)

            with self._cache_lock:
                self._cache[key_id] = (signing_key, now)
            logger.debug(f"AWS Secrets Manager resolved key: {key_id}")
            return signing_key

        except ImportError:
            logger.error("boto3 not installed. Install with: pip install boto3")
            raise KeyResolutionError(key_id=key_id)
        except KeyResolutionError:
            raise
        except Exception as e:
            logger.error(f"AWS Secrets Manager key resolution failed: {e}")
            raise KeyResolutionError(key_id=key_id)


class GCPSecretManagerKeyResolver(KeyResolver):
    """Resolve keys from GCP Secret Manager.

    Secret Manager handles both storage and encryption (via Cloud KMS under the hood).
    Store your signing key as a binary secret.

    Args:
        project_id: GCP project ID
        secret_prefix: Prefix for secret names (default: "tenuo-keys-")
            Full secret name will be: {secret_prefix}{key_id}
        version: Secret version (default: "latest")
        cache_ttl: Cache TTL in seconds (default: 300)

    Example:
        resolver = GCPSecretManagerKeyResolver(
            project_id="my-project-123",
            secret_prefix="prod-tenuo-",
        )

        # Store key in gcloud CLI:
        # gcloud secrets create prod-tenuo-my-key-id --data-file=signing_key.bin
    """

    def __init__(
        self,
        project_id: str,
        secret_prefix: str = "tenuo-keys-",
        version: str = "latest",
        cache_ttl: int = 300,
    ) -> None:
        self._project_id = project_id
        self._secret_prefix = secret_prefix
        self._version = version
        self._cache_ttl = cache_ttl
        self._cache: Dict[str, tuple[Any, float]] = {}
        self._cache_lock = threading.Lock()

    async def resolve(self, key_id: str) -> Any:
        """Resolve key from GCP Secret Manager.

        The GCP client is synchronous; this runs it in an executor to avoid
        blocking the event loop.
        """
        import asyncio
        import time

        now = time.time()
        with self._cache_lock:
            if key_id in self._cache:
                cached_key, cached_at = self._cache[key_id]
                if now - cached_at < self._cache_ttl:
                    logger.debug(f"GCP Secret Manager cache hit for key: {key_id}")
                    return cached_key

        secret_name = f"{self._secret_prefix}{key_id}"
        resource_name = f"projects/{self._project_id}/secrets/{secret_name}/versions/{self._version}"

        try:
            from google.cloud import secretmanager  # type: ignore[import-not-found,import-untyped]

            loop = asyncio.get_running_loop()
            response = await loop.run_in_executor(
                None,
                lambda: secretmanager.SecretManagerServiceClient().access_secret_version(
                    name=resource_name
                ),
            )
            key_bytes = response.payload.data

            from tenuo_core import SigningKey

            signing_key = SigningKey.from_bytes(key_bytes)

            with self._cache_lock:
                self._cache[key_id] = (signing_key, now)
            logger.debug(f"GCP Secret Manager resolved key: {key_id}")
            return signing_key

        except ImportError:
            logger.error(
                "google-cloud-secret-manager not installed. "
                "Install with: pip install google-cloud-secret-manager"
            )
            raise KeyResolutionError(key_id=key_id)
        except KeyResolutionError:
            raise
        except Exception as e:
            logger.error(f"GCP Secret Manager key resolution failed: {e}")
            raise KeyResolutionError(key_id=key_id)


class CompositeKeyResolver(KeyResolver):
    """Try multiple resolvers in order (fallback chain).

    Useful for graceful degradation:
    - Try Vault first (production)
    - Fall back to cloud secrets manager (backup)
    - Fall back to env vars (local dev)

    Args:
        resolvers: List of resolvers to try in order

    Example:
        resolver = CompositeKeyResolver([
            VaultKeyResolver(url="https://vault.prod.internal"),
            AWSSecretsManagerKeyResolver(secret_prefix="tenuo/"),
            EnvKeyResolver(),  # Fallback for local dev
        ])
    """

    def __init__(
        self,
        resolvers: List[KeyResolver],
        *,
        warn_on_fallback: bool = True,
    ) -> None:
        if not resolvers:
            raise ValueError("CompositeKeyResolver requires at least one resolver")
        self._resolvers = resolvers
        self._warn_on_fallback = warn_on_fallback

    async def resolve(self, key_id: str) -> Any:
        """Try each resolver in order until one succeeds."""
        errors: List[str] = []

        for i, resolver in enumerate(self._resolvers):
            try:
                key = await resolver.resolve(key_id)
                if i > 0 and self._warn_on_fallback:
                    failed_names = [type(self._resolvers[j]).__name__ for j in range(i)]
                    logger.warning(
                        f"CompositeKeyResolver: primary resolver(s) failed ({', '.join(failed_names)}), "
                        f"resolved {key_id} via fallback {type(resolver).__name__}. "
                        f"Errors: {errors}"
                    )
                else:
                    logger.debug(f"CompositeKeyResolver: resolved {key_id} via resolver {i} ({type(resolver).__name__})")
                return key
            except KeyResolutionError as e:
                errors.append(f"{type(resolver).__name__}: {e}")
                continue

        logger.error(f"CompositeKeyResolver: all resolvers failed for {key_id}: {errors}")
        raise KeyResolutionError(key_id=key_id)


# =============================================================================
# Interceptor Config
# =============================================================================


@dataclass
class TenuoInterceptorConfig:
    """Configuration for TenuoInterceptor."""

    key_resolver: KeyResolver
    """Required. Resolves key IDs to signing keys.

    Note: The key_resolver is used by ``tenuo_execute_activity()`` to
    reconstruct signing keys for PoP generation.  In the lightweight
    authorization path (``trusted_roots=None``), only constraint checks
    are performed and the key_resolver is not invoked.  Set
    ``trusted_roots`` to enable full Authorizer + PoP verification.
    """

    control_plane: Optional[Any] = None
    """
    Optional ControlPlaneClient for emitting authorization check results
    back to the control plane.
    """

    on_denial: Literal["raise", "log", "skip"] = "raise"
    """
    Behavior when authorization fails:
    - "raise": Raise TemporalConstraintViolation (default)
    - "log": Log denial, continue execution
    - "skip": Silent denial, return None
    """

    dry_run: bool = False
    """
    Shadow mode for integration evaluation. When True, authorization denials
    are NOT enforced: Tenuo emits denial audit/log signals but allows activity
    execution to continue. This setting is for staging and rollout validation
    only and must not be used in production.
    """

    tool_mappings: Dict[str, str] = field(default_factory=dict)
    """
    Optional explicit activity-to-tool mappings.
    Example: {"fetch_document": "read_file"}
    If not specified, activity name = tool name.
    """

    audit_callback: Optional[Callable[[TemporalAuditEvent], None]] = None
    """Optional callback for authorization audit events."""

    audit_allow: bool = True
    """Whether to emit audit events for allowed actions."""

    audit_deny: bool = True
    """Whether to emit audit events for denied actions."""

    max_chain_depth: int = 10
    """Maximum warrant chain depth to accept."""

    # Phase 2: Security hardening options
    block_local_activities: bool = True
    """
    Block protected activities from being used as local activities.
    Local activities bypass the worker interceptor, so protected
    activities must be marked @unprotected to run locally.
    """

    # Phase 4: Observability options
    metrics: Optional["TenuoMetrics"] = None
    """
    Optional metrics collector for Prometheus.
    Pass a TenuoMetrics instance to enable metrics.
    """

    enable_tracing: bool = False
    """
    Enable OpenTelemetry tracing spans for authorization.
    Requires opentelemetry-api to be installed.
    """

    # Security: Fail-closed options
    require_warrant: bool = True
    """
    Require a warrant for all activities (fail-closed).
    When True, activities without a warrant are denied.
    When False, activities without a warrant pass through (opt-in).
    Default: True (secure by default).
    """

    redact_args_in_logs: bool = True
    """
    Redact argument values in logs and audit events.
    When True, argument values are replaced with "[REDACTED]".
    Prevents leaking sensitive data like passwords, tokens, etc.
    Default: True (secure by default).
    """

    trusted_roots: Optional[List[Any]] = None
    """
    Trusted root public keys for warrant verification and PoP checking.
    When provided, the interceptor uses the Authorizer to verify warrants
    and PoP signatures cryptographically. When None, only constraint
    checks are performed (no chain-of-trust or PoP verification).

    For root warrants (created via ``Warrant.mint_builder().mint(key)``),
    pass ``[key.public_key]`` here.  The Authorizer verifies that the
    warrant's signing key is in this list, even for depth-0 (root)
    warrants.  For delegated warrants, include the original root's
    public key so the full chain can be validated.
    """

    authorized_signals: Optional[List[str]] = None
    """
    When set, only signals whose name is in this list are accepted.
    Unrecognized signals are denied and logged. When None (default),
    all signals pass through (backward compatible).
    """

    authorized_updates: Optional[List[str]] = None
    """
    When set, only workflow updates whose name is in this list are
    accepted.  Unrecognized updates are rejected at the validator
    stage.  When None (default), all updates pass through.
    """

    activity_fns: Optional[List[Callable]] = None
    """
    Activity functions registered with the Worker.  When provided,
    the outbound workflow interceptor can resolve parameter names
    for transparent PoP signing even when using plain
    ``workflow.execute_activity()``.

    Without this, the outbound interceptor must fall back to
    positional keys (``arg0``, ``arg1``, ...) which will fail
    constraint checks if the warrant uses named parameters.

    Pass the same list you give to ``Worker(activities=...)``.
    """

    approval_handler: Optional[Callable] = None
    """
    Callback invoked when an approval gate fires and no pre-supplied
    approvals are available in activity headers.  Receives an
    ``ApprovalRequest`` and must return a ``SignedApproval`` (or list).
    Raise ``ApprovalDenied`` to reject.

    When ``None`` (default), approval-gate-triggered calls with no pre-supplied
    approvals are denied with ``ApprovalGateTriggered``.
    """

    trusted_approvers: Optional[List[Any]] = None
    """
    Public keys of trusted approvers for approval gate satisfaction.
    Required when using warrant approval gates with ``required_approvers``.
    If ``None``, the warrant's own ``required_approvers()`` list is used.
    """

    approval_threshold: Optional[int] = None
    """
    Minimum number of valid approvals required to satisfy a guard.
    If ``None``, uses the warrant's ``approval_threshold()``.
    """

    strict_mode: bool = False
    """
    Enforce production-safe configuration at startup.  When ``True``:

    * ``trusted_roots`` **must** be provided.  Omitting it raises
      ``ValueError`` immediately so a misconfigured worker fails fast
      rather than silently running in lightweight (no-PoP) mode.

    Recommended for all production deployments::

        config = TenuoInterceptorConfig(
            key_resolver=VaultKeyResolver(url="..."),
            trusted_roots=[root_key.public_key],
            strict_mode=True,
        )
    """

    def __post_init__(self) -> None:
        if self.dry_run:
            logger.warning(
                "TenuoInterceptorConfig: dry_run=True enables shadow mode and "
                "does not enforce authorization denials. Do not use in production."
            )
            # Elevate to a Python-level warning so it appears in production logs regardless of
            # logger configuration. Use stacklevel=2 to point at the call site, not this frame.
            warnings.warn(
                "TenuoInterceptorConfig: dry_run=True — authorization denials will NOT be "
                "enforced. This is a shadow/staging mode; remove dry_run=True before "
                "deploying to production.",
                stacklevel=2,
            )

        # F1: enforce strict_mode invariants before anything else
        if self.strict_mode and not self.trusted_roots:
            # Try global configure() first — allows setting trusted_roots once at startup
            from tenuo.config import resolve_trusted_roots as _resolve
            _global = _resolve(None)
            if _global:
                self.trusted_roots = _global  # type: ignore[assignment]
            else:
                from tenuo.exceptions import ConfigurationError
                raise ConfigurationError(
                    "TenuoInterceptorConfig: strict_mode=True requires trusted_roots to be set. "
                    "Without trusted_roots, PoP and chain-of-trust verification are skipped, "
                    "reducing security to lightweight constraint checks only. "
                    "Pass trusted_roots=[control_key.public_key], call "
                    "tenuo.configure(trusted_roots=[...]) at startup, or set strict_mode=False "
                    "to acknowledge running in lightweight mode."
                )

        self._activity_registry: Dict[str, Callable] = {}
        if self.activity_fns:
            for fn in self.activity_fns:
                name = getattr(fn, "__temporal_activity_definition", None)
                if name and hasattr(name, "name"):
                    self._activity_registry[name.name] = fn
                else:
                    self._activity_registry[getattr(fn, "__name__", str(fn))] = fn


# =============================================================================
# Client Interceptor — injects Tenuo headers into workflow start
# =============================================================================


class TenuoClientInterceptor:
    """Temporal client interceptor for injecting Tenuo warrant headers.

    This interceptor:
      1. Wraps raw header bytes as ``Payload`` protobufs for Temporal
      2. Stores the **raw** bytes in ``_workflow_headers_store`` (keyed by
         ``workflow_id``) so the activity interceptor can read them without
         going through Temporal's serialization pipeline.

    Usage::

        client_interceptor = TenuoClientInterceptor()
        client = await Client.connect("localhost:7233",
                                      interceptors=[client_interceptor])

        # Before starting a workflow, bind headers to a workflow ID:
        client_interceptor.set_headers_for_workflow(
            "wf-123",
            tenuo_headers(warrant, key_id),
        )

        await client.execute_workflow(MyWorkflow.run, ...)
    """

    def __init__(self) -> None:
        # One-shot headers for the next workflow start (legacy API).
        self._next_headers: Dict[str, bytes] = {}
        # Preferred API: deterministic mapping by workflow ID.
        self._headers_by_workflow_id: Dict[str, Dict[str, bytes]] = {}
        self._lock = threading.Lock()

    def set_headers(self, headers: Dict[str, bytes]) -> None:
        """Set one-shot headers for the next workflow start.

        This legacy API is consumed once by the next ``start_workflow`` call.
        For concurrent callers, prefer ``set_headers_for_workflow()``.
        """
        warnings.warn(
            "TenuoClientInterceptor.set_headers() is deprecated for concurrent use. "
            "Use set_headers_for_workflow(workflow_id, headers) or "
            "execute_workflow_authorized(...).",
            DeprecationWarning,
            stacklevel=2,
        )
        with self._lock:
            self._next_headers = dict(headers)

    def set_headers_for_workflow(self, workflow_id: str, headers: Dict[str, bytes]) -> None:
        """Set headers for a specific workflow ID.

        This is concurrency-safe and deterministic for multi-tenant clients.
        The headers are consumed once when that workflow ID is started.
        """
        if not workflow_id:
            raise ValueError("workflow_id must be a non-empty string")
        with self._lock:
            self._headers_by_workflow_id[workflow_id] = dict(headers)

    def clear_headers(self) -> None:
        """Clear all pending headers."""
        with self._lock:
            self._next_headers = {}
            self._headers_by_workflow_id.clear()

    async def execute_workflow_authorized(
        self,
        client: Any,
        workflow_run_fn: Any,
        *,
        workflow_id: str,
        warrant: Any,
        key_id: str,
        args: Optional[List[Any]] = None,
        compress: bool = True,
        **execute_kwargs: Any,
    ) -> Any:
        """Bind headers and execute a workflow in one call.

        This helper is the safest way to start authorized workflows from shared
        clients because it binds headers to a specific workflow ID before the
        start request is issued.
        """
        return await execute_workflow_authorized(
            client=client,
            client_interceptor=self,
            workflow_run_fn=workflow_run_fn,
            workflow_id=workflow_id,
            warrant=warrant,
            key_id=key_id,
            args=args,
            compress=compress,
            **execute_kwargs,
        )

    # --- Temporal client interceptor interface ---

    def intercept_client(self, next_interceptor: Any) -> "_TenuoClientOutbound":
        return _TenuoClientOutbound(next_interceptor, self)


class _TenuoClientOutbound:
    """Outbound half of the client interceptor — wraps ``start_workflow``.

    Injects Tenuo headers as ``Payload`` objects into the Temporal
    ``StartWorkflow`` request.  The headers travel through the Temporal
    Server and are extracted on the worker by
    ``_TenuoWorkflowInboundInterceptor``.

    As a convenience for single-process setups (e.g. demos), we also
    write the raw bytes directly into ``_workflow_headers_store``.  In
    production (separate client and worker processes) this in-process
    write has no effect — the workflow interceptor's extraction from
    ``input.headers`` is the canonical path.
    """

    def __init__(self, next_interceptor: Any, parent: TenuoClientInterceptor) -> None:
        self._next = next_interceptor
        self._parent = parent

    def __getattr__(self, name: str) -> Any:
        return getattr(self._next, name)

    async def start_workflow(self, input: Any) -> Any:
        workflow_id: str = getattr(input, "id", None) or ""

        selected_headers: Dict[str, bytes] = {}
        with self._parent._lock:
            # Prefer explicit workflow-ID binding when available.
            if workflow_id and workflow_id in self._parent._headers_by_workflow_id:
                selected_headers = self._parent._headers_by_workflow_id.pop(workflow_id)
            # Fallback: consume one-shot "next workflow" headers.
            elif self._parent._next_headers:
                selected_headers = self._parent._next_headers
                self._parent._next_headers = {}

        if selected_headers:
            try:
                from temporalio.api.common.v1 import Payload  # type: ignore
            except ImportError:
                raise TenuoContextError("temporalio not installed")

            raw_store: Dict[str, bytes] = {}

            for k, v in selected_headers.items():
                raw = v if isinstance(v, bytes) else str(v).encode("utf-8")
                input.headers = {**(input.headers or {}), k: Payload(data=raw)}
                if k.startswith("x-tenuo-"):
                    raw_store[k] = raw

            # Convenience write for single-process mode.  In production
            # the workflow interceptor populates the store from the
            # Temporal-delivered Payload headers (see above docstring).
            if workflow_id:
                with _store_lock:
                    _workflow_headers_store[workflow_id] = raw_store

        return await self._next.start_workflow(input)


async def execute_workflow_authorized(
    *,
    client: Any,
    client_interceptor: TenuoClientInterceptor,
    workflow_run_fn: Any,
    workflow_id: str,
    warrant: Any,
    key_id: str,
    args: Optional[List[Any]] = None,
    compress: bool = True,
    **execute_kwargs: Any,
) -> Any:
    """Execute a workflow with deterministic per-workflow header binding.

    This utility binds Tenuo headers to ``workflow_id`` using
    ``set_headers_for_workflow`` and immediately invokes
    ``client.execute_workflow``.
    """
    if "id" in execute_kwargs:
        raise ValueError(
            "Pass workflow_id via execute_workflow_authorized(..., workflow_id=...). "
            "Do not also pass id= in execute_kwargs."
        )
    client_interceptor.set_headers_for_workflow(
        workflow_id,
        tenuo_headers(warrant, key_id, compress=compress),
    )
    return await client.execute_workflow(
        workflow_run_fn,
        args=args or [],
        id=workflow_id,
        **execute_kwargs,
    )


# =============================================================================
# Header Utilities
# =============================================================================


def tenuo_headers(
    warrant: Any,  # Warrant type from tenuo_core
    key_id: str,
    *,
    compress: bool = True,
) -> Dict[str, bytes]:
    """Create headers dict for starting a workflow with Tenuo authorization.

    Args:
        warrant: The warrant authorizing this workflow
        key_id: Identifier for the holder's signing key. The actual signing
            key is resolved at runtime by workers via KeyResolver from secure
            storage (Vault, AWS Secrets Manager, GCP Secret Manager, etc.).
        compress: Whether to gzip compress the warrant (default: True)

    Returns:
        Headers dict to pass to client.start_workflow()

    Security:
        **CRITICAL**: Private keys are NEVER transmitted in headers. Workers
        resolve keys from secure storage using the key_id. This ensures:
        - Keys never leave secure boundaries (HSM, KMS, Vault)
        - Keys are not persisted in Temporal's database
        - Keys are not transmitted over the network
        - Compliance with NIST SP 800-57, OWASP, SOC2 requirements

    Example:
        # Client side - only passes key_id
        await client.start_workflow(
            MyWorkflow.run,
            args=[...],
            headers=tenuo_headers(warrant, "prod-agent-2024"),
        )

        # Worker side - resolves key from Vault
        config = TenuoInterceptorConfig(
            key_resolver=VaultKeyResolver(url="https://vault.company.com"),
        )
    """
    # Defensive check: reject SigningKey objects passed as key_id.
    # key_id must be a plain string identifier, never a key object.
    try:
        from tenuo_core import SigningKey
        if isinstance(key_id, SigningKey):
            raise TypeError(
                "key_id must be a string identifier, not a SigningKey. "
                "Private keys must never be transmitted in headers. "
                "Use a string key ID and configure KeyResolver on workers."
            )
    except ImportError:
        pass
    if not isinstance(key_id, str):
        raise TypeError(
            f"key_id must be a string identifier, got {type(key_id).__name__}. "
            "Private keys must never be transmitted in headers."
        )

    # Serialize warrant to raw CBOR bytes (v2 wire format).
    # Previous format (v1) double-encoded: base64(gzip(utf8(base64(cbor)))).
    # V2 eliminates the intermediate base64 layer: gzip(cbor) or raw cbor.
    warrant_bytes = bytes(warrant.to_bytes())

    headers: Dict[str, bytes] = {
        TENUO_KEY_ID_HEADER: key_id.encode("utf-8"),
        TENUO_WIRE_FORMAT_HEADER: _WIRE_FORMAT_V2,
    }

    if compress:
        compressed = gzip.compress(warrant_bytes, compresslevel=9)
        headers[TENUO_WARRANT_HEADER] = compressed
        headers[TENUO_COMPRESSED_HEADER] = b"1"
    else:
        headers[TENUO_WARRANT_HEADER] = warrant_bytes
        headers[TENUO_COMPRESSED_HEADER] = b"0"

    return headers


async def tenuo_execute_activity(
    activity: Any,
    *,
    args: Optional[List[Any]] = None,
    start_to_close_timeout: Any = None,
    schedule_to_close_timeout: Any = None,
    schedule_to_start_timeout: Any = None,
    heartbeat_timeout: Any = None,
    retry_policy: Any = None,
    task_queue: Optional[str] = None,
    cancellation_type: Any = None,
) -> Any:
    """Execute an activity with Tenuo authorization (legacy wrapper).

    **Note:** This function is now a thin wrapper around the standard
    ``workflow.execute_activity()``. Authorization is handled transparently
    by the outbound workflow interceptor, so you can use standard Temporal
    APIs directly:

        # Recommended (transparent):
        await workflow.execute_activity(read_file, args=[path], ...)

        # Legacy (still works):
        await tenuo_execute_activity(read_file, args=[path], ...)

    Both are equivalent. The interceptor computes PoP signatures automatically.

    This function is kept for backward compatibility and advanced use cases
    where explicit control is needed (future: custom warrants, multi-warrant).

    Args:
        activity: The activity function to execute
        args: Arguments to pass to the activity
        start_to_close_timeout: Timeout for activity execution
        schedule_to_close_timeout: Timeout from schedule to completion
        schedule_to_start_timeout: Timeout from schedule to start
        heartbeat_timeout: Heartbeat timeout for long-running activities
        retry_policy: Retry policy for the activity
        task_queue: Optional task queue override
        cancellation_type: Cancellation behavior

    Returns:
        The activity's return value

    Example:
        @workflow.defn
        class MyWorkflow:
            @workflow.run
            async def run(self) -> str:
                # Both work - use standard Temporal API:
                return await workflow.execute_activity(
                    read_file,
                    args=["/data/report.txt"],
                    start_to_close_timeout=timedelta(seconds=30),
                )
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]
    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")

    # Build activity kwargs
    activity_kwargs: Dict[str, Any] = {}
    if args is not None:
        activity_kwargs["args"] = args
    if start_to_close_timeout is not None:
        activity_kwargs["start_to_close_timeout"] = start_to_close_timeout
    if schedule_to_close_timeout is not None:
        activity_kwargs["schedule_to_close_timeout"] = schedule_to_close_timeout
    if schedule_to_start_timeout is not None:
        activity_kwargs["schedule_to_start_timeout"] = schedule_to_start_timeout
    if heartbeat_timeout is not None:
        activity_kwargs["heartbeat_timeout"] = heartbeat_timeout
    if retry_policy is not None:
        activity_kwargs["retry_policy"] = retry_policy
    if task_queue is not None:
        activity_kwargs["task_queue"] = task_queue
    if cancellation_type is not None:
        activity_kwargs["cancellation_type"] = cancellation_type

    # Store function reference so outbound interceptor can inspect parameters
    wf_id = workflow.info().workflow_id
    with _store_lock:
        _pending_activity_fn[wf_id] = activity

    try:
        return await workflow.execute_activity(activity, **activity_kwargs)
    finally:
        with _store_lock:
            _pending_activity_fn.pop(wf_id, None)


def set_activity_approvals(approvals: List[Any]) -> None:
    """Pre-supply signed approvals for the next activity execution.

    Call this from a workflow before ``workflow.execute_activity()`` when
    the warrant has guards that require approval.  The outbound interceptor
    encodes them into activity headers and the inbound interceptor uses
    them to satisfy the guard check.

    Approvals are consumed on the next activity dispatch (one-shot).

    Args:
        approvals: List of ``SignedApproval`` objects.

    Example::

        set_activity_approvals([signed_approval])
        await workflow.execute_activity(
            delete_file, args=["/etc/config"],
            start_to_close_timeout=timedelta(seconds=30),
        )
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]
    except ImportError:
        raise TenuoContextError("temporalio not available")

    wf_id = workflow.info().workflow_id
    with _store_lock:
        _pending_activity_approvals[wf_id] = list(approvals)


def attenuated_headers(
    *,
    tools: Optional[List[str]] = None,
    constraints: Optional[Dict[str, Any]] = None,
    ttl_seconds: Optional[int] = None,
    child_key_id: Optional[str] = None,
    compress: bool = True,
) -> Dict[str, bytes]:
    """Create headers for a child workflow with attenuated scope.

    Must be called from within a workflow context. Creates a new warrant
    with reduced capabilities from the parent warrant.

    Args:
        tools: Tools to allow (subset of parent). None = inherit all.
        constraints: Per-tool constraint overrides, e.g.
            ``{"read_file": {"path": Pattern("/safe/*")}}``.
            Merged on top of the parent's constraints (must be narrower).
        ttl_seconds: Max TTL for child warrant. None = inherit parent.
        child_key_id: Key ID for child. None = inherit parent.
        compress: Whether to gzip compress (default: True).

    Returns:
        Headers dict with attenuated warrant

    NOTE: Temporal's ``execute_child_workflow()`` does not accept a
    ``headers`` kwarg directly.  Use ``tenuo_execute_child_workflow()``
    instead — it calls ``attenuated_headers()`` internally and injects
    the attenuated warrant via the outbound workflow interceptor.

    Raises:
        TenuoContextError: If no parent warrant in context
        TemporalConstraintViolation: If requested tools exceed parent scope
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]  # noqa: F401
    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")

    # Get parent warrant from workflow context
    parent_warrant = current_warrant()
    parent_key_id = current_key_id()

    # Validate tools are subset of parent
    parent_tools = set(parent_warrant.tools or [])
    if tools is not None:
        requested_tools = set(tools)
        if not requested_tools.issubset(parent_tools):
            excess = requested_tools - parent_tools
            raise TemporalConstraintViolation(
                tool=str(list(excess)[0]),
                arguments={},
                constraint=f"Cannot delegate tools not in parent: {excess}",
                warrant_id=parent_warrant.id,
            )
    else:
        tools = list(parent_tools)

    # Get workflow ID to retrieve headers from store
    try:
        from temporalio import workflow as _wf  # type: ignore[import-not-found]
        info = _wf.info()
        wf_id = info.workflow_id
    except ImportError:
        raise TenuoContextError("temporalio not available")

    # Retrieve key_id from workflow headers store
    # (Same pattern as tenuo_execute_activity)
    with _store_lock:
        raw_headers = _workflow_headers_store.get(wf_id, {})
        config_store_entry = _workflow_config_store.get(wf_id)

    if not raw_headers:
        raise TenuoContextError(
            "No Tenuo headers in store. Ensure TenuoInterceptor is "
            "registered and tenuo_headers() was passed at workflow start."
        )

    if not config_store_entry:
        raise TenuoContextError(
            "No interceptor config found. Ensure TenuoInterceptor is registered."
        )

    parent_key_id = raw_headers.get(TENUO_KEY_ID_HEADER, b"").decode("utf-8")
    if not parent_key_id:
        raise TenuoContextError(
            "No key_id found in parent workflow headers."
        )

    # Resolve signing key from secure storage via KeyResolver
    config = config_store_entry
    if not config.key_resolver:
        raise TenuoContextError(
            "key_resolver not configured in TenuoInterceptorConfig. "
            "Required for child workflow delegation."
        )

    try:
        # Use resolve_sync() to safely handle event loop (may be in Temporal workflow)
        signer = config.key_resolver.resolve_sync(parent_key_id)
    except Exception as e:
        raise TenuoContextError(
            f"Failed to resolve signing key for '{parent_key_id}': {e}"
        )

    # Build capabilities dict: start from parent's per-tool constraints,
    # then overlay any caller-supplied narrowing constraints.
    parent_caps = parent_warrant.capabilities or {}
    extra = constraints or {}
    capabilities = {}
    for tool_key in tools:
        base = dict(parent_caps.get(tool_key, {}))
        narrowing = extra.get(tool_key, {})
        # F4: give a clear Python-level error if the caller introduces a
        # constraint key that does not exist in the parent.  Widening of
        # existing keys is still caught by tenuo_core.attenuate(), but
        # introducing *unknown* keys surfaces a confusing FFI error without
        # this check.
        unknown_keys = set(narrowing) - set(base)
        if unknown_keys:
            raise TemporalConstraintViolation(
                tool=tool_key,
                arguments={},
                constraint=(
                    f"Cannot introduce constraint keys not present in parent warrant: "
                    f"{sorted(unknown_keys)}.  Only existing constraint keys may be "
                    "narrowed in a child warrant."
                ),
                warrant_id=parent_warrant.id,
            )
        base.update(narrowing)
        capabilities[tool_key] = base

    child_warrant = parent_warrant.attenuate(
        capabilities=capabilities,
        signing_key=signer,
        ttl_seconds=ttl_seconds,
    )

    # Use parent key_id if not specified
    key_id = child_key_id or parent_key_id

    hdrs = tenuo_headers(child_warrant, key_id, compress=compress)

    # Propagate the delegation chain so the activity interceptor can call
    # check_chain() for full trust-root verification.
    #
    # Wire format: WarrantStack (CBOR array of warrants, base64url-encoded),
    # stored as UTF-8 bytes.  This matches the canonical X-Tenuo-Warrant
    # WarrantStack format used by HTTP and A2A transports.
    # Falls back to the legacy JSON-base64 format if encode_warrant_stack
    # is unavailable (older tenuo_core binary).
    existing_chain_raw = raw_headers.get(TENUO_CHAIN_HEADER)
    try:
        from tenuo_core import decode_warrant_stack_base64 as _decode_stack
        from tenuo_core import encode_warrant_stack as _encode_stack

        if existing_chain_raw:
            # Try decoding as WarrantStack first, then fall back to JSON-base64
            try:
                existing_warrants = _decode_stack(existing_chain_raw.decode("utf-8"))
            except Exception:
                # Legacy JSON-base64 format from an older sender
                _legacy = json.loads(base64.b64decode(existing_chain_raw))
                from tenuo_core import Warrant as _W
                existing_warrants = [_W.from_base64(w) for w in _legacy]
        else:
            existing_warrants = [parent_warrant]

        all_chain = existing_warrants + [child_warrant]
        hdrs[TENUO_CHAIN_HEADER] = _encode_stack(all_chain).encode("utf-8")
    except ImportError:
        # Older tenuo_core without encode_warrant_stack — keep legacy format
        if existing_chain_raw:
            parent_chain = json.loads(base64.b64decode(existing_chain_raw))
        else:
            parent_chain = [parent_warrant.to_base64()]
        parent_chain.append(child_warrant.to_base64())
        hdrs[TENUO_CHAIN_HEADER] = base64.b64encode(json.dumps(parent_chain).encode())

    return hdrs


async def tenuo_execute_child_workflow(
    workflow_fn: Any,
    *,
    args: Optional[List[Any]] = None,
    id: Optional[str] = None,
    tools: Optional[List[str]] = None,
    constraints: Optional[Dict[str, Any]] = None,
    ttl_seconds: Optional[int] = None,
    child_key_id: Optional[str] = None,
    task_queue: Optional[str] = None,
    execution_timeout: Any = None,
    run_timeout: Any = None,
    task_timeout: Any = None,
    cancellation_type: Any = None,
    parent_close_policy: Any = None,
    retry_policy: Any = None,
    id_reuse_policy: Any = None,
    cron_schedule: str = "",
    memo: Any = None,
    search_attributes: Any = None,
) -> Any:
    """Execute a child workflow with an attenuated Tenuo warrant.

    Creates a narrowed warrant via ``attenuated_headers()`` and injects it
    into the child workflow through the outbound interceptor.

    Args:
        workflow_fn: The child workflow function/class to execute.
        args: Arguments to pass to the child workflow.
        id: Workflow ID for the child. Auto-generated if not provided.
        tools: Tools to allow (subset of parent). None = inherit all.
        constraints: Per-tool constraint overrides (must narrow parent).
        ttl_seconds: Max TTL for child warrant.
        child_key_id: Key ID for child. None = inherit parent.
        task_queue: Task queue override.
        execution_timeout: Workflow execution timeout.
        run_timeout: Single workflow run timeout.
        task_timeout: Workflow task timeout.
        cancellation_type: Child workflow cancellation type.
        parent_close_policy: What happens to child when parent closes.
        retry_policy: Retry policy for the child workflow.
        id_reuse_policy: Workflow ID reuse policy.
        cron_schedule: Cron schedule string.
        memo: Memo fields.
        search_attributes: Search attributes.

    Returns:
        The child workflow's return value.

    Example::

        data = await tenuo_execute_child_workflow(
            ReaderChild.run,
            args=[source_dir],
            id=f"reader-{workflow.info().workflow_id}",
            tools=["read_file", "list_directory"],
            ttl_seconds=60,
        )
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]
    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")

    hdrs = attenuated_headers(
        tools=tools,
        constraints=constraints,
        ttl_seconds=ttl_seconds,
        child_key_id=child_key_id,
    )

    child_id = id or f"{workflow.info().workflow_id}-child-{workflow.uuid4()}"

    with _store_lock:
        _pending_child_headers[child_id] = hdrs

    kwargs: Dict[str, Any] = {"id": child_id}
    if args is not None:
        kwargs["args"] = args
    if task_queue is not None:
        kwargs["task_queue"] = task_queue
    if execution_timeout is not None:
        kwargs["execution_timeout"] = execution_timeout
    if run_timeout is not None:
        kwargs["run_timeout"] = run_timeout
    if task_timeout is not None:
        kwargs["task_timeout"] = task_timeout
    if cancellation_type is not None:
        kwargs["cancellation_type"] = cancellation_type
    if parent_close_policy is not None:
        kwargs["parent_close_policy"] = parent_close_policy
    if retry_policy is not None:
        kwargs["retry_policy"] = retry_policy
    if id_reuse_policy is not None:
        kwargs["id_reuse_policy"] = id_reuse_policy
    if cron_schedule:
        kwargs["cron_schedule"] = cron_schedule
    if memo is not None:
        kwargs["memo"] = memo
    if search_attributes is not None:
        kwargs["search_attributes"] = search_attributes

    try:
        return await workflow.execute_child_workflow(workflow_fn, **kwargs)
    finally:
        # Clean up pending headers regardless of success or failure.
        # On success the outbound interceptor already consumed the entry
        # via pop(); this handles the case where the child never started.
        with _store_lock:
            _pending_child_headers.pop(child_id, None)


def workflow_grant(
    tool: str,
    constraints: Optional[Dict[str, Any]] = None,
    *,
    ttl_seconds: int = 300,
) -> Any:
    """Issue a scoped warrant for a single tool within a workflow.

    Uses workflow.now() for deterministic timestamp, ensuring
    replay safety. The grant is scoped to one tool with constraints.

    Args:
        tool: The tool to authorize
        constraints: Per-tool constraint overrides, e.g.
            ``{"path_prefix": "/data/"}``. Merged on top of the
            parent's constraints for this tool (must be narrower).
        ttl_seconds: Time-to-live in seconds (default: 5 minutes)

    Returns:
        A new Warrant scoped to the specified tool

    Example:
        # Within a workflow — issue a scoped grant for a single tool
        file_warrant = workflow_grant(
            "read_file",
            constraints={"path_prefix": "/data/"},
            ttl_seconds=60,
        )

        # Activities are authorized via the interceptor automatically;
        # use tenuo_execute_activity() to add PoP signing:
        await tenuo_execute_activity(
            read_file,
            args=[path],
            start_to_close_timeout=timedelta(seconds=30),
        )

    Raises:
        TenuoContextError: If called outside workflow context
        TemporalConstraintViolation: If tool not in parent warrant
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]  # noqa: F401
    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")

    parent_warrant = current_warrant()

    parent_tools = parent_warrant.tools or []
    if tool not in parent_tools:
        raise TemporalConstraintViolation(
            tool=tool,
            arguments={},
            constraint=f"Tool '{tool}' not in parent warrant capabilities",
            warrant_id=parent_warrant.id,
        )

    wf_id = workflow.info().workflow_id
    with _store_lock:
        raw_headers = _workflow_headers_store.get(wf_id, {})
        config_store_entry = _workflow_config_store.get(wf_id)

    if not raw_headers:
        raise TenuoContextError(
            "No Tenuo headers in store. Ensure TenuoInterceptor is "
            "registered and tenuo_headers() was passed at workflow start."
        )

    if not config_store_entry:
        raise TenuoContextError(
            "No interceptor config found. Ensure TenuoInterceptor is registered."
        )

    key_id = raw_headers.get(TENUO_KEY_ID_HEADER, b"").decode("utf-8")
    if not key_id:
        raise TenuoContextError(
            "No key_id found in workflow headers. Cannot issue attenuated grant."
        )

    # Resolve signing key from secure storage via KeyResolver
    config = config_store_entry
    if not config.key_resolver:
        raise TenuoContextError(
            "key_resolver not configured in TenuoInterceptorConfig. "
            "Required for attenuated grants."
        )

    try:
        # Use resolve_sync() to safely handle event loop (may be in Temporal workflow)
        signer = config.key_resolver.resolve_sync(key_id)
    except Exception as e:
        raise TenuoContextError(f"Failed to resolve signing key for '{key_id}': {e}")

    parent_caps = parent_warrant.capabilities or {}
    base = dict(parent_caps.get(tool, {}))
    if constraints:
        base.update(constraints)
    capabilities = {tool: base}

    return parent_warrant.attenuate(
        capabilities=capabilities,
        signing_key=signer,
        ttl_seconds=ttl_seconds,
    )


def _extract_warrant_from_headers(headers: Dict[str, bytes]) -> Any:
    """Extract and deserialize warrant from headers.

    Supports both wire formats:
      - v2 (x-tenuo-wire-format: "2"): raw CBOR bytes (optionally gzipped)
      - v1 (absent or "1"): base64-encoded CBOR (optionally base64(gzipped(base64)))

    Returns:
        Warrant object, or None if no warrant header present.

    Raises:
        ChainValidationError: If warrant cannot be deserialized
    """
    from tenuo_core import Warrant

    raw = headers.get(TENUO_WARRANT_HEADER)
    if raw is None:
        return None

    try:
        is_compressed = headers.get(TENUO_COMPRESSED_HEADER, b"0") == b"1"
        wire_format = headers.get(TENUO_WIRE_FORMAT_HEADER, b"1")

        if wire_format == _WIRE_FORMAT_V2:
            # V2: raw CBOR bytes, optionally gzip-compressed
            if is_compressed:
                cbor_bytes = gzip.decompress(raw)
            else:
                cbor_bytes = raw
            return Warrant.from_bytes(cbor_bytes)
        else:
            # V1 (legacy): base64-encoded CBOR, optionally base64(gzip(base64))
            if is_compressed:
                compressed = base64.b64decode(raw)
                warrant_bytes = gzip.decompress(compressed)
            else:
                warrant_bytes = raw
            warrant_b64 = warrant_bytes.decode("utf-8")
            return Warrant.from_base64(warrant_b64)

    except Exception as e:
        raise ChainValidationError(
            reason=f"Failed to deserialize warrant: {e}",
            depth=0,
        )


def _extract_key_id_from_headers(headers: Dict[str, bytes]) -> Optional[str]:
    """Extract key ID from headers."""
    raw = headers.get(TENUO_KEY_ID_HEADER)
    if raw is None:
        return None
    return raw.decode("utf-8")


# =============================================================================
# Context Accessors (for use within workflows)
# =============================================================================


def _unwrap_payload_headers(headers: Any) -> Dict[str, bytes]:
    """Convert a Temporal header mapping to plain ``Dict[str, bytes]``.

    ``workflow.info().headers`` is ``Mapping[str, Payload]`` where each
    ``Payload`` has a ``.data`` attribute containing the raw bytes.
    ``_workflow_headers_store`` values are already ``Dict[str, bytes]``.

    This helper normalises both representations so callers don't need
    to care which one they received.
    """
    out: Dict[str, bytes] = {}
    for k, v in (headers or {}).items():
        if isinstance(v, bytes):
            out[k] = v
        elif hasattr(v, "data") and isinstance(getattr(v, "data", None), bytes):
            out[k] = v.data
        else:
            # Last resort: coerce to bytes
            out[k] = bytes(v) if v is not None else b""
    return out


def current_warrant() -> Any:
    """Get the warrant from the current workflow context.

    Must be called from within a workflow.

    Returns:
        The warrant attached to this workflow

    Raises:
        TenuoContextError: If no warrant in context
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]

        info = workflow.info()
        raw_headers = _unwrap_payload_headers(getattr(info, "headers", {}))

        warrant = _extract_warrant_from_headers(raw_headers)
        if warrant is None:
            raise TenuoContextError("No Tenuo warrant in workflow context")

        return warrant

    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")


def current_key_id() -> str:
    """Get the key ID from the current workflow context.

    Must be called from within a workflow.

    Returns:
        The key ID for this workflow's holder

    Raises:
        TenuoContextError: If no key ID in context
    """
    try:
        from temporalio import workflow  # type: ignore[import-not-found]

        info = workflow.info()
        raw_headers = _unwrap_payload_headers(getattr(info, "headers", {}))

        key_id = _extract_key_id_from_headers(raw_headers)
        if key_id is None:
            raise TenuoContextError("No Tenuo key ID in workflow context")

        return key_id

    except ImportError:
        raise TenuoContextError("temporalio not available. Install with: pip install temporalio")


# =============================================================================
# AuthorizedWorkflow Base Class (Phase 3)
# =============================================================================


class AuthorizedWorkflow:
    """Base class for Tenuo-authorized workflows.

    Provides convenient authorized activity execution with fail-fast
    warrant validation. The workflow's warrant is validated at initialization
    and automatically applied to all activity executions.

    ⚠️ Important: You must use @workflow.defn on your subclass!
        Temporal Python SDK uses decorators, not inheritance, to define workflows.

    Single Warrant Limitation:
        This class assumes all activities execute under the SAME warrant.
        For multi-warrant workflows (delegation chains), use
        tenuo_execute_activity() directly.

    Example:
        from temporalio import workflow
        from tenuo.temporal import AuthorizedWorkflow, tenuo_headers

        @workflow.defn  # ← Required decorator!
        class MyWorkflow(AuthorizedWorkflow):
            @workflow.run
            async def run(self, name: str) -> str:
                return await self.execute_authorized_activity(
                    my_activity,
                    args=[name],
                    start_to_close_timeout=timedelta(seconds=30),
                )

    Starting the workflow:
        await client.execute_workflow(
            MyWorkflow.run,
            "input",
            id="workflow-id",
            task_queue="my-queue",
            headers=tenuo_headers(warrant, "agent-key-1"),
        )

    Raises:
        TenuoContextError: If workflow is started without Tenuo headers
    """

    def __init__(self) -> None:
        """Validate Tenuo authorization at workflow start.

        This performs fail-fast validation. The warrant is not stored -
        it will be fetched deterministically from headers during each
        activity execution.

        Raises a **non-retryable** ``ApplicationError`` so that Temporal
        fails the workflow immediately instead of retrying forever.
        """
        try:
            # Fail-fast validation only - don't store
            # Warrant will be fetched deterministically during activity calls
            current_warrant()
            current_key_id()
        except TenuoContextError as e:
            try:
                from temporalio.exceptions import ApplicationError  # type: ignore[import-not-found]
            except ImportError:
                raise e
            raise ApplicationError(
                f"AuthorizedWorkflow requires Tenuo headers: {e}",
                type="TenuoContextError",
                non_retryable=True,
            ) from e

    async def execute_authorized_activity(self, activity: Any, **kwargs: Any) -> Any:
        """Execute activity with automatic Tenuo authorization.

        Convenience wrapper around tenuo_execute_activity() that automatically
        uses the workflow's warrant validated at initialization.

        Args:
            activity: The activity function to execute
            **kwargs: Activity execution options (args, start_to_close_timeout, etc.)

        Returns:
            The activity result

        Raises:
            TemporalConstraintViolation: If activity violates warrant constraints
            WarrantExpired: If warrant has expired
            ChainValidationError: If warrant chain is invalid
        """
        return await tenuo_execute_activity(activity, **kwargs)


# =============================================================================
# @unprotected Decorator (Phase 2)
# =============================================================================


def unprotected(func: F) -> F:
    """Mark an activity as unprotected - safe for local execution.

    Protected activities (default) require Tenuo authorization and
    cannot be used as local activities because local activities bypass
    worker interceptors.

    Use this decorator when:
    - The activity only accesses internal/trusted resources
    - The activity doesn't need per-invocation authorization
    - You want to run the activity as a local activity

    Example:
        @activity.defn
        @unprotected
        async def get_config_value(key: str) -> str:
            '''Internal config lookup - no Tenuo needed.'''
            return config[key]

        # Can now be called as local activity:
        await workflow.execute_local_activity(
            get_config_value,
            args=["database_url"],
            ...
        )
    """
    func._tenuo_unprotected = True  # type: ignore
    return func


def is_unprotected(func: Any) -> bool:
    """Check if an activity is marked as unprotected."""
    return getattr(func, "_tenuo_unprotected", False)


# =============================================================================
# @tool() Decorator (Phase 3)
# =============================================================================


def tool(name: str) -> Callable[[F], F]:
    """Map an activity to a specific Tenuo tool name.

    By default, activities are authorized using their function name
    as the tool name. Use this decorator when the activity name
    differs from the warrant tool name.

    Args:
        name: The tool name in the warrant (e.g., "read_file")

    Example:
        @activity.defn
        @tool("read_file")
        async def fetch_document(doc_id: str) -> str:
            '''Fetches document - authorized via 'read_file' capability.'''
            return await storage.get(doc_id)

        # Warrant needs: capability("read_file", {...})
        # Activity is called: fetch_document(doc_id)
    """

    def decorator(func: F) -> F:
        func._tenuo_tool_name = name  # type: ignore
        return func

    return decorator


def get_tool_name(func: Any, default: str) -> str:
    """Get the Tenuo tool name for an activity.

    Returns the @tool() name if set, otherwise the default.
    """
    return getattr(func, "_tenuo_tool_name", default)


# =============================================================================
# PoP Utilities (Phase 2)
# =============================================================================


def _compute_pop_challenge(
    workflow_id: str,
    activity_id: str,
    tool_name: str,
    args: Dict[str, Any],
    scheduled_time: datetime,
) -> bytes:
    """Compute the PoP challenge bytes for an activity.

    The challenge is a SHA-256 hash of:
    - workflow_id
    - activity_id
    - tool_name
    - canonical JSON of arguments
    - scheduled_time (ISO format)

    This ensures the challenge is deterministic and replay-safe.
    """
    # Canonical JSON: sorted keys, no whitespace
    args_json = json.dumps(args, sort_keys=True, separators=(",", ":"))

    message = f"{workflow_id}|{activity_id}|{tool_name}|{args_json}|{scheduled_time.isoformat()}"

    return hashlib.sha256(message.encode()).digest()


# =============================================================================
# Interceptor
# =============================================================================


class _TenuoWorkflowOutboundInterceptor:
    """Outbound workflow interceptor — transparently computes and injects PoP.

    This interceptor makes Tenuo authorization completely transparent.
    When ``workflow.execute_activity()`` is called (standard Temporal API),
    this interceptor automatically:
    1. Retrieves the warrant and key_id from workflow headers, resolves signing key via KeyResolver
    2. Computes the Proof-of-Possession signature using deterministic time
    3. Injects warrant + PoP into activity headers

    No special wrapper functions needed — works with standard Temporal code.
    This follows the OpenTelemetry pattern: add interceptor, everything works.
    """

    def __init__(self, next_outbound: Any, config: Optional["TenuoInterceptorConfig"] = None) -> None:
        self._next = next_outbound
        self.__dict__["_config"] = config

    def __getattr__(self, name: str) -> Any:
        return getattr(self._next, name)

    def start_activity(self, input: Any) -> Any:
        """Transparently compute and inject PoP for every activity.

        This is the key to transparent authorization. Standard
        workflow.execute_activity() calls go through this interceptor,
        which computes PoP inline with no queue machinery needed.
        """
        import inspect

        from temporalio import workflow as _wf  # type: ignore[import-not-found]

        try:
            from temporalio.api.common.v1 import Payload  # type: ignore
        except ImportError:
            return self._next.start_activity(input)

        try:
            wf_id = _wf.info().workflow_id
            tool_name = input.activity

            # Read warrant and key_id from headers store
            with _store_lock:
                raw_headers = dict(_workflow_headers_store.get(wf_id, {}))

            if raw_headers:
                # Extract warrant and key_id
                warrant = _extract_warrant_from_headers(raw_headers)
                key_id_bytes = raw_headers.get(TENUO_KEY_ID_HEADER)

                if warrant and key_id_bytes:
                    key_id = key_id_bytes.decode("utf-8")

                    # Resolve signing key from secure storage via KeyResolver
                    if not self._config or not self._config.key_resolver:
                        raise TenuoContextError(
                            "key_resolver not configured in TenuoInterceptorConfig. "
                            "Required for PoP signature generation."
                        )

                    # Use resolve_sync() to safely handle event loop (may be in Temporal workflow)
                    signer = self._config.key_resolver.resolve_sync(key_id)

                    # Convert positional args to dict for PoP signature.
                    # Resolution order for the activity function reference:
                    #   1. input.fn (Temporal SDK, if available)
                    #   2. _pending_activity_fn (set by tenuo_execute_activity)
                    #   3. config.activity_fns registry (transparent mode)
                    #   4. Positional keys (arg0, arg1, ...)
                    args_dict: Dict[str, Any] = {}
                    raw_args = getattr(input, "args", ())
                    if raw_args:
                        activity_fn = getattr(input, "fn", None)
                        if activity_fn is None:
                            with _store_lock:
                                activity_fn = _pending_activity_fn.get(wf_id)
                        if activity_fn is None and self._config is not None:
                            activity_fn = self._config._activity_registry.get(
                                tool_name
                            )
                        if activity_fn:
                            try:
                                sig = inspect.signature(activity_fn)
                                params = list(sig.parameters.keys())
                                for i, arg in enumerate(raw_args):
                                    if i < len(params):
                                        args_dict[params[i]] = arg
                                    else:
                                        args_dict[f"arg{i}"] = arg
                            except (ValueError, TypeError):
                                for i, arg in enumerate(raw_args):
                                    args_dict[f"arg{i}"] = arg
                        else:
                            # No function reference - use positional keys (consistent with inbound)
                            for i, arg in enumerate(raw_args):
                                args_dict[f"arg{i}"] = arg

                    # ✨ TRANSPARENT POP COMPUTATION ✨
                    # Use workflow.now() for deterministic replay safety.
                    # CRITICAL: timestamp MUST be provided for Temporal workflows
                    # to ensure identical PoP signatures during replay.
                    timestamp = int(_wf.now().timestamp())
                    pop_signature = warrant.sign(signer, tool_name, args_dict, timestamp)
                    pop_encoded = base64.b64encode(bytes(pop_signature))


                    # Inject all headers into activity
                    activity_headers = dict(input.headers or {})
                    for k, v in raw_headers.items():
                        activity_headers[k] = Payload(data=v)
                    activity_headers[TENUO_POP_HEADER] = Payload(data=pop_encoded)

                    # Include the arg keys used for signing so the inbound
                    # interceptor reconstructs the same dict for verification,
                    # even when it has access to the real function signature.
                    arg_keys_csv = ",".join(args_dict.keys())
                    activity_headers[TENUO_ARG_KEYS_HEADER] = Payload(
                        data=arg_keys_csv.encode("utf-8")
                    )

                    # Forward pre-supplied approvals (set by workflow code
                    # via set_activity_approvals()) so the inbound interceptor
                    # can satisfy guard checks.
                    with _store_lock:
                        pending_approvals = _pending_activity_approvals.pop(wf_id, None)
                    if pending_approvals:
                        encoded = json.dumps([
                            base64.b64encode(a.to_bytes()).decode("ascii")
                            for a in pending_approvals
                        ])
                        activity_headers[TENUO_APPROVALS_HEADER] = Payload(
                            data=encoded.encode("utf-8")
                        )

                    input = _replace_field(input, "headers", activity_headers)

        except TenuoContextError:
            raise
        except Exception as e:
            # FAIL-CLOSED: Abort the activity instead of proceeding without PoP.
            # Proceeding would let the inbound interceptor see a request with
            # no PoP, which is indistinguishable from a real attack.
            activity = getattr(input, "activity", "<unknown>")
            raise TenuoContextError(
                f"PoP computation failed for activity '{activity}': {e}. "
                f"Activity aborted (fail-closed)."
            ) from e

        return self._next.start_activity(input)

    def start_child_workflow(self, input: Any) -> Any:
        """Inject attenuated Tenuo headers into child workflow starts."""
        try:
            from temporalio.api.common.v1 import Payload  # type: ignore
        except ImportError:
            return self._next.start_child_workflow(input)

        child_id = input.id
        with _store_lock:
            raw_headers = _pending_child_headers.pop(child_id, None)

        if raw_headers:
            child_headers = dict(input.headers or {})
            for k, v in raw_headers.items():
                child_headers[k] = Payload(data=v)
            input = _replace_field(input, "headers", child_headers)

        return self._next.start_child_workflow(input)

    def continue_as_new(self, input: Any) -> None:
        """Re-inject Tenuo headers so the next run keeps its warrant."""
        try:
            from temporalio import workflow as _wf  # type: ignore[import-not-found]
            from temporalio.api.common.v1 import Payload  # type: ignore
        except ImportError:
            return self._next.continue_as_new(input)

        wf_id = _wf.info().workflow_id
        with _store_lock:
            raw_headers = _workflow_headers_store.get(wf_id, {})

        if raw_headers:
            can_headers = dict(input.headers or {})
            for k, v in raw_headers.items():
                can_headers[k] = Payload(data=v)
            input = _replace_field(input, "headers", can_headers)

        return self._next.continue_as_new(input)

    def start_nexus_operation(self, input: Any) -> Any:
        """Propagate Tenuo headers into Nexus cross-namespace operations."""
        from temporalio import workflow as _wf  # type: ignore[import-not-found]

        wf_id = _wf.info().workflow_id
        with _store_lock:
            raw_headers = _workflow_headers_store.get(wf_id, {})

        if raw_headers:
            nexus_headers = dict(input.headers or {})
            for k, v in raw_headers.items():
                nexus_headers[k] = base64.b64encode(v).decode()
            input = _replace_field(input, "headers", nexus_headers)

        return self._next.start_nexus_operation(input)

    def start_local_activity(self, input: Any) -> Any:
        return self._next.start_local_activity(input)


def _replace_field(obj: Any, field: str, value: Any) -> Any:
    """Create a copy of a dataclass with one field replaced.

    Falls back to setattr for non-dataclass objects.
    """
    import dataclasses

    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        return dataclasses.replace(obj, **{field: value})
    # Fallback: mutate in place (some Temporal versions use mutable objects)
    setattr(obj, field, value)
    return obj


class _TenuoWorkflowInboundInterceptor:
    """Workflow interceptor — extracts Tenuo headers and cleans up on completion.

    **Inbound** half: extracts ``x-tenuo-*`` headers from the Temporal
    ``Payload`` mapping delivered by the server and writes them into
    ``_workflow_headers_store`` for ``tenuo_execute_activity()`` to read.

    **Outbound** half (via ``init()``): wraps the next outbound
    interceptor with ``_TenuoWorkflowOutboundInterceptor``, which
    injects Tenuo headers + PoP into ``StartActivityInput.headers``
    so that activities on **any** worker receive authorization data
    through Temporal's standard header propagation.
    """

    _config: Optional["TenuoInterceptorConfig"] = None

    def __init__(self, next_interceptor: Any) -> None:
        self.next = next_interceptor

    def init(self, outbound: Any) -> None:
        # Wrap the outbound interceptor so activity scheduling carries
        # Tenuo headers through Temporal's header propagation.
        self.next.init(_TenuoWorkflowOutboundInterceptor(outbound, self._config))

    async def execute_workflow(self, input: Any) -> Any:
        from temporalio import workflow as _wf  # type: ignore[import-not-found]

        wf_id = _wf.info().workflow_id

        # --- Extract Tenuo headers from the Temporal Payload map ---
        # input.headers is Mapping[str, Payload] propagated by the
        # Temporal Server from the client's StartWorkflow request.
        incoming: Dict[str, bytes] = {}
        for key, payload in (getattr(input, "headers", None) or {}).items():
            if key.startswith("x-tenuo-"):
                data = getattr(payload, "data", None)
                if data is not None:
                    incoming[key] = data

        if incoming:
            with _store_lock:
                _workflow_headers_store[wf_id] = incoming

        if self._config is not None:
            with _store_lock:
                _workflow_config_store[wf_id] = self._config

        try:
            return await self.next.execute_workflow(input)
        finally:
            with _store_lock:
                _workflow_headers_store.pop(wf_id, None)
                _workflow_config_store.pop(wf_id, None)

    def _resolve_config(self) -> Optional["TenuoInterceptorConfig"]:
        from temporalio import workflow as _wf  # type: ignore[import-not-found]

        wf_id = _wf.info().workflow_id
        with _store_lock:
            return _workflow_config_store.get(wf_id)

    async def handle_signal(self, input: Any) -> None:
        config = self._resolve_config()
        if config and config.authorized_signals is not None:
            signal_name = getattr(input, "signal", None)
            if signal_name not in config.authorized_signals:
                logger.warning(
                    f"Signal '{signal_name}' denied: not in authorized_signals"
                )
                raise TemporalConstraintViolation(
                    tool=f"signal:{signal_name}",
                    arguments={},
                    constraint=f"Signal not authorized: {signal_name}",
                    warrant_id="workflow",
                )
        return await self.next.handle_signal(input)

    async def handle_query(self, input: Any) -> Any:
        return await self.next.handle_query(input)

    def handle_update_validator(self, input: Any) -> None:
        config = self._resolve_config()
        if config and config.authorized_updates is not None:
            update_name = getattr(input, "update", None)
            if update_name not in config.authorized_updates:
                logger.warning(
                    f"Update '{update_name}' rejected at validation: "
                    "not in authorized_updates"
                )
                raise TemporalConstraintViolation(
                    tool=f"update:{update_name}",
                    arguments={},
                    constraint=f"Update not authorized: {update_name}",
                    warrant_id="workflow",
                )
        return self.next.handle_update_validator(input)

    async def handle_update_handler(self, input: Any) -> Any:
        config = self._resolve_config()
        if config and config.authorized_updates is not None:
            update_name = getattr(input, "update", None)
            if update_name not in config.authorized_updates:
                raise TemporalConstraintViolation(
                    tool=f"update:{update_name}",
                    arguments={},
                    constraint=f"Update not authorized: {update_name}",
                    warrant_id="workflow",
                )
        return await self.next.handle_update_handler(input)


class TenuoInterceptor:
    """Temporal interceptor that enforces Tenuo warrant authorization.

    Intercepts activity execution and verifies the calling workflow
    has a valid warrant authorizing the activity.

    Important: The worker **must** configure ``tenuo`` and ``tenuo_core``
    as passthrough modules in the workflow sandbox.  Without this, PoP
    verification will fail.  See the module docstring for details.

    Example::

        from temporalio.worker.workflow_sandbox import (
            SandboxedWorkflowRunner, SandboxRestrictions,
        )

        activities = [read_file, write_file]
        interceptor = TenuoInterceptor(
            TenuoInterceptorConfig(
                key_resolver=EnvKeyResolver(),
                on_denial="raise",
                trusted_roots=[control_key.public_key],
                activity_fns=activities,
            )
        )

        worker = Worker(
            client,
            task_queue="my-queue",
            workflows=[MyWorkflow],
            activities=activities,
            interceptors=[interceptor],
            workflow_runner=SandboxedWorkflowRunner(
                restrictions=SandboxRestrictions.default.with_passthrough_modules(
                    "tenuo", "tenuo_core",
                )
            ),
        )
    """

    def __init__(self, config: TenuoInterceptorConfig) -> None:
        self._config = config
        self._version = self._get_version()
        if not config.trusted_roots:
            _msg = (
                "TenuoInterceptor initialized WITHOUT trusted_roots. "
                "Running in lightweight mode: PoP signatures and chain-of-trust are NOT "
                "verified — any structurally valid warrant is accepted. "
                "Set trusted_roots=[control_key.public_key] for production security, "
                "call tenuo.configure(trusted_roots=[...]) and set strict_mode=True, "
                "or pass strict_mode=True to fail fast on misconfiguration."
            )
            if getattr(config, "strict_mode", False):
                # strict_mode already checked in __post_init__, but guard here too
                # in case the config was built without __post_init__ running.
                from tenuo.exceptions import ConfigurationError as _ConfigError
                raise _ConfigError(
                    "TenuoInterceptor strict_mode=True requires trusted_roots. "
                    "Pass trusted_roots=[issuer_public_key] to TenuoInterceptorConfig or "
                    "call tenuo.configure(trusted_roots=[...]) at application startup."
                )
            import warnings as _warnings
            logger.warning(_msg)
            _warnings.warn(_msg, stacklevel=2)
        # Verify tenuo_core is importable — catches missing passthrough_modules early.
        # Inside a Temporal sandbox, tenuo_core must be declared as a passthrough module or
        # it raises "PyO3 modules may only be initialized once per interpreter process".
        try:
            import tenuo_core as _tc  # noqa: F401
        except ImportError as _e:
            raise RuntimeError(
                "tenuo_core is not importable inside the Temporal sandbox. "
                "Add 'tenuo' and 'tenuo_core' to passthrough_modules:\n\n"
                "    SandboxRestrictions.default.with_passthrough_modules('tenuo', 'tenuo_core')\n\n"
                "See the module docstring for a full Worker setup example."
            ) from _e

    def _get_version(self) -> str:
        """Get Tenuo version for audit events."""
        try:
            from tenuo import __version__

            return __version__
        except ImportError:
            return "unknown"

    def intercept_activity(
        self,
        next_interceptor: Any,  # ActivityInboundInterceptor
    ) -> "TenuoActivityInboundInterceptor":
        """Return activity interceptor that wraps the next one."""
        return TenuoActivityInboundInterceptor(
            next_interceptor,
            self._config,
            self._version,
        )

    def workflow_interceptor_class(
        self,
        input: Any,  # WorkflowInterceptorClassInput
    ) -> Optional[type]:
        """Return workflow interceptor class that captures Tenuo headers.

        The returned class stores workflow-start headers in a module-level
        dict so the activity interceptor can read them. This sidesteps the
        fact that workflow.execute_activity() does not accept ``headers``.

        A new subclass is created for every ``TenuoInterceptor`` instance so
        that two interceptors with different configs (e.g. different
        ``trusted_roots`` for different task queues) cannot accidentally
        share or overwrite each other's configuration (F3).
        """
        # F3: create a per-instance subclass so the class-level _config
        # is not shared across multiple TenuoInterceptor instances.
        bound_config = self._config
        interceptor_cls = type(
            "_TenuoWorkflowInboundInterceptor",
            (_TenuoWorkflowInboundInterceptor,),
            {"_config": bound_config},
        )
        return interceptor_cls


class TenuoActivityInboundInterceptor:
    """Activity-level interceptor that performs authorization checks."""

    def __init__(
        self,
        next_interceptor: Any,
        config: TenuoInterceptorConfig,
        version: str,
    ) -> None:
        self._next = next_interceptor
        self._config = config
        self._version = version
        self._authorizer: Optional[Any] = None
        if config.trusted_roots:
            try:
                from tenuo_core import Authorizer
                self._authorizer = Authorizer(trusted_roots=config.trusted_roots)
            except ImportError:
                logger.warning("tenuo_core not available; Authorizer not cached")

    def init(self, outbound: Any) -> None:
        """Called by Temporal to initialize the interceptor with an outbound impl."""
        self._next.init(outbound)

    async def execute_activity(self, input: Any) -> Any:
        """Intercept activity execution for authorization."""
        try:
            from temporalio import activity  # type: ignore[import-not-found]
        except ImportError:
            # If temporalio not available, pass through
            return await self._next.execute_activity(input)

        # Get activity info
        info = activity.info()

        import time
        start_ns = time.perf_counter_ns()
        chain_result = None

        # Phase 2: Local activity guard
        # Local activities bypass interceptors at the worker level, but
        # we can detect them here and enforce @unprotected marking.
        is_local = getattr(info, "is_local", False)
        activity_fn = getattr(input, "fn", None)

        if is_local and self._config.block_local_activities:
            # Fail-closed: if we can't determine protection status, deny
            if activity_fn is None:
                logger.warning(
                    f"Local activity {info.activity_type} denied: cannot determine protection status (fail-closed)"
                )
                raise LocalActivityError(info.activity_type)

            # Check if activity is marked @unprotected
            if not is_unprotected(activity_fn):
                raise LocalActivityError(info.activity_type)

            # Unprotected local activities skip authorization
            return await self._next.execute_activity(input)

        # --- Read Tenuo headers ---
        # The outbound workflow interceptor transparently computes PoP
        # and injects all Tenuo headers into StartActivityInput.headers
        # as Payloads. These travel through Temporal's standard header
        # propagation and arrive here in input.headers on ANY worker.

        headers: Dict[str, bytes] = {}
        input_headers = getattr(input, "headers", None) or {}
        for k, v in input_headers.items():
            if k.startswith("x-tenuo-"):
                if isinstance(v, bytes):
                    headers[k] = v
                elif hasattr(v, "data") and isinstance(getattr(v, "data", None), bytes):
                    headers[k] = v.data

        # Extract warrant (if present)
        try:
            warrant = _extract_warrant_from_headers(headers)
        except ChainValidationError:
            raise  # Re-raise validation errors

        async def _deny_or_continue(tool: str, reason: str) -> Optional[Any]:
            if self._config.dry_run:
                logger.warning(
                    "DRY-RUN mode: would deny activity %s in workflow %s (%s); "
                    "executing anyway. Not for production.",
                    tool,
                    info.workflow_id,
                    reason,
                )
                return await self._next.execute_activity(input)

            if self._config.on_denial == "log":
                logger.warning(f"Authorization denied for {tool}: {reason}")
            return None

        # If no warrant, check require_warrant config (fail-closed by default)
        if warrant is None:
            if self._config.require_warrant:
                # Fail-closed: deny activities without warrant
                logger.warning(f"No warrant for activity {info.activity_type}, denying (require_warrant=True)")
                if self._config.on_denial == "raise" and not self._config.dry_run:
                    raise TemporalConstraintViolation(
                        tool=info.activity_type,
                        arguments={},
                        constraint="No warrant provided (require_warrant=True)",
                        warrant_id="none",
                    )
                return await _deny_or_continue(
                    tool=info.activity_type,
                    reason="No warrant provided (require_warrant=True)",
                )
            else:
                # Opt-in mode: allow without warrant
                logger.debug(
                    f"No warrant for activity {info.activity_type}, executing without auth (require_warrant=False)"
                )
                return await self._next.execute_activity(input)

        # Resolve tool name
        activity_fn = getattr(input, "fn", None)
        default_tool = info.activity_type
        if activity_fn:
            default_tool = get_tool_name(activity_fn, info.activity_type)
        tool_name = self._config.tool_mappings.get(
            info.activity_type, default_tool,
        )

        # Get activity arguments, using outbound-supplied arg keys for
        # PoP consistency when the outbound lacked the function reference.
        args = self._extract_arguments(input, headers)

        # Check chain depth (enforce max_chain_depth config)
        chain_depth = warrant.depth if hasattr(warrant, "depth") else 0
        if chain_depth > self._config.max_chain_depth:
            self._emit_denial_event(
                info=info,
                warrant=warrant,
                tool=tool_name,
                args=args,
                reason=f"Chain depth {chain_depth} exceeds max {self._config.max_chain_depth}",
                constraint="max_chain_depth_exceeded",
            )
            if self._config.on_denial == "raise" and not self._config.dry_run:
                raise ChainValidationError(
                    reason=f"Chain depth {chain_depth} exceeds max {self._config.max_chain_depth}",
                    depth=chain_depth,
                )
            return await _deny_or_continue(
                tool=tool_name,
                reason=f"Chain depth {chain_depth} exceeds max {self._config.max_chain_depth}",
            )

        # --- Full Authorizer path (with PoP verification) ---
        if self._authorizer is not None:
            try:
                from tenuo_core import Warrant as CoreWarrant  # type: ignore[import-not-found]

                authorizer = self._authorizer

                # Extract PoP signature (base64-encoded in headers)
                pop_bytes = None
                pop_header = headers.get(TENUO_POP_HEADER)
                if pop_header:
                    pop_bytes = base64.b64decode(pop_header)

                # Approval gate evaluation: collect approvals before authorize
                # so Rust can satisfy the gate atomically with PoP.
                gate_approvals = self._resolve_approval_gate_approvals(
                    warrant, tool_name, args, headers,
                )

                chain_header = headers.get(TENUO_CHAIN_HEADER)
                if chain_header:
                    # Decode chain: try WarrantStack format first, then legacy
                    # JSON-base64 for backward compatibility with older senders.
                    try:
                        from tenuo_core import decode_warrant_stack_base64 as _decode_stack
                        chain = _decode_stack(chain_header.decode("utf-8"))
                    except Exception:
                        chain_list = json.loads(base64.b64decode(chain_header))
                        chain = [CoreWarrant.from_base64(w) for w in chain_list]
                    chain_result = authorizer.check_chain(
                        chain, tool_name, args,
                        signature=pop_bytes,
                        approvals=gate_approvals,
                    )
                else:
                    chain_result = authorizer.authorize_one(
                        warrant, tool_name, args,
                        signature=pop_bytes,
                        approvals=gate_approvals,
                    )

                # PoP replay detection: reject if the same dedup key
                # was seen within the dedup TTL window.  Skip on
                # Temporal retries (attempt > 1) which reuse the same
                # headers legitimately.
                if info.attempt <= 1:
                    global _pop_dedup_last_evict
                    base_dedup = warrant.dedup_key(tool_name, args)
                    # Include run ID so continue-as-new workflows don't
                    # false-positive as PoP replays across runs.
                    dedup_key = (
                        f"{base_dedup}:{info.workflow_id}:"
                        f"{info.workflow_run_id}:{info.activity_id}"
                    )
                    now = datetime.now(timezone.utc).timestamp()
                    ttl = float(warrant.dedup_ttl_secs())
                    with _store_lock:
                        last_seen = _pop_dedup_cache.get(dedup_key)
                        if last_seen is not None and (now - last_seen) < ttl:
                            raise PopVerificationError(
                                reason=f"replay detected (dedup_key seen {now - last_seen:.1f}s ago)",
                                activity_name=tool_name,
                            )
                        _pop_dedup_cache[dedup_key] = now

                        # Periodic eviction of expired entries
                        needs_evict = (now - _pop_dedup_last_evict) >= _DEDUP_EVICT_INTERVAL
                        # Emergency eviction if cache exceeds size cap
                        needs_evict = needs_evict or len(_pop_dedup_cache) > _DEDUP_MAX_SIZE
                        if needs_evict:
                            _pop_dedup_last_evict = now
                            expired = [
                                k for k, t in _pop_dedup_cache.items()
                                if (now - t) >= ttl
                            ]
                            for k in expired:
                                del _pop_dedup_cache[k]
                            # If still over cap after TTL eviction, drop oldest entries
                            if len(_pop_dedup_cache) > _DEDUP_MAX_SIZE:
                                by_age = sorted(_pop_dedup_cache.items(), key=lambda x: x[1])
                                excess = len(_pop_dedup_cache) - _DEDUP_MAX_SIZE
                                for k, _ in by_age[:excess]:
                                    del _pop_dedup_cache[k]

            except (TemporalConstraintViolation, PopVerificationError, ChainValidationError, WarrantExpired):
                raise
            except Exception as e:
                # Distinguish authorization denials (from tenuo_core or Temporal
                # integration) from genuine internal errors (bugs).
                is_auth_denial = False
                try:
                    from tenuo.exceptions import TenuoError  # base class for all tenuo_core errors
                    is_auth_denial = isinstance(e, (TenuoTemporalError, TenuoError))
                except ImportError:
                    is_auth_denial = isinstance(e, TenuoTemporalError)

                if is_auth_denial:
                    self._emit_denial_event(
                        info=info,
                        warrant=warrant,
                        tool=tool_name,
                        args=args,
                        reason=str(e),
                    )
                    if self._config.on_denial == "raise" and not self._config.dry_run:
                        raise TemporalConstraintViolation(
                            tool=tool_name,
                            arguments=args,
                            constraint=str(e),
                            warrant_id=warrant.id,
                        )
                    return await _deny_or_continue(tool=tool_name, reason=str(e))
                else:
                    # Internal error — always propagate regardless of on_denial.
                    logger.error(
                        f"Internal error during authorization for {tool_name}: {e}",
                        exc_info=True,
                    )
                    raise

        else:
            # --- Lightweight path (no trusted_roots, no PoP) ---
            # SECURITY: This path has no cryptographic issuer verification and no
            # Proof-of-Possession check.  It is suitable only for development or
            # internal environments where warrants are not received from untrusted
            # callers.  Configure trusted_roots on TenuoInterceptorConfig for
            # production deployments so the Rust Authorizer gates every call.
            import warnings as _warnings
            from tenuo.exceptions import TenuoSecurityWarning as _TSW
            _warnings.warn(
                "TenuoInterceptor has no trusted_roots configured: activity "
                f"'{tool_name}' is being authorized without issuer trust verification "
                "or Proof-of-Possession. Set trusted_roots=... on "
                "TenuoInterceptorConfig to enable full cryptographic enforcement.",
                _TSW,
                stacklevel=4,
            )
            # Check warrant expiry
            if warrant.is_expired():
                self._emit_denial_event(
                    info=info,
                    warrant=warrant,
                    tool=tool_name,
                    args=args,
                    reason="Warrant expired",
                )
                if self._config.on_denial == "raise" and not self._config.dry_run:
                    raise WarrantExpired(
                        warrant_id=warrant.id,
                        expired_at=warrant.expires_at(),
                    )
                return await _deny_or_continue(tool=tool_name, reason="Warrant expired")

            # Check tool is in capabilities — delegate to core via why_denied() so
            # grant semantics (wildcards, capability shapes) match the Authorizer.
            _why = warrant.why_denied(tool_name, args)
            from tenuo.warrant_ext import DenyCode as _DC
            if _why.deny_code == _DC.TOOL_NOT_FOUND:
                self._emit_denial_event(
                    info=info,
                    warrant=warrant,
                    tool=tool_name,
                    args=args,
                    reason=f"Tool '{tool_name}' not in warrant capabilities",
                    constraint="tool_not_allowed",
                )
                if self._config.on_denial == "raise" and not self._config.dry_run:
                    raise TemporalConstraintViolation(
                        tool=tool_name,
                        arguments=args,
                    constraint=f"Tool not in warrant capabilities: {_why.suggestion or tool_name}",
                        warrant_id=warrant.id,
                    )
                return await _deny_or_continue(
                    tool=tool_name,
                    reason=f"Tool '{tool_name}' not in warrant capabilities",
                )

            # Check constraints — returns None on success, violation string on failure
            try:
                violation = warrant.check_constraints(tool_name, args)
                if violation is not None:
                    self._emit_denial_event(
                        info=info,
                        warrant=warrant,
                        tool=tool_name,
                        args=args,
                        reason=f"Constraint violated: {violation}",
                        constraint="constraint_violated",
                    )
                    if self._config.on_denial == "raise" and not self._config.dry_run:
                        raise TemporalConstraintViolation(
                            tool=tool_name,
                            arguments=args,
                            constraint=str(violation),
                            warrant_id=warrant.id,
                        )
                    return await _deny_or_continue(
                        tool=tool_name,
                        reason=f"Constraint violated: {violation}",
                    )

            except TemporalConstraintViolation:
                raise
            except Exception as e:
                logger.error(f"Constraint check error: {e}")
                self._emit_denial_event(
                    info=info,
                    warrant=warrant,
                    tool=tool_name,
                    args=args,
                    reason=f"Constraint check error: {e}",
                )
                if self._config.on_denial == "raise" and not self._config.dry_run:
                    raise TemporalConstraintViolation(
                        tool=tool_name,
                        arguments=args,
                        constraint=f"Constraint check failed: {e}",
                        warrant_id=warrant.id,
                    )
                return await _deny_or_continue(
                    tool=tool_name,
                    reason=f"Constraint check error: {e}",
                )

            # Lightweight approval gate check (no cryptographic verification of
            # approvals — the full path handles that).  If a gate fires
            # and no approval_handler is configured, deny the call.
            try:
                from tenuo_core import evaluate_approval_gates as _eval_g
                if _eval_g(warrant, tool_name, args):
                    self._emit_denial_event(
                        info=info,
                        warrant=warrant,
                        tool=tool_name,
                        args=args,
                        reason=f"Approval gate triggered for '{tool_name}' (lightweight path — "
                               "configure trusted_roots for full approval verification)",
                        constraint="approval_gate_triggered",
                    )
                    if self._config.on_denial == "raise" and not self._config.dry_run:
                        raise TemporalConstraintViolation(
                            tool=tool_name,
                            arguments=args,
                            constraint=f"Approval gate triggered for '{tool_name}'. "
                                       "Use trusted_roots for full approval gate support.",
                            warrant_id=warrant.id,
                        )
                    return await _deny_or_continue(
                        tool=tool_name,
                        reason=f"Approval gate triggered for '{tool_name}'",
                    )
            except TemporalConstraintViolation:
                raise
            except ImportError:
                pass
            except Exception as e:
                logger.debug(f"Approval gate evaluation skipped (lightweight path): {e}")

        # Authorization passed — emit allow event
        self._emit_allow_event(
            info=info,
            warrant=warrant,
            tool=tool_name,
            args=args,
            start_ns=start_ns,
            chain_result=chain_result,
        )

        # Execute the activity
        return await self._next.execute_activity(input)

    def _resolve_approval_gate_approvals(
        self,
        warrant: Any,
        tool_name: str,
        args: Dict[str, Any],
        headers: Dict[str, bytes],
    ) -> Optional[List[Any]]:
        """Evaluate warrant approval gates and collect approvals when a gate fires.

        Resolution order:
          1. Pre-supplied approvals in ``x-tenuo-approvals`` header
             (base64-encoded JSON list of CBOR-serialized SignedApprovals).
          2. ``approval_handler`` callback on TenuoInterceptorConfig.
          3. Raise ApprovalGateTriggered (gate fires, no approvals available).

        Returns ``None`` when no gate fires (ungated call).
        """
        from tenuo_core import evaluate_approval_gates as _evaluate_approval_gates

        if not _evaluate_approval_gates(warrant, tool_name, args):
            return None

        # Approval gate fired — try to collect approvals.
        from tenuo_core import SignedApproval as CoreSignedApproval

        # 1. Check header for pre-supplied approvals
        raw_approvals_header = headers.get(TENUO_APPROVALS_HEADER)
        if raw_approvals_header:
            try:
                approvals_list = json.loads(raw_approvals_header)
                return [
                    CoreSignedApproval.from_bytes(base64.b64decode(a))
                    for a in approvals_list
                ]
            except Exception as e:
                logger.warning(f"Failed to decode approvals header: {e}")

        # 2. Try approval_handler callback
        handler = self._config.approval_handler if self._config else None
        if handler is not None:
            try:
                from tenuo_core import py_compute_request_hash as _compute_hash
                from tenuo.approval import ApprovalRequest

                holder_key = getattr(warrant, "holder_key", None)
                warrant_id = getattr(warrant, "id", "") or ""
                request_hash = _compute_hash(warrant_id, tool_name, args, holder_key)
                request = ApprovalRequest(
                    tool=tool_name,
                    arguments=args,
                    warrant_id=warrant_id,
                    request_hash=request_hash,
                )

                result = handler(request)
                if _inspect.isawaitable(result):
                    import asyncio
                    from typing import cast, Coroutine as _Coro
                    coro = cast(_Coro[Any, Any, Any], result)
                    try:
                        loop = asyncio.get_running_loop()
                    except RuntimeError:
                        loop = None
                    if loop and loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(coro, loop)
                        result = future.result(timeout=300)
                    else:
                        result = asyncio.run(coro)

                collected = result if isinstance(result, list) else [result]

                # Verify the approvals locally before forwarding to Rust
                approvers = (
                    self._config.trusted_approvers
                    if self._config and self._config.trusted_approvers
                    else warrant.required_approvers()
                )
                threshold = (
                    self._config.approval_threshold
                    if self._config and self._config.approval_threshold is not None
                    else warrant.approval_threshold()
                )
                # Threshold safety: config must not be weaker than the warrant's own minimum.
                if self._config and self._config.approval_threshold is not None:
                    warrant_min = warrant.approval_threshold()
                    if warrant_min is not None and threshold < warrant_min:
                        raise TemporalConstraintViolation(
                            tool=tool_name,
                            arguments=args,
                            constraint=(
                                f"Config approval_threshold ({threshold}) is less than "
                                f"warrant minimum ({warrant_min})"
                            ),
                            warrant_id=getattr(warrant, "id", ""),
                        )
                from tenuo_core import verify_approvals as _verify
                _verify(request_hash, collected, approvers, threshold)

                return collected
            except Exception:
                raise

        # 3. No approvals available — approval gate fired but cannot be satisfied.
        raise ApprovalGateTriggered(
            f"Approval gate triggered for '{tool_name}' but no approvals available "
            "(set approval_handler on TenuoInterceptorConfig or supply x-tenuo-approvals header)"
        )

    def _extract_arguments(
        self, input: Any, headers: Optional[Dict[str, bytes]] = None,
    ) -> Dict[str, Any]:
        """Extract arguments from activity input with proper signature mapping.

        When the outbound interceptor includes ``x-tenuo-arg-keys`` (because
        ``StartActivityInput`` lacked the function reference), those keys take
        precedence so the inbound reconstructs the exact same dict that was
        signed.  This guarantees PoP consistency for both
        ``tenuo_execute_activity()`` and plain ``workflow.execute_activity()``.
        """
        import inspect

        args = getattr(input, "args", ())

        # Outbound-supplied arg keys override local resolution so the
        # signed dict and the verified dict always match.
        if headers and TENUO_ARG_KEYS_HEADER in headers:
            keys = headers[TENUO_ARG_KEYS_HEADER].decode("utf-8").split(",")
            result: Dict[str, Any] = {}
            for i, arg in enumerate(args):
                if i < len(keys):
                    result[keys[i]] = arg
                else:
                    result[f"arg{i}"] = arg
            return result

        activity_fn = getattr(input, "fn", None)

        if activity_fn and args:
            try:
                sig = inspect.signature(activity_fn)
                params = list(sig.parameters.keys())
                result = {}
                for i, arg in enumerate(args):
                    if i < len(params):
                        result[params[i]] = arg
                    else:
                        result[f"arg{i}"] = arg
                return result
            except (ValueError, TypeError):
                pass

        if args and isinstance(args[0], dict):
            return args[0]

        result = {}
        for i, arg in enumerate(args):
            result[f"arg{i}"] = arg

        return result

    def _redact_args(self, args: Dict[str, Any]) -> Dict[str, Any]:
        """Redact argument values for logging (prevent sensitive data leaks)."""
        if not self._config.redact_args_in_logs:
            return args
        return {k: "[REDACTED]" for k in args.keys()}

    def _emit_allow_event(
        self,
        info: Any,
        warrant: Any,
        tool: str,
        args: Dict[str, Any],
        start_ns: Optional[int] = None,
        chain_result: Optional[Any] = None,
    ) -> None:
        """Emit audit event for allowed action."""
        if self._config.control_plane:
            import time
            from tenuo._enforcement import EnforcementResult
            latency_us = int((time.perf_counter_ns() - start_ns) / 1000) if start_ns else 0

            res = EnforcementResult(
                allowed=True,
                tool=tool,
                arguments=args,
                warrant_id=getattr(warrant, "id", None)
            )
            self._config.control_plane.emit_for_enforcement(
                res, chain_result=chain_result, latency_us=latency_us
            )

        if not self._config.audit_allow or not self._config.audit_callback:
            return

        event = TemporalAuditEvent(
            workflow_id=info.workflow_id,
            workflow_type=info.workflow_type,
            workflow_run_id=info.workflow_run_id,
            activity_name=info.activity_type,
            activity_id=info.activity_id,
            task_queue=info.task_queue,
            decision="ALLOW",
            tool=tool,
            arguments=self._redact_args(args),
            warrant_id=warrant.id,
            warrant_expires_at=warrant.expires_at(),
            warrant_capabilities=list(warrant.tools or []),
            tenuo_version=self._version,
        )

        try:
            self._config.audit_callback(event)
        except Exception as e:
            logger.error(f"Audit callback failed: {e}")

    def _emit_denial_event(
        self,
        info: Any,
        warrant: Any,
        tool: str,
        args: Dict[str, Any],
        reason: str,
        constraint: Optional[str] = None,
        start_ns: Optional[int] = None,
    ) -> None:
        """Emit audit event for denied action."""
        if self._config.control_plane:
            import time
            from tenuo._enforcement import EnforcementResult
            latency_us = int((time.perf_counter_ns() - start_ns) / 1000) if start_ns else 0

            # Encode single-warrant stack for the denial event so the control
            # plane can reconstruct chain context even without a full check_chain result.
            warrant_stack_b64 = None
            try:
                from tenuo_core import encode_warrant_stack
                warrant_stack_b64 = encode_warrant_stack([warrant])
            except Exception:
                pass

            res = EnforcementResult(
                allowed=False,
                tool=tool,
                arguments=args,
                denial_reason=reason,
                constraint_violated=constraint,
                warrant_id=getattr(warrant, "id", None),
            )
            self._config.control_plane.emit_for_enforcement(
                res, chain_result=None, latency_us=latency_us,
                warrant_stack_override=warrant_stack_b64,
            )

        if not self._config.audit_deny or not self._config.audit_callback:
            return

        event = TemporalAuditEvent(
            workflow_id=info.workflow_id,
            workflow_type=info.workflow_type,
            workflow_run_id=info.workflow_run_id,
            activity_name=info.activity_type,
            activity_id=info.activity_id,
            task_queue=info.task_queue,
            decision="DENY",
            tool=tool,
            arguments=self._redact_args(args),
            warrant_id=warrant.id,
            warrant_expires_at=warrant.expires_at(),
            warrant_capabilities=list(warrant.tools or []),
            denial_reason=reason,
            constraint_violated=constraint,
            tenuo_version=self._version,
        )

        try:
            self._config.audit_callback(event)
        except Exception as e:
            logger.error(f"Audit callback failed: {e}")


# =============================================================================
# Exports
# =============================================================================

__all__ = [
    # Exceptions
    "TenuoTemporalError",
    "TenuoContextError",
    "TemporalConstraintViolation",
    "WarrantExpired",
    "ChainValidationError",
    "KeyResolutionError",
    # Phase 2 exceptions
    "LocalActivityError",
    "PopVerificationError",
    "ApprovalGateTriggered",
    # Audit
    "TemporalAuditEvent",
    # Key Resolvers
    "KeyResolver",
    "EnvKeyResolver",
    "VaultKeyResolver",  # Phase 4
    "AWSSecretsManagerKeyResolver",  # Phase 4
    "GCPSecretManagerKeyResolver",  # Phase 4
    "CompositeKeyResolver",  # Phase 4
    # Metrics
    "TenuoMetrics",  # Phase 4
    # Config
    "TenuoInterceptorConfig",
    # Interceptors
    "TenuoInterceptor",
    "TenuoClientInterceptor",
    "TenuoActivityInboundInterceptor",
    # Header utilities
    "tenuo_headers",
    "attenuated_headers",  # Phase 3
    # Workflow helpers
    "tenuo_execute_activity",
    "execute_workflow_authorized",
    "tenuo_execute_child_workflow",
    "set_activity_approvals",
    # Context accessors
    "current_warrant",
    "current_key_id",
    "workflow_grant",  # Phase 3
    "AuthorizedWorkflow",  # Phase 3
    # Phase 2: Decorators
    "unprotected",
    "is_unprotected",
    # Phase 3: Decorators
    "tool",
    "get_tool_name",
    # Constants
    "TENUO_WARRANT_HEADER",
    "TENUO_KEY_ID_HEADER",
    "TENUO_POP_HEADER",
    "TENUO_COMPRESSED_HEADER",
    "TENUO_CHAIN_HEADER",
    "TENUO_WIRE_FORMAT_HEADER",
    "TENUO_APPROVALS_HEADER",
]
