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}