klieo-ops 0.2.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>,
    },
}

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"
        );
    }
}