orchestrata_ultimate.kernel.pam.escalation
Reference › orchestrata_ultimate › kernel › pam › escalation
Escalation management for PAM++, SPEC-001 stories US-003 and US-005. When the enforcer's checks produce a conflict between volks, this module decides who must settle it and tracks the case until it is resolved or times out. The pam package imports it, the value types are in types, and the multi-hop part uses AdvancedEscalationHandler from the governance escalation handler.
EscalationManager holds the open escalations by id. detect_conflict takes a ConflictContext and asks Prolog escalation_required/1 for its conflict type; it returns a Conflict with a generated id, or None when no escalation is needed or the query fails. get_escalation_path asks pam_escalation_path/2 and escalation_priority/2 and builds an EscalationPath of numbered steps; with no answer it falls back to a single step to the orchestrator alias U:Ox at priority 1. escalate assigns an id, sets a timeout (default 3600 seconds), stores a pending EscalationResult, marks the first step as notified and logs it to the graph sink when present. resolve marks an escalation resolved with the resolution text, and timeout_stale marks pending or in-progress escalations past their deadline as timed out and returns their ids. get_escalation and get_all_escalations read the store.
MultiHopEscalationManager adds multi-hop routing on top: route_escalation returns the authority of the first step of an existing escalation (single-hop, for backward compatibility), and multi_hop_escalate hands a conflict and a path of volk aliases to the governance AdvancedEscalationHandler, starting at the first alias or orchestrator when the path is empty.
At a glance
| Source | src/orchestrata_ultimate/kernel/pam/escalation.py |
| Lines of code | 447 |
| Classes | 3 |
| Functions | 0 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.protocols, orchestrata_ultimate.governance.escalation_handler, orchestrata_ultimate.kernel.pam.types |
| Imported by | orchestrata_ultimate.kernel.pam |
| Tests that import it | none |
Class diagram
Dependency diagram
API
Classes
EscalationManager
Decorators: traces(spec='001', story='US-003', reqs=['REQ-001-US-003-021', 'REQ-001-US-003-023', 'REQ-001-US-003-024']) · Line 48
Escalation Manager for conflict resolution.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(prolog_bridge: IGovernancePrologBridge, graph_audit: Any | None = None, default_timeout_seconds: int = 3600, escalations: dict[str, EscalationResult] | None = None) -> None |
Initialize escalation manager. |
detect_conflict |
async detect_conflict(context: ConflictContext) -> Conflict | None |
Detect if context represents a conflict (REQ-001-US-003-021). |
get_escalation_path |
async get_escalation_path(conflict: Conflict) -> EscalationPath |
Get escalation path for conflict (REQ-001-US-003-022). |
escalate |
async escalate(conflict: Conflict) -> EscalationResult |
Escalate conflict to appropriate authority (REQ-001-US-003-023 to REQ-001-US-003-027). |
resolve |
async resolve(escalation_id: str, resolution: Resolution) -> bool |
Resolve an escalation (REQ-001-US-003-029). |
timeout_stale |
async timeout_stale(max_age_seconds: int) -> list[str] |
Timeout stale escalations (REQ-001-US-003-025). |
get_escalation |
get_escalation(escalation_id: str) -> EscalationResult | None |
Get escalation by ID (REQ-001-US-003-024). |
get_all_escalations |
get_all_escalations() -> list[EscalationResult] |
Get all escalations. |
_EscalationLookup (protocol)
Bases: Protocol · Decorators: runtime_checkable · Line 334
Structural shape route_escalation actually needs.
| Method | Signature | Summary |
|---|---|---|
get_escalation |
get_escalation(escalation_id: str) -> EscalationResult | None |
MultiHopEscalationManager
Decorators: traces(spec='001', story='US-003', reqs=['REQ-001-US-003-023', 'REQ-001-US-003-028', 'REQ-001-US-005-014', 'REQ-001-US-… · Line 364
Enhanced escalation manager with multi-hop support (US-005).
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(pam_enforcer: _EscalationLookup, prolog: IGovernancePrologBridge, redis_state: Any, graph_audit: Any | None = None) -> None |
Initialize multi-hop escalation manager. |
route_escalation |
async route_escalation(escalation_id: str, conflict: Any) -> str | None |
Route escalation (backward compatible single-hop). |
multi_hop_escalate |
async multi_hop_escalate(conflict: AdvancedConflict, path: list[str]) -> Any |
Execute multi-hop escalation (NEW - US-005). |
Source
# mypy: ignore-errors
"""Escalation management for PAM++ (EscalationManager, MultiHopEscalationManager).
Decomposed from kernel/pam_enforcer.py. Handles conflict detection, escalation
routing, resolution tracking, and advanced multi-hop escalation.
"""
import asyncio
import logging
import uuid
from datetime import timedelta
from typing import Any, Protocol, runtime_checkable
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.protocols import IGovernancePrologBridge
# Import for Advanced Escalation Integration (US-005)
# Kept at module top to avoid circular dependency
from orchestrata_ultimate.governance.escalation_handler import (
AdvancedEscalationHandler,
)
from orchestrata_ultimate.governance.escalation_handler import (
Conflict as AdvancedConflict,
)
from orchestrata_ultimate.kernel.pam.types import (
Conflict,
ConflictContext,
EscalationPath,
EscalationResult,
EscalationStatus,
EscalationStep,
Resolution,
_utc_now,
)
logger = logging.getLogger(__name__)
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-021",
"REQ-001-US-003-023",
"REQ-001-US-003-024",
],
)
class EscalationManager:
"""Escalation Manager for conflict resolution.
Handles conflict detection, escalation routing, and resolution tracking.
"""
def __init__(
self,
prolog_bridge: IGovernancePrologBridge,
graph_audit: Any | None = None,
default_timeout_seconds: int = 3600,
escalations: dict[str, EscalationResult] | None = None,
) -> None:
"""Initialize escalation manager.
Args:
prolog_bridge: IGovernancePrologBridge for rule queries
graph_audit: Graph audit trail (optional)
default_timeout_seconds: Default escalation timeout
escalations: Initial escalations dict (optional)
"""
self._prolog = prolog_bridge
self._graph = graph_audit
self._default_timeout = default_timeout_seconds
self._escalations: dict[str, EscalationResult] = (
escalations if escalations is not None else {}
)
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-021",
"REQ-001-US-005-001",
"REQ-001-US-005-002",
"REQ-001-US-005-003",
],
)
async def detect_conflict(self, context: ConflictContext) -> Conflict | None:
"""Detect if context represents a conflict (REQ-001-US-003-021).
Args:
context: Conflict context
Returns:
Conflict if detected, None otherwise
"""
# Query Prolog to check if escalation needed (async)
conflict_type = context.conflict_type.value
query = f"pam:escalation_required({conflict_type})"
loop = asyncio.get_running_loop()
try:
needs_esc = await loop.run_in_executor(None, self._prolog.query_bool, query)
except Exception as e:
logger.error(f"Prolog query failed for conflict detection: {e}")
needs_esc = False
if not needs_esc:
return None
conflict_id = f"conflict-{uuid.uuid4().hex[:8]}"
return Conflict(
conflict_id=conflict_id,
conflict_type=context.conflict_type,
context=context,
)
@traces(
spec="001",
story="US-003",
reqs=["REQ-001-US-003-022", "REQ-001-US-005-011"],
)
async def get_escalation_path(self, conflict: Conflict) -> EscalationPath:
"""Get escalation path for conflict (REQ-001-US-003-022).
Args:
conflict: Conflict to escalate
Returns:
EscalationPath with steps and priority
"""
conflict_type = conflict.conflict_type.value
loop = asyncio.get_running_loop()
# Query Prolog for escalation path (async)
path_query = f"pam:pam_escalation_path({conflict_type}, Path)"
try:
path_result = await loop.run_in_executor(
None, self._prolog.query_one, path_query
)
except Exception as e:
logger.error(f"Prolog query failed for escalation path: {e}")
path_result = None
if not path_result:
# Default to Orchestrator
return EscalationPath(
steps=[EscalationStep(level=1, authority="U:Ox")],
final_authority="U:Ox",
priority=1,
)
# Parse path from Prolog list
path_list = path_result.get("Path", ["U:Ox"])
steps = [
EscalationStep(level=idx + 1, authority=auth)
for idx, auth in enumerate(path_list)
]
# Get priority (async)
priority_query = f"pam:escalation_priority({conflict_type}, Priority)"
try:
priority_result = await loop.run_in_executor(
None, self._prolog.query_one, priority_query
)
priority = priority_result.get("Priority", 1) if priority_result else 1
except Exception as e:
logger.error(f"Prolog query failed for escalation priority: {e}")
priority = 1
return EscalationPath(
steps=steps,
final_authority=path_list[-1] if path_list else "U:Ox",
priority=priority,
)
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-023",
"REQ-001-US-003-026",
"REQ-001-US-003-030",
],
)
async def escalate(self, conflict: Conflict) -> EscalationResult:
"""Escalate conflict to appropriate authority (REQ-001-US-003-023 to REQ-001-US-003-027).
Args:
conflict: Conflict to escalate
Returns:
EscalationResult with escalation tracking info
"""
escalation_id = f"esc-{uuid.uuid4().hex[:8]}"
# Get escalation path
path = await self.get_escalation_path(conflict)
# Create escalation result
timeout_at = _utc_now() + timedelta(seconds=self._default_timeout)
result = EscalationResult(
escalation_id=escalation_id,
conflict=conflict,
path=path,
status=EscalationStatus.PENDING,
timeout_at=timeout_at,
)
# Store escalation
self._escalations[escalation_id] = result
# Notify first authority in path (REQ-001-US-003-026)
if path.steps:
first_step = path.steps[0]
first_step.notified_at = _utc_now()
logger.info(
f"Escalation {escalation_id} notified to {first_step.authority} "
f"(priority: {path.priority})"
)
# Log to graph (REQ-001-US-003-030)
if self._graph:
await self._graph.log_escalation(result)
return result
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-029",
"REQ-001-US-005-021",
"REQ-001-US-005-024",
],
)
async def resolve(self, escalation_id: str, resolution: Resolution) -> bool:
"""Resolve an escalation (REQ-001-US-003-029).
Args:
escalation_id: Escalation to resolve
resolution: Resolution details
Returns:
True if resolved successfully
"""
if escalation_id not in self._escalations:
return False
escalation = self._escalations[escalation_id]
escalation.status = EscalationStatus.RESOLVED
escalation.resolved_at = resolution.resolved_at
escalation.resolution = resolution.resolution_text
logger.info(
f"Escalation {escalation_id} resolved by {resolution.resolved_by}: "
f"{resolution.resolution_text}"
)
# Log to graph (REQ-001-US-003-030)
if self._graph:
await self._graph.log_resolution(escalation_id, resolution)
return True
@traces(
spec="001",
story="US-003",
reqs=["REQ-001-US-003-025", "REQ-001-US-005-016"],
)
async def timeout_stale(self, max_age_seconds: int) -> list[str]:
"""Timeout stale escalations (REQ-001-US-003-025).
Args:
max_age_seconds: Maximum age before timeout
Returns:
List of timed out escalation IDs
"""
timed_out = []
for esc_id, escalation in self._escalations.items():
# Check both PENDING and IN_PROGRESS escalations
if escalation.status in (
EscalationStatus.PENDING,
EscalationStatus.IN_PROGRESS,
):
if escalation.timeout_at and escalation.timeout_at < _utc_now():
escalation.status = EscalationStatus.TIMED_OUT
timed_out.append(esc_id)
logger.warning(f"Escalation {esc_id} timed out")
# Log to graph (REQ-001-US-003-030)
if self._graph:
await self._graph.log_timeout(esc_id)
return timed_out
@traces(spec="001", story="US-003", reqs=["REQ-001-US-003-024"])
def get_escalation(self, escalation_id: str) -> EscalationResult | None:
"""Get escalation by ID (REQ-001-US-003-024).
Args:
escalation_id: Escalation ID
Returns:
EscalationResult if found
"""
return self._escalations.get(escalation_id)
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-024",
],
)
def get_all_escalations(self) -> list[EscalationResult]:
"""Get all escalations.
Returns:
List of all escalation results
"""
return list(self._escalations.values())
# ================================================================
# Advanced Escalation Integration (US-005)
# ================================================================
@runtime_checkable
class _EscalationLookup(Protocol):
"""Structural shape ``route_escalation`` actually needs.
``MultiHopEscalationManager`` is typed to accept a ``PAMEnforcer``, but
``route_escalation`` below calls ``.get_escalation()``, a method that
lives on ``EscalationManager`` (this same module), not ``PAMEnforcer`` --
``PAMEnforcer`` has no escalation storage of its own. This is a
pre-existing bug inherited from the source monorepo (same mismatch in
``orchestrata-kernel/pam_enforcer.py``); there are zero production
callers of ``MultiHopEscalationManager`` (unwired), only tests, and the
tests that exercise ``route_escalation`` pass an ``EscalationManager``
despite the ``pam_enforcer`` parameter name. Typing the parameter to
what the method genuinely requires documents the real contract without
guessing at (or silently forcing) the missing production wiring
decision.
"""
def get_escalation(self, escalation_id: str) -> EscalationResult | None: ...
@traces(
spec="001",
story="US-003",
reqs=[
"REQ-001-US-003-023",
"REQ-001-US-003-028",
"REQ-001-US-005-014",
"REQ-001-US-005-015",
],
)
class MultiHopEscalationManager:
"""Enhanced escalation manager with multi-hop support (US-005).
Extends single-hop escalation with advanced multi-hop capabilities.
Use this for advanced escalation scenarios. For basic escalation,
use the original EscalationManager class.
"""
def __init__(
self,
pam_enforcer: _EscalationLookup,
prolog: IGovernancePrologBridge,
redis_state: Any,
graph_audit: Any | None = None,
) -> None:
"""Initialize multi-hop escalation manager.
Args:
pam_enforcer: PAM enforcer instance
prolog: Prolog bridge
redis_state: Redis state manager
graph_audit: Optional Graph audit logger
"""
self._pam = pam_enforcer
self._prolog = prolog
self._redis = redis_state
self._graph = graph_audit
# Create advanced escalation handler
self._advanced_handler = AdvancedEscalationHandler(
prolog=prolog, redis=redis_state, graph=graph_audit
)
@traces(spec="001", story="US-003", reqs=["REQ-001-US-003-023"])
async def route_escalation(self, escalation_id: str, conflict: Any) -> str | None:
"""Route escalation (backward compatible single-hop).
Args:
escalation_id: Escalation ID
conflict: Conflict to escalate
Returns:
Authority to route to, or None
"""
# Use existing PAM enforcer logic
escalation = self._pam.get_escalation(escalation_id)
if not escalation:
return None
# Get escalation path from PAM
path = escalation.path
if path.steps:
return path.steps[0].authority
return None
@traces(
spec="001",
story="US-003",
reqs=["REQ-001-US-003-028", "REQ-001-US-005-015"],
)
async def multi_hop_escalate(
self, conflict: AdvancedConflict, path: list[str]
) -> Any:
"""Execute multi-hop escalation (NEW - US-005).
Args:
conflict: Conflict to escalate
path: Escalation path (list of volk aliases)
Returns:
EscalationResult with full hop history
"""
# Convert path to starting volk (first in path)
starting_volk = path[0] if path else "orchestrator"
# Use advanced escalation handler
result = await self._advanced_handler.escalate(
conflict=conflict, starting_volk=starting_volk
)
return result