klieo-ops 0.41.2

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! `Escalation` trait + supporting types.

use crate::types::TenantId;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Severity classifier for escalations. Determines acknowledgement-target
/// SLA + breach-auto-escalate behaviour.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Severity {
    /// Operator-immediate. Default ack target: 0 (page now).
    Critical,
    /// Default ack target: 5 minutes.
    High,
    /// Default ack target: 30 minutes.
    Medium,
    /// Default ack target: 4 hours.
    Low,
}

impl Severity {
    /// Severity one level above; saturates at Critical.
    #[must_use]
    pub fn escalate_one_level(self) -> Self {
        match self {
            Self::Low => Self::Medium,
            Self::Medium => Self::High,
            Self::High => Self::Critical,
            Self::Critical => Self::Critical,
        }
    }
}

/// Lifecycle state of an escalation ticket.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EscalationState {
    /// Newly raised, not yet acknowledged.
    Raised,
    /// Acknowledged by an operator (or downstream system).
    Acknowledged,
    /// Resolved successfully.
    Resolved,
    /// Auto-denied (e.g. quorum not reached, timeout).
    AutoDenied,
    /// Halted (kill-switch tripped while awaiting approval).
    Halted,
}

/// Reference to a provenance bundle attached to an escalation ticket.
///
/// Reviewers see the full Merkle chain + Ed25519 chain signature via this
/// reference. Phase A captures only the Merkle root + signature blob; the
/// bundle itself lives wherever klieo-provenance persists it (typically
/// alongside the run log).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ProvenanceRef {
    /// Run identifier.
    pub run_id: String,
    /// Hex-encoded Merkle root at the time of escalation raise.
    pub merkle_root: String,
    /// Hex-encoded Ed25519 chain signature over the Merkle root.
    pub chain_signature: Option<String>,
}

impl ProvenanceRef {
    /// Construct a `ProvenanceRef`. Use this instead of struct literal syntax —
    /// `#[non_exhaustive]` blocks cross-crate struct literals.
    pub fn new(
        run_id: impl Into<String>,
        merkle_root: impl Into<String>,
        chain_signature: Option<String>,
    ) -> Self {
        Self {
            run_id: run_id.into(),
            merkle_root: merkle_root.into(),
            chain_signature,
        }
    }
}

/// Inputs to `Escalation::raise`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EscalationTicket {
    /// Tenant tag (None when raising globally).
    pub tenant: Option<TenantId>,
    /// Severity classifier.
    pub severity: Severity,
    /// Free-form reason. MUST be pre-redacted by the caller (the
    /// `OpsRuntime` Redactor scrubs it again before persistence as a
    /// belt-and-braces measure).
    pub reason: String,
    /// Optional provenance bundle reference for reviewer drill-down.
    pub provenance: Option<ProvenanceRef>,
}

impl EscalationTicket {
    /// Construct an `EscalationTicket`. Use this instead of struct literal
    /// syntax — `#[non_exhaustive]` blocks cross-crate struct literals.
    pub fn new(
        severity: Severity,
        reason: impl Into<String>,
        tenant: Option<TenantId>,
        provenance: Option<ProvenanceRef>,
    ) -> Self {
        Self {
            tenant,
            severity,
            reason: reason.into(),
            provenance,
        }
    }
}

/// Server-assigned escalation identifier.
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct EscalationId(pub String);

impl std::fmt::Display for EscalationId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Resolution payload for `Escalation::resolve`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Resolution {
    /// Terminal outcome.
    pub outcome: ResolutionOutcome,
    /// Free-form reason; redacted before persistence.
    pub reason: Option<String>,
    /// Approver IDs that signed off (Phase B FourEyesGate wires this).
    /// Phase A always-empty; M8 fills in.
    pub approvers: Vec<String>,
    /// Approver signatures matching `approvers` 1:1 (Phase B M8 fills in).
    pub signatures: Vec<Vec<u8>>,
}

impl Resolution {
    /// Construct a `Resolution`. Use this instead of struct literal syntax —
    /// `#[non_exhaustive]` blocks cross-crate struct literals.
    pub fn new(
        outcome: ResolutionOutcome,
        reason: Option<String>,
        approvers: Vec<String>,
        signatures: Vec<Vec<u8>>,
    ) -> Self {
        Self {
            outcome,
            reason,
            approvers,
            signatures,
        }
    }
}

/// Terminal outcome of an escalation resolution.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResolutionOutcome {
    /// Reviewer(s) approved.
    Approved,
    /// Reviewer(s) explicitly denied.
    Denied,
    /// Timed out without resolution.
    TimedOut,
    /// Auto-resolved by kill-switch trip.
    Halted,
}

/// Filter for `Escalation::list`.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct EscalationFilter {
    /// Optionally restrict to tickets in these states.
    pub states: Option<Vec<EscalationState>>,
    /// Optionally restrict to tickets at or above this severity.
    pub min_severity: Option<Severity>,
    /// Optionally restrict to this tenant.
    pub tenant: Option<TenantId>,
}

/// Errors raised by `Escalation` calls.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EscalationError {
    /// Underlying storage/channel unreachable.
    #[error("escalation channel unavailable: {0}")]
    Unavailable(String),
    /// Caller-supplied ticket id is unknown.
    #[error("unknown escalation ticket: {0}")]
    UnknownTicket(EscalationId),
    /// Resolution carried fewer approvers than required (Phase B
    /// FourEyesGate triggers this; Phase A escalation never).
    #[error("quorum not met: {provided}/{required}")]
    QuorumNotMet {
        /// Approvers actually provided.
        provided: u8,
        /// Approvers required.
        required: u8,
    },
    /// Other internal.
    #[error("internal: {0}")]
    Internal(String),
}

/// Escalation primitive.
#[async_trait]
pub trait Escalation: Send + Sync {
    /// Raise a new ticket; returns the server-assigned id.
    async fn raise(&self, ticket: EscalationTicket) -> Result<EscalationId, EscalationError>;

    /// Resolve an existing ticket with the given resolution payload.
    async fn resolve(
        &self,
        id: EscalationId,
        resolution: Resolution,
    ) -> Result<(), EscalationError>;

    /// List tickets matching the given filter.
    async fn list(&self, filter: EscalationFilter) -> Vec<EscalationTicket>;
}