orchestrata_ultimate.governance.escalation_handler.handler

Reference › orchestrata_ultimate › governance › escalation_handler › handler

Multi-hop escalation, the part of governance that carries an unresolved conflict up the chain of authority, SPEC-001 story US-005 (REQ-001-US-005-001 to -017). AdvancedEscalationHandler takes a Conflict and a starting volk, asks Prolog for the escalation path of the affected component, then sends an escalation packet to each authority in turn until one accepts or the chain reaches a terminal authority. The kernel's MultiHopEscalationManager delegates to it, and orchestrator.graph_audit imports the package. The SLA tracker in sla imports this module, and the data types are in models.

escalate makes an escalation id, calls get_escalation_path (which queries pam:multi_hop_escalation/2 and falls back to the single authority orchestrator), runs execute_multi_hop, builds an EscalationResult from the hops and the elapsed time, stores it in Redis (_store_escalation) and writes it to the graph audit trail. execute_multi_hop walks the path creating an EscalationHop with a deadline (DEFAULT_HOP_TIMEOUT_SECONDS, 300 seconds) for each volk alias; _execute_hop retries up to the configured maximum, and _attempt_hop_once marks a hop timed out when its deadline has passed (calling handle_escalation_timeout), otherwise builds a packet and dispatches it. The walk stops at the first hop that succeeds or that targets a terminal authority (_is_terminal_authority); a failed hop moves on to the next.

The message that moves between packages: _build_escalation_packet creates a CXPPacket from governance to the hop's volk with subject escalation, high priority, and a payload naming the conflict id and type, component, volks, description, hop index and retry count. _dispatch_packet hands it to a route callback supplied by the caller, which is how the packet reaches the postman; with no callback it logs a warning and reports failure. track_escalation_state keeps hop state in Redis, handle_escalation_timeout records the timeout in Redis and the graph, and override_escalation_path lets an operator replace the path.

At a glance

Source src/orchestrata_ultimate/governance/escalation_handler/handler.py
Lines of code 636
Classes 2
Functions 1
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.packet, orchestrata_ultimate.core.protocols, orchestrata_ultimate.governance.constants, orchestrata_ultimate.governance.escalation_handler.models, orchestrata_ultimate.orchestrator.graph_audit
Imported by orchestrata_ultimate.governance.escalation_handler, orchestrata_ultimate.governance.escalation_handler.sla
Tests that import it none

Class diagram

Class diagram orchestrata_ultimate.governance.escalation_handler.handler

Dependency diagram

Dependency diagram orchestrata_ultimate.governance.escalation_handler.handler

API

Classes

_AsyncClientRedisStateManager (protocol)

Bases: IRedisStateManager, Protocol · Decorators: runtime_checkable · Line 49

IRedisStateManager plus the async_client property this handler needs.

Method Signature Summary
async_client property async_client() -> Any

AdvancedEscalationHandler

Decorators: traces(spec='001', story='US-005', reqs=['REQ-001-US-005-011', 'REQ-001-US-005-014']) · Line 70

Handles advanced multi-hop escalation logic (US-005).

Method Signature Summary
__init__ __init__(prolog: IGovernancePrologBridge, redis: _AsyncClientRedisStateManager, graph: IAuditTrail | None = None, default_hop_timeout: int = DEFAULT_HOP_TIMEOUT_SECONDS, max_retries: int = DEFAULT_ESCALATION_MAX_RETRIES, route_callback: Callable[[CXPPacket], bool] | None = None) -> None Initialize escalation handler.
escalate async escalate(conflict: Conflict, starting_volk: str) -> EscalationResult Execute multi-hop escalation (REQ-001-US-005-001).
_build_escalation_result _build_escalation_result(escalation_id: str, conflict: Conflict, hops: list, total_time_ms: int) -> EscalationResult Determine resolution state and construct the EscalationResult.
_audit_escalation_to_graph async _audit_escalation_to_graph(escalation_id: str, conflict: Conflict, path: list, result: EscalationResult, starting_volk: str, total_time_ms: int, hops: list) -> None Log the completed escalation to graph audit trail (REQ-001-US-005-020).
execute_multi_hop async execute_multi_hop(path: list[str], conflict: Conflict) -> list[EscalationHop] Execute multi-hop escalation sequence (REQ-001-US-005-001, REQ-001-US-005-002).
_execute_hop async _execute_hop(hop: EscalationHop, conflict: Conflict) -> bool Execute a single escalation hop with retry (REQ-001-US-005-010).
_build_escalation_packet _build_escalation_packet(hop: EscalationHop, conflict: Conflict) -> CXPPacket Build a CXP packet for escalation routing.
_dispatch_packet _dispatch_packet(packet: CXPPacket) -> bool Dispatch a CXP packet via the route callback.
_attempt_hop_once async _attempt_hop_once(hop: EscalationHop, conflict: Conflict, retry: int) -> tuple Perform one attempt of a hop, handling timeout and routing.
track_escalation_state async track_escalation_state(escalation_id: str, hop_index: int, status: EscalationStatus) -> None Track escalation state at each hop (REQ-001-US-005-002).
handle_escalation_timeout async handle_escalation_timeout(escalation_id: str, hop_index: int) -> None Handle escalation timeout at specific hop (REQ-001-US-005-003).
override_escalation_path async override_escalation_path(escalation_id: str, new_path: list[str]) -> None Emergency override of escalation path (REQ-001-US-005-004).
get_escalation_path async get_escalation_path(component: str) -> list[str] Query Prolog for escalation path (REQ-001-US-005-001).
_is_terminal_authority async _is_terminal_authority(volk_alias: str) -> bool Check if volk is terminal authority (REQ-001-US-005-008).
_log_escalation_hop async _log_escalation_hop(conflict_id: str, hop: EscalationHop) -> None Log escalation hop details (REQ-001-US-005-005).
_store_escalation async _store_escalation(result: EscalationResult) -> None Store escalation result in Redis.

Functions

Function Signature Summary
_quote_atom _quote_atom(value: str) -> str Escape a string for safe use inside a single-quoted Prolog atom.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/governance/escalation_handler/handler.py

# mypy: ignore-errors
"""Advanced multi-hop escalation handler (US-005)."""

import asyncio
import json
import logging
import time
import uuid
from collections.abc import Callable
from datetime import timedelta
from typing import Any, Protocol, runtime_checkable

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.packet import CXPPacket, Priority
from orchestrata_ultimate.core.protocols import (
    IAuditTrail,
    IGovernancePrologBridge,
    IRedisStateManager,
)
from orchestrata_ultimate.governance.constants import (
    DEFAULT_ESCALATION_MAX_RETRIES,
    DEFAULT_HOP_TIMEOUT_SECONDS,
)
from orchestrata_ultimate.governance.escalation_handler.models import (
    Conflict,
    EscalationHop,
    EscalationResult,
    EscalationStatus,
    _utc_now,
)

logger = logging.getLogger(__name__)


def _quote_atom(value: str) -> str:
    """Escape a string for safe use inside a single-quoted Prolog atom.

    Without this, an unquoted volk alias like ``U:Cd`` parses as the
    module-qualification OPERATOR (``:``) applied to two unbound
    variables (``U`` and ``Cd``, both uppercase) rather than the intended
    atom -- a silent wrong-contract bug distinct from, but adjacent to,
    F4's nonexistent-predicate defect (both query sites this backs were
    touched to fix F4).
    """
    return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"


@runtime_checkable
class _AsyncClientRedisStateManager(IRedisStateManager, Protocol):
    """``IRedisStateManager`` plus the ``async_client`` property this
    handler needs.

    ``IRedisStateManager`` (``core/protocols/decoupling.py``) is the shared
    cycle-breaking decoupling interface and deliberately doesn't declare
    ``async_client`` (most consumers only need the sync surface). This
    handler needs the async Redis client (escalation hops are async),
    so it extends the shared Protocol locally rather than widening it
    project-wide (precedent: escalation_handler/resolver.py).
    """

    @property
    def async_client(self) -> Any: ...


@traces(
    spec="001",
    story="US-005",
    reqs=["REQ-001-US-005-011", "REQ-001-US-005-014"],
)
class AdvancedEscalationHandler:
    """Handles advanced multi-hop escalation logic (US-005)."""

    def __init__(
        self,
        prolog: IGovernancePrologBridge,
        redis: _AsyncClientRedisStateManager,
        graph: IAuditTrail | None = None,
        default_hop_timeout: int = DEFAULT_HOP_TIMEOUT_SECONDS,
        max_retries: int = DEFAULT_ESCALATION_MAX_RETRIES,
        route_callback: Callable[[CXPPacket], bool] | None = None,
    ) -> None:
        """Initialize escalation handler.

        Args:
            prolog: Prolog bridge for querying rules
            redis: Redis state manager
            graph: Optional graph audit logger
            default_hop_timeout: Default timeout per hop in seconds
            max_retries: Maximum retries per hop
            route_callback: Optional callback to dispatch CXP
                packets. Returns True on success. Falls back
                to logging when None.
        """
        self._prolog = prolog
        self._redis_manager = redis
        self._redis = redis.async_client  # Use async Redis client
        self._graph = graph
        self._default_hop_timeout = default_hop_timeout
        self._max_retries = max_retries
        self._route_callback = route_callback

        # In-memory cache (backed by Redis)
        self._escalations: dict[str, EscalationResult] = {}

    @traces(spec="001", reqs=["REQ-001-US-005-003"])
    async def escalate(
        self, conflict: Conflict, starting_volk: str
    ) -> EscalationResult:
        """Execute multi-hop escalation (REQ-001-US-005-001).

        Args:
            conflict: Conflict to escalate
            starting_volk: Volk that initiated escalation

        Returns:
            EscalationResult with full escalation history
        """
        escalation_id = f"esc-{uuid.uuid4().hex[:8]}"
        start_time = time.time()

        path = await self.get_escalation_path(conflict.component)

        logger.info(
            f"Starting escalation {escalation_id} for conflict {conflict.conflict_id} "
            f"from {starting_volk} through path: {path}"
        )

        hops = await self.execute_multi_hop(path, conflict)
        total_time_ms = int((time.time() - start_time) * 1000)

        result = self._build_escalation_result(
            escalation_id, conflict, hops, total_time_ms
        )

        self._escalations[escalation_id] = result
        await self._store_escalation(result)

        await self._audit_escalation_to_graph(
            escalation_id, conflict, path, result, starting_volk, total_time_ms, hops
        )

        return result

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-021"])
    def _build_escalation_result(
        self,
        escalation_id: str,
        conflict: Conflict,
        hops: list,
        total_time_ms: int,
    ) -> EscalationResult:
        """Determine resolution state and construct the EscalationResult."""
        resolved = any(hop.status == EscalationStatus.completed for hop in hops)
        resolution = None
        resolved_at = None

        if resolved:
            completed_hop = next(
                hop for hop in hops if hop.status == EscalationStatus.completed
            )
            resolution = completed_hop.response
            resolved_at = _utc_now()

        return EscalationResult(
            escalation_id=escalation_id,
            conflict=conflict,
            hops=hops,
            resolved=resolved,
            resolution=resolution,
            total_time_ms=total_time_ms,
            resolved_at=resolved_at,
        )

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-011"])
    async def _audit_escalation_to_graph(
        self,
        escalation_id: str,
        conflict: Conflict,
        path: list,
        result: EscalationResult,
        starting_volk: str,
        total_time_ms: int,
        hops: list,
    ) -> None:
        """Log the completed escalation to graph audit trail (REQ-001-US-005-020)."""
        if not self._graph:
            return

        from orchestrata_ultimate.orchestrator.graph_audit import (  # noqa: PLC0415
            AuditEvent,
            AuditEventType,
        )

        event = AuditEvent(
            event_type=AuditEventType.ESCALATION,
            session_id=escalation_id,
            volk_id=starting_volk,
            timestamp=_utc_now(),
            data={
                "escalation_id": escalation_id,
                "conflict_id": conflict.conflict_id,
                "path": path,
                "resolved": result.resolved,
                "total_time_ms": total_time_ms,
                "hop_count": len(hops),
            },
        )
        await self._graph.log_event(event)

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-014"])
    async def execute_multi_hop(
        self, path: list[str], conflict: Conflict
    ) -> list[EscalationHop]:
        """Execute multi-hop escalation sequence (REQ-001-US-005-001, REQ-001-US-005-002).

        Args:
            path: List of volk aliases in escalation path
            conflict: Conflict being escalated

        Returns:
            List of escalation hops with statuses
        """
        hops: list[EscalationHop] = []

        for hop_index, volk_alias in enumerate(path):
            # Check if terminal authority (REQ-001-US-005-008)
            is_terminal = await self._is_terminal_authority(volk_alias)

            # Create hop
            hop = EscalationHop(
                hop_index=hop_index,
                volk_alias=volk_alias,
                status=EscalationStatus.pending,
                timestamp=_utc_now(),
                timeout_at=_utc_now() + timedelta(seconds=self._default_hop_timeout),
            )

            # Execute hop with retry logic (REQ-001-US-005-010)
            success = await self._execute_hop(hop, conflict)

            # Track state (REQ-001-US-005-002)
            await self.track_escalation_state(
                f"esc-{conflict.conflict_id}", hop_index, hop.status
            )

            # Log hop (REQ-001-US-005-005)
            await self._log_escalation_hop(conflict.conflict_id, hop)

            hops.append(hop)

            # If resolved or terminal authority reached, stop
            if success or is_terminal:
                hop.status = EscalationStatus.completed
                hop.response = f"Resolved by {volk_alias}"
                break

            # If hop failed after retries, continue to next
            if hop.status == EscalationStatus.failed:
                logger.warning(
                    f"Hop {hop_index} to {volk_alias} failed after {hop.retry_count} retries"
                )
                continue

        return hops

    async def _execute_hop(self, hop: EscalationHop, conflict: Conflict) -> bool:
        """Execute a single escalation hop with retry (REQ-001-US-005-010).

        Args:
            hop: Hop to execute
            conflict: Conflict being escalated

        Returns:
            True if hop succeeded, False otherwise
        """
        hop.status = EscalationStatus.in_progress

        for retry in range(self._max_retries):
            hop.retry_count = retry
            succeeded, done = await self._attempt_hop_once(hop, conflict, retry)
            if done:
                return succeeded

        # All retries exhausted
        hop.status = EscalationStatus.failed  # pragma: no cover
        return False  # pragma: no cover

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-017"])
    def _build_escalation_packet(
        self, hop: EscalationHop, conflict: Conflict
    ) -> CXPPacket:
        """Build a CXP packet for escalation routing.

        Args:
            hop: Current escalation hop
            conflict: Conflict being escalated

        Returns:
            CXPPacket addressed to the target volk
        """
        payload = {
            "conflict_id": conflict.conflict_id,
            "conflict_type": conflict.conflict_type.value,
            "component": conflict.component,
            "volks": conflict.volks,
            "description": conflict.description,
            "hop_index": hop.hop_index,
            "retry_count": hop.retry_count,
        }
        return CXPPacket(
            from_agent="governance",
            to_agent=hop.volk_alias,
            subject="escalation",
            payload=payload,
            priority=Priority.HIGH,
        )

    def _dispatch_packet(self, packet: CXPPacket) -> bool:
        """Dispatch a CXP packet via the route callback.

        Falls back to logging when no callback is configured.

        Args:
            packet: CXP packet to dispatch

        Returns:
            True if dispatched successfully
        """
        if self._route_callback is None:
            logger.warning(
                "No route_callback configured; cannot dispatch packet %s to %s",
                packet.message_id,
                packet.to_agent,
            )
            return False
        return self._route_callback(packet)

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-016"])
    async def _attempt_hop_once(
        self, hop: EscalationHop, conflict: Conflict, retry: int
    ) -> tuple:
        """Perform one attempt of a hop, handling timeout and routing.

        Returns:
            (succeeded, done) where done=True means stop retrying.
        """
        try:
            if hop.timeout_at and _utc_now() > hop.timeout_at:
                hop.status = EscalationStatus.timeout
                await self.handle_escalation_timeout(
                    f"esc-{conflict.conflict_id}",
                    hop.hop_index,
                )
                return False, True

            packet = self._build_escalation_packet(hop, conflict)
            dispatched = self._dispatch_packet(packet)

            if dispatched:
                hop.status = EscalationStatus.completed
                hop.response = f"Routed to {hop.volk_alias}"
                return True, True

            # Dispatch failed — retry or give up
            if retry < self._max_retries - 1:
                await asyncio.sleep(0.01)
                return False, False

            hop.status = EscalationStatus.failed
            return False, True

        except Exception as e:
            logger.error(
                "Error in hop %d retry %d: %s",
                hop.hop_index,
                retry,
                e,
            )
            if retry < self._max_retries - 1:
                await asyncio.sleep(0.01)
                return False, False
            else:  # pragma: no cover
                hop.status = EscalationStatus.failed  # pragma: no cover
                return False, True  # pragma: no cover

    @traces(
        spec="001",
        story="US-003",
        reqs=["REQ-001-US-003-024"],
    )
    async def track_escalation_state(
        self, escalation_id: str, hop_index: int, status: EscalationStatus
    ) -> None:
        """Track escalation state at each hop (REQ-001-US-005-002).

        Args:
            escalation_id: Escalation ID
            hop_index: Index of current hop
            status: Status of hop
        """
        key = f"escalation:{escalation_id}"

        state = {
            "current_hop": hop_index,
            "status": status.value,
            "updated_at": _utc_now().isoformat(),
        }

        try:
            await self._redis.set(key, json.dumps(state))
        except Exception as e:
            logger.error(f"Failed to track escalation state for {escalation_id}: {e}")
            raise

        logger.debug(
            f"Escalation {escalation_id} hop {hop_index} state: {status.value}"
        )

    @traces(
        spec="001",
        story="US-005",
        reqs=["REQ-001-US-005-016"],
    )
    async def handle_escalation_timeout(
        self, escalation_id: str, hop_index: int
    ) -> None:
        """Handle escalation timeout at specific hop (REQ-001-US-005-003).

        Args:
            escalation_id: Escalation ID
            hop_index: Index of hop that timed out
        """
        logger.warning(f"Escalation {escalation_id} hop {hop_index} timed out")

        try:
            # Update state
            key = f"escalation:{escalation_id}"
            state = await self._redis.get(key)

            if state:
                state_dict = json.loads(state)
                state_dict["timeout_hop"] = hop_index
                state_dict["timeout_at"] = _utc_now().isoformat()
            else:
                # Create new timeout record if no existing state
                state_dict = {
                    "escalation_id": escalation_id,
                    "timeout_hop": hop_index,
                    "timeout_at": _utc_now().isoformat(),
                    "status": "timed_out",
                }

            await self._redis.set(key, json.dumps(state_dict))
        except Exception as e:
            logger.error(f"Failed to update timeout state for {escalation_id}: {e}")
            raise

        # Log to graph (graph.log_event may need await if it's async)
        if self._graph:
            from orchestrata_ultimate.orchestrator.graph_audit import (  # noqa: PLC0415
                AuditEvent,
                AuditEventType,
            )

            event = AuditEvent(
                event_type=AuditEventType.ESCALATION,
                session_id=escalation_id,
                volk_id="system",
                timestamp=_utc_now(),
                data={
                    "escalation_id": escalation_id,
                    "hop_index": hop_index,
                    "action": "timeout",
                },
            )
            await self._graph.log_event(event)

    async def override_escalation_path(
        self, escalation_id: str, new_path: list[str]
    ) -> None:
        """Emergency override of escalation path (REQ-001-US-005-004).

        Args:
            escalation_id: Escalation to override
            new_path: New escalation path
        """
        logger.warning(
            f"Emergency override of escalation {escalation_id} to path: {new_path}"
        )

        try:
            key = f"escalation_override:{escalation_id}"
            await self._redis.set(key, json.dumps(new_path))
        except Exception as e:
            logger.error(f"Failed to override escalation path for {escalation_id}: {e}")
            raise

        # Log override
        if self._graph:
            from orchestrata_ultimate.orchestrator.graph_audit import (  # noqa: PLC0415
                AuditEvent,
                AuditEventType,
            )

            event = AuditEvent(
                event_type=AuditEventType.ESCALATION,
                session_id=escalation_id,
                volk_id="system",
                timestamp=_utc_now(),
                data={
                    "escalation_id": escalation_id,
                    "new_path": new_path,
                    "action": "override",
                },
            )
            await self._graph.log_event(event)

    @traces(spec="001", story="US-003", reqs=["REQ-001-US-003-022"])
    async def get_escalation_path(self, component: str) -> list[str]:
        """Query Prolog for escalation path (REQ-001-US-005-001).

        Args:
            component: Component being escalated

        Returns:
            List of volk aliases in escalation order
        """
        # Query Prolog for multi-hop escalation path (pam_rules.pl -- F4:
        # this predicate did not exist before this fix; every call errored
        # into the "default to orchestrator" fallback below regardless of
        # the real PAM ownership/authority chain). Module-qualified + the
        # component atom quoted (component names are plain lowercase
        # words today, but quoting makes the query correct regardless).
        query = f"pam:multi_hop_escalation({_quote_atom(component)}, Path)"
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(None, self._prolog.query_one, query)

        if result and "Path" in result:
            path = result["Path"]
            # Convert Prolog list to Python list
            if isinstance(path, list):
                return path
            return [path]

        # Default to orchestrator
        return ["orchestrator"]

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-014"])
    async def _is_terminal_authority(self, volk_alias: str) -> bool:
        """Check if volk is terminal authority (REQ-001-US-005-008).

        Args:
            volk_alias: Volk alias to check

        Returns:
            True if terminal authority
        """
        # F4: terminal_authority/1 did not exist before this fix; module-
        # qualified + quoted for the same reason as get_escalation_path
        # above -- a bare volk alias like 'U:Cd' is NOT a valid unquoted
        # Prolog atom (':' is the module-qualification operator, so it
        # would parse as two unbound variables, not the intended atom).
        query = f"pam:terminal_authority({_quote_atom(volk_alias)})"
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._prolog.query_bool, query)

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-019"])
    async def _log_escalation_hop(self, conflict_id: str, hop: EscalationHop) -> None:
        """Log escalation hop details (REQ-001-US-005-005).

        Args:
            conflict_id: Conflict ID
            hop: Hop to log
        """
        hop_key = f"escalation_hops:esc-{conflict_id}"

        hop_data = {
            "hop_index": hop.hop_index,
            "volk_alias": hop.volk_alias,
            "status": hop.status.value,
            "timestamp": hop.timestamp.isoformat(),
            "response": hop.response,
            "retry_count": hop.retry_count,
        }

        try:
            # Append to hop history in Redis
            await self._redis.lpush(hop_key, json.dumps(hop_data))
        except Exception as e:
            logger.error(f"Failed to log escalation hop for {conflict_id}: {e}")
            raise

        logger.info(
            f"Logged escalation hop {hop.hop_index} to {hop.volk_alias}: {hop.status.value}"
        )

    @traces(spec="001", story="US-005", reqs=["REQ-001-US-005-028"])
    async def _store_escalation(self, result: EscalationResult) -> None:
        """Store escalation result in Redis.

        Args:
            result: Escalation result to store
        """
        key = f"escalation:{result.escalation_id}"

        # Determine current hop and status
        current_hop = len(result.hops) - 1 if result.hops else 0
        if result.resolved:
            status = "completed"
        elif any(hop.status == EscalationStatus.failed for hop in result.hops):
            status = "failed"
        elif any(hop.status == EscalationStatus.timeout for hop in result.hops):
            status = "timeout"
        else:
            status = "in_progress"

        data = {
            "conflict_id": result.conflict.conflict_id,
            "resolved": result.resolved,
            "resolution": result.resolution,
            "total_time_ms": result.total_time_ms,
            "hop_count": len(result.hops),
            "current_hop": current_hop,
            "status": status,
            "created_at": result.created_at.isoformat(),
            "updated_at": _utc_now().isoformat(),
            "resolved_at": (
                result.resolved_at.isoformat() if result.resolved_at else None
            ),
        }

        try:
            await self._redis.set(key, json.dumps(data))
        except Exception as e:
            logger.error(f"Failed to store escalation {result.escalation_id}: {e}")
            raise