Skip to main content

klieo_ops/
ops_event.rs

1//! Typed shape of the body carried inside `klieo_core::Episode::Ops`.
2//!
3//! `klieo-core` carries an opaque `serde_json::Value` to avoid depending on
4//! `klieo-ops`. This module provides typed serde round-tripping plus the
5//! tenant tag that is part of the canonical signed payload (see ADR-008).
6
7use crate::types::{AgentId, ProviderId, TenantId};
8use serde::{Deserialize, Serialize};
9
10/// Operational-layer event. Carried inside `Episode::Ops(serde_json::Value)`.
11///
12/// The `tenant` field is part of the canonical signed payload — chain
13/// signatures cover it, so a tampered tenant tag invalidates the chain.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[non_exhaustive]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum OpsEvent {
18    /// Supervisor lifecycle event.
19    SupervisorStateChange {
20        /// Tenant tag for run-scope consistency.
21        tenant: Option<TenantId>,
22        /// Affected agent.
23        agent: AgentId,
24        /// New state.
25        state: String,
26        /// Optional human-readable reason.
27        reason: Option<String>,
28    },
29    /// Supervisor detected a stalled agent.
30    SupervisorStallDetected {
31        /// Tenant tag.
32        tenant: Option<TenantId>,
33        /// Stalled agent.
34        agent: AgentId,
35        /// Missed heartbeat count.
36        missed_heartbeats: u32,
37    },
38    /// Kill-switch tripped.
39    KillSwitchTripped {
40        /// Tenant tag.
41        tenant: Option<TenantId>,
42        /// Trigger source (programmatic / external / audit).
43        trigger: String,
44        /// Reason recorded with the trip.
45        reason: String,
46    },
47    /// Governor denied an LLM or egress acquire.
48    GovernorDenial {
49        /// Tenant tag.
50        tenant: Option<TenantId>,
51        /// Resource kind: currently `"llm"` or `"egress"`. Stringly-typed
52        /// in v0.2 because the field name was forced by the serde tag
53        /// collision (see ADR-008). A typed enum may replace this in v0.3.
54        resource: String,
55        /// Provider id (LLM only).
56        provider: Option<ProviderId>,
57        /// Host (egress only).
58        host: Option<String>,
59        /// Reason.
60        reason: String,
61    },
62    /// Governor fenced a scope.
63    GovernorFence {
64        /// Tenant tag.
65        tenant: Option<TenantId>,
66        /// Scope description (formatted).
67        scope: String,
68        /// Optional reason.
69        reason: Option<String>,
70    },
71    /// Periodic budget snapshot.
72    GovernorBudgetSnapshot {
73        /// Tenant tag.
74        tenant: Option<TenantId>,
75        /// Scope description.
76        scope: String,
77        /// Remaining budget.
78        remaining: i64,
79        /// Limit.
80        limit: i64,
81    },
82    /// Gate evaluated a tool invocation.
83    GateDecision {
84        /// Tenant tag.
85        tenant: Option<TenantId>,
86        /// Tool name.
87        tool: String,
88        /// `allow` / `deny` / `require_approval`.
89        decision: String,
90        /// Gate name that produced the decision.
91        gate: String,
92        /// Optional policy reference (e.g. cedar bundle sha).
93        policy_ref: Option<String>,
94        /// Optional reason text.
95        reason: Option<String>,
96    },
97    /// New escalation ticket raised.
98    EscalationRaised {
99        /// Tenant tag for run-scope consistency.
100        tenant: Option<TenantId>,
101        /// Stable ticket identifier (klieo-ops generated).
102        ticket: String,
103        /// Severity at time of raise.
104        severity: String,
105        /// Free-form reason text (already redacted via global Redactor).
106        reason: String,
107        /// Optional Merkle root of the provenance bundle attached to the ticket.
108        provenance_root: Option<String>,
109    },
110    /// Escalation state transition (e.g. raised → acknowledged, acknowledged → resolved,
111    /// or requeued + escalated to a higher severity).
112    EscalationStateChanged {
113        /// Tenant tag.
114        tenant: Option<TenantId>,
115        /// Ticket identifier.
116        ticket: String,
117        /// From-state label.
118        from: String,
119        /// To-state label.
120        to: String,
121        /// Optional reason / annotation.
122        reason: Option<String>,
123    },
124    /// Escalation resolved with a terminal outcome (Resolved / AutoDenied / TimedOut /
125    /// Halted).
126    EscalationResolved {
127        /// Tenant tag.
128        tenant: Option<TenantId>,
129        /// Ticket identifier.
130        ticket: String,
131        /// Terminal outcome label (e.g. "resolved", "auto_denied", "timed_out", "halted").
132        outcome: String,
133        /// Optional resolution reason text.
134        reason: Option<String>,
135    },
136    /// Work item planned (entered the DAG).
137    WorkPlanned {
138        /// Tenant tag.
139        tenant: Option<TenantId>,
140        /// Work identifier.
141        work_id: String,
142        /// Item title or short label.
143        title: String,
144        /// Optional list of work_ids this item depends on (declared at plan time).
145        depends_on: Vec<String>,
146    },
147    /// Dependency relationship added between two existing work items.
148    WorkDependencyAdded {
149        /// Tenant tag.
150        tenant: Option<TenantId>,
151        /// Child work id.
152        child: String,
153        /// Dependency parent work id.
154        on: String,
155    },
156    /// Work item dispatched (enqueued onto the JobQueue for execution).
157    WorkDispatched {
158        /// Tenant tag.
159        tenant: Option<TenantId>,
160        /// Work identifier.
161        work_id: String,
162    },
163    /// Work item state transition.
164    WorkTransition {
165        /// Tenant tag.
166        tenant: Option<TenantId>,
167        /// Work identifier.
168        work_id: String,
169        /// From-state label.
170        from: String,
171        /// To-state label.
172        to: String,
173        /// Optional reason / annotation.
174        reason: Option<String>,
175    },
176    /// Handoff envelope packaged and signed.
177    HandoffPackaged {
178        /// Tenant tag.
179        tenant: Option<TenantId>,
180        /// Source run id.
181        from_run: String,
182        /// Hex-encoded Merkle root.
183        merkle_root: String,
184    },
185    /// Handoff envelope delivered to a receiver agent.
186    HandoffDelivered {
187        /// Tenant tag.
188        tenant: Option<TenantId>,
189        /// Source run id.
190        from_run: String,
191        /// Receiver agent identifier.
192        to_agent: String,
193    },
194    /// Handoff verification succeeded.
195    HandoffVerified {
196        /// Tenant tag.
197        tenant: Option<TenantId>,
198        /// Source run id.
199        from_run: String,
200    },
201    /// Handoff verification failed (bad sig, expired, prefix proof invalid,
202    /// or redaction schema fail).
203    HandoffVerificationFailed {
204        /// Tenant tag.
205        tenant: Option<TenantId>,
206        /// Source run id (if extractable from the envelope).
207        from_run: Option<String>,
208        /// Free-form reason.
209        reason: String,
210    },
211}
212
213impl OpsEvent {
214    /// Convert to the opaque `serde_json::Value` shape consumed by
215    /// `Episode::Ops`. Returns an error only if serde_json cannot serialise
216    /// the value — practically impossible for this type, but must not panic
217    /// in production code paths.
218    pub fn into_episode_payload(self) -> Result<serde_json::Value, serde_json::Error> {
219        serde_json::to_value(self)
220    }
221
222    /// Round-trip from an opaque `Episode::Ops` payload. Returns
223    /// `None` if the payload doesn't match the known shape.
224    #[must_use]
225    pub fn from_episode_payload(v: &serde_json::Value) -> Option<Self> {
226        serde_json::from_value(v.clone()).ok()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::types::TenantId;
234
235    #[test]
236    fn supervisor_state_change_round_trips() {
237        let original = OpsEvent::SupervisorStateChange {
238            tenant: Some(TenantId::default()),
239            agent: crate::types::AgentId::default(),
240            state: "running".into(),
241            reason: Some("startup".into()),
242        };
243        let payload = original.clone().into_episode_payload().expect("serialise");
244        let recovered = OpsEvent::from_episode_payload(&payload).expect("round-trip");
245        // We assert via JSON equality because OpsEvent does not derive Eq.
246        let original_json = serde_json::to_value(&original).unwrap();
247        let recovered_json = serde_json::to_value(&recovered).unwrap();
248        assert_eq!(original_json, recovered_json);
249    }
250
251    #[test]
252    fn discriminator_is_snake_case() {
253        let event = OpsEvent::KillSwitchTripped {
254            tenant: None,
255            trigger: "programmatic".into(),
256            reason: "operator initiated".into(),
257        };
258        let payload = event.into_episode_payload().expect("serialise");
259        assert_eq!(
260            payload.get("kind").and_then(|v| v.as_str()),
261            Some("kill_switch_tripped"),
262            "serde rename_all = snake_case must produce snake_case discriminator"
263        );
264    }
265
266    #[test]
267    fn work_planned_round_trips() {
268        let event = OpsEvent::WorkPlanned {
269            tenant: Some(TenantId("BL_auto".into())),
270            work_id: "wrk_01JXABCDEF".into(),
271            title: "triage claim #4201".into(),
272            depends_on: vec!["wrk_01JXA00001".into(), "wrk_01JXA00002".into()],
273        };
274        let payload = event.clone().into_episode_payload().expect("serialise");
275        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
276        let original_json = serde_json::to_value(&event).unwrap();
277        let recovered_json = serde_json::to_value(&round).unwrap();
278        assert_eq!(original_json, recovered_json);
279        assert_eq!(
280            payload.get("kind").and_then(|v| v.as_str()),
281            Some("work_planned")
282        );
283    }
284
285    #[test]
286    fn handoff_verified_round_trips() {
287        let event = OpsEvent::HandoffVerified {
288            tenant: Some(TenantId("BL_auto".into())),
289            from_run: "rn_01HXX0000001".into(),
290        };
291        let payload = event.clone().into_episode_payload().expect("serialise");
292        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
293        let original_json = serde_json::to_value(&event).unwrap();
294        let recovered_json = serde_json::to_value(&round).unwrap();
295        assert_eq!(original_json, recovered_json);
296        assert_eq!(
297            payload.get("kind").and_then(|v| v.as_str()),
298            Some("handoff_verified")
299        );
300    }
301
302    #[test]
303    fn escalation_raised_round_trips() {
304        let event = OpsEvent::EscalationRaised {
305            tenant: Some(TenantId("BL_test".into())),
306            ticket: "esc_01HXY0000001".into(),
307            severity: "high".into(),
308            reason: "amount exceeds tenant cap".into(),
309            provenance_root: Some("b3ad7e".into()),
310        };
311        let payload = event.clone().into_episode_payload().expect("serialise");
312        let round = OpsEvent::from_episode_payload(&payload).expect("deserialise");
313        let original_json = serde_json::to_value(&event).unwrap();
314        let recovered_json = serde_json::to_value(&round).unwrap();
315        assert_eq!(original_json, recovered_json);
316        assert_eq!(
317            payload.get("kind").and_then(|v| v.as_str()),
318            Some("escalation_raised")
319        );
320    }
321}