klieo-ops 3.3.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Typed shape of the body carried inside `klieo_core::Episode::Ops`.
//!
//! `klieo-core` carries an opaque `serde_json::Value` to avoid depending on
//! `klieo-ops`. This module provides typed serde round-tripping plus the
//! tenant tag that is part of the canonical signed payload (see ADR-008).

use crate::types::{AgentId, ProviderId, TenantId};
use serde::{Deserialize, Serialize};

/// Operational-layer event. Carried inside `Episode::Ops(serde_json::Value)`.
///
/// The `tenant` field is part of the canonical signed payload — chain
/// signatures cover it, so a tampered tenant tag invalidates the chain.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OpsEvent {
    /// Supervisor lifecycle event.
    SupervisorStateChange {
        /// Tenant tag for run-scope consistency.
        tenant: Option<TenantId>,
        /// Affected agent.
        agent: AgentId,
        /// New state.
        state: String,
        /// Optional human-readable reason.
        reason: Option<String>,
    },
    /// Supervisor detected a stalled agent.
    SupervisorStallDetected {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Stalled agent.
        agent: AgentId,
        /// Missed heartbeat count.
        missed_heartbeats: u32,
    },
    /// Kill-switch tripped.
    KillSwitchTripped {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Trigger source (programmatic / external / audit).
        trigger: String,
        /// Reason recorded with the trip.
        reason: String,
    },
    /// Governor denied an LLM or egress acquire.
    GovernorDenial {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Resource kind: currently `"llm"` or `"egress"`. Stringly-typed
        /// in v0.2 because the field name was forced by the serde tag
        /// collision (see ADR-008). A typed enum may replace this in v0.3.
        resource: String,
        /// Provider id (LLM only).
        provider: Option<ProviderId>,
        /// Host (egress only).
        host: Option<String>,
        /// Reason.
        reason: String,
    },
    /// Governor fenced a scope.
    GovernorFence {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Scope description (formatted).
        scope: String,
        /// Optional reason.
        reason: Option<String>,
    },
    /// Periodic budget snapshot.
    GovernorBudgetSnapshot {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Scope description.
        scope: String,
        /// Remaining budget.
        remaining: i64,
        /// Limit.
        limit: i64,
    },
    /// Gate evaluated a tool invocation.
    GateDecision {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Tool name.
        tool: String,
        /// `allow` / `deny` / `require_approval`.
        decision: String,
        /// Gate name that produced the decision.
        gate: String,
        /// Optional policy reference (e.g. cedar bundle sha).
        policy_ref: Option<String>,
        /// Optional reason text.
        reason: Option<String>,
    },
    /// New escalation ticket raised.
    EscalationRaised {
        /// Tenant tag for run-scope consistency.
        tenant: Option<TenantId>,
        /// Stable ticket identifier (klieo-ops generated).
        ticket: String,
        /// Severity at time of raise.
        severity: String,
        /// Free-form reason text (already redacted via global Redactor).
        reason: String,
        /// Optional Merkle root of the provenance bundle attached to the ticket.
        provenance_root: Option<String>,
    },
    /// Escalation state transition (e.g. raised → acknowledged, acknowledged → resolved,
    /// or requeued + escalated to a higher severity).
    EscalationStateChanged {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Ticket identifier.
        ticket: String,
        /// From-state label.
        from: String,
        /// To-state label.
        to: String,
        /// Optional reason / annotation.
        reason: Option<String>,
    },
    /// Escalation resolved with a terminal outcome (Resolved / AutoDenied / TimedOut /
    /// Halted).
    EscalationResolved {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Ticket identifier.
        ticket: String,
        /// Terminal outcome label (e.g. "resolved", "auto_denied", "timed_out", "halted").
        outcome: String,
        /// Optional resolution reason text.
        reason: Option<String>,
    },
    /// Work item planned (entered the DAG).
    WorkPlanned {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Work identifier.
        work_id: String,
        /// Item title or short label.
        title: String,
        /// Optional list of work_ids this item depends on (declared at plan time).
        depends_on: Vec<String>,
    },
    /// Dependency relationship added between two existing work items.
    WorkDependencyAdded {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Child work id.
        child: String,
        /// Dependency parent work id.
        on: String,
    },
    /// Work item dispatched (enqueued onto the JobQueue for execution).
    WorkDispatched {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Work identifier.
        work_id: String,
    },
    /// Work item state transition.
    WorkTransition {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Work identifier.
        work_id: String,
        /// From-state label.
        from: String,
        /// To-state label.
        to: String,
        /// Optional reason / annotation.
        reason: Option<String>,
    },
    /// Handoff envelope packaged and signed.
    HandoffPackaged {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Source run id.
        from_run: String,
        /// Hex-encoded Merkle root.
        merkle_root: String,
    },
    /// Handoff envelope delivered to a receiver agent.
    HandoffDelivered {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Source run id.
        from_run: String,
        /// Receiver agent identifier.
        to_agent: String,
    },
    /// Handoff verification succeeded.
    HandoffVerified {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Source run id.
        from_run: String,
    },
    /// Handoff verification failed (bad sig, expired, prefix proof invalid,
    /// or redaction schema fail).
    HandoffVerificationFailed {
        /// Tenant tag.
        tenant: Option<TenantId>,
        /// Source run id (if extractable from the envelope).
        from_run: Option<String>,
        /// Free-form reason.
        reason: String,
    },
}

impl OpsEvent {
    /// Convert to the opaque `serde_json::Value` shape consumed by
    /// `Episode::Ops`. Returns an error only if serde_json cannot serialise
    /// the value — practically impossible for this type, but must not panic
    /// in production code paths.
    pub fn into_episode_payload(self) -> Result<serde_json::Value, serde_json::Error> {
        serde_json::to_value(self)
    }

    /// Round-trip from an opaque `Episode::Ops` payload. Returns
    /// `None` if the payload doesn't match the known shape.
    #[must_use]
    pub fn from_episode_payload(v: &serde_json::Value) -> Option<Self> {
        serde_json::from_value(v.clone()).ok()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::TenantId;

    #[test]
    fn supervisor_state_change_round_trips() {
        let original = OpsEvent::SupervisorStateChange {
            tenant: Some(TenantId::default()),
            agent: crate::types::AgentId::default(),
            state: "running".into(),
            reason: Some("startup".into()),
        };
        let payload = original.clone().into_episode_payload().expect("serialise");
        let recovered = OpsEvent::from_episode_payload(&payload).expect("round-trip");
        // We assert via JSON equality because OpsEvent does not derive Eq.
        let original_json = serde_json::to_value(&original).unwrap();
        let recovered_json = serde_json::to_value(&recovered).unwrap();
        assert_eq!(original_json, recovered_json);
    }

    #[test]
    fn discriminator_is_snake_case() {
        let event = OpsEvent::KillSwitchTripped {
            tenant: None,
            trigger: "programmatic".into(),
            reason: "operator initiated".into(),
        };
        let payload = event.into_episode_payload().expect("serialise");
        assert_eq!(
            payload.get("kind").and_then(|v| v.as_str()),
            Some("kill_switch_tripped"),
            "serde rename_all = snake_case must produce snake_case discriminator"
        );
    }

    #[test]
    fn work_planned_round_trips() {
        let event = OpsEvent::WorkPlanned {
            tenant: Some(TenantId("BL_auto".into())),
            work_id: "wrk_01JXABCDEF".into(),
            title: "triage claim #4201".into(),
            depends_on: vec!["wrk_01JXA00001".into(), "wrk_01JXA00002".into()],
        };
        let payload = event.clone().into_episode_payload().expect("serialise");
        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
        let original_json = serde_json::to_value(&event).unwrap();
        let recovered_json = serde_json::to_value(&round).unwrap();
        assert_eq!(original_json, recovered_json);
        assert_eq!(
            payload.get("kind").and_then(|v| v.as_str()),
            Some("work_planned")
        );
    }

    #[test]
    fn handoff_verified_round_trips() {
        let event = OpsEvent::HandoffVerified {
            tenant: Some(TenantId("BL_auto".into())),
            from_run: "rn_01HXX0000001".into(),
        };
        let payload = event.clone().into_episode_payload().expect("serialise");
        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
        let original_json = serde_json::to_value(&event).unwrap();
        let recovered_json = serde_json::to_value(&round).unwrap();
        assert_eq!(original_json, recovered_json);
        assert_eq!(
            payload.get("kind").and_then(|v| v.as_str()),
            Some("handoff_verified")
        );
    }

    #[test]
    fn escalation_raised_round_trips() {
        let event = OpsEvent::EscalationRaised {
            tenant: Some(TenantId("BL_test".into())),
            ticket: "esc_01HXY0000001".into(),
            severity: "high".into(),
            reason: "amount exceeds tenant cap".into(),
            provenance_root: Some("b3ad7e".into()),
        };
        let payload = event.clone().into_episode_payload().expect("serialise");
        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
        let original_json = serde_json::to_value(&event).unwrap();
        let recovered_json = serde_json::to_value(&round).unwrap();
        assert_eq!(original_json, recovered_json);
        assert_eq!(
            payload.get("kind").and_then(|v| v.as_str()),
            Some("escalation_raised")
        );
    }
}