1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
//! 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")
);
}
}