1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::kernel::determinism_guard::verify_event_stream_hash;
12use crate::kernel::event::Event;
13use crate::kernel::identity::RunId;
14use crate::kernel::EventStore;
15use crate::kernel::KernelError;
16
17#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
19pub enum VerificationFailure {
20 StateHashMismatch { expected: String, actual: String },
22 ToolChecksumMismatch { expected: String, actual: String },
24 UnmatchedInterrupt { seq: u64, value: serde_json::Value },
26 UnmatchedResume { seq: u64 },
28 RunNotFound,
30}
31
32pub type VerificationResult = Result<(), VerificationFailure>;
34
35#[derive(Clone, Debug, Default)]
37pub struct VerifyConfig {
38 pub verify_state_hash: bool,
40 pub expected_state_hash: Option<String>,
42 pub verify_tool_checksum: bool,
44 pub expected_tool_checksum: Option<String>,
46 pub verify_interrupt_consistency: bool,
48}
49
50pub struct ReplayVerifier;
52
53impl ReplayVerifier {
54 pub fn verify(
56 store: &dyn EventStore,
57 run_id: &RunId,
58 config: &VerifyConfig,
59 ) -> VerificationResult {
60 let head = store
62 .head(run_id)
63 .map_err(|e| VerificationFailure::StateHashMismatch {
64 expected: "head check".into(),
65 actual: e.to_string(),
66 })?;
67 if head == 0 {
68 return Err(VerificationFailure::RunNotFound);
69 }
70
71 if config.verify_state_hash {
73 if let Some(expected) = &config.expected_state_hash {
74 let expected_bytes =
75 hex::decode(expected).map_err(|_| VerificationFailure::StateHashMismatch {
76 expected: expected.clone(),
77 actual: "invalid hex".into(),
78 })?;
79 let mut expected_arr = [0u8; 32];
80 if expected_bytes.len() != 32 {
81 return Err(VerificationFailure::StateHashMismatch {
82 expected: expected.clone(),
83 actual: "wrong length".into(),
84 });
85 }
86 expected_arr.copy_from_slice(&expected_bytes);
87
88 if let Err(e) = verify_event_stream_hash(store, run_id, &expected_arr) {
89 return Err(VerificationFailure::StateHashMismatch {
90 expected: expected.clone(),
91 actual: format!("verification failed: {}", e),
92 });
93 }
94 }
95 }
96
97 if config.verify_tool_checksum {
99 let actual_checksum = compute_tool_checksum(store, run_id).map_err(|e| {
100 VerificationFailure::ToolChecksumMismatch {
101 expected: "compute".into(),
102 actual: e.to_string(),
103 }
104 })?;
105 if let Some(expected) = &config.expected_tool_checksum {
106 if &actual_checksum != expected {
107 return Err(VerificationFailure::ToolChecksumMismatch {
108 expected: expected.clone(),
109 actual: actual_checksum,
110 });
111 }
112 }
113 }
114
115 if config.verify_interrupt_consistency {
117 verify_interrupt_consistency(store, run_id)?;
118 }
119
120 Ok(())
121 }
122
123 pub fn tool_checksum(store: &dyn EventStore, run_id: &RunId) -> Result<String, KernelError> {
125 Ok(compute_tool_checksum(store, run_id)?)
126 }
127
128 pub fn state_hash(store: &dyn EventStore, run_id: &RunId) -> Result<String, KernelError> {
130 let hash = crate::kernel::determinism_guard::event_stream_hash(store, run_id)?;
131 Ok(hex::encode(hash))
132 }
133}
134
135fn compute_tool_checksum(store: &dyn EventStore, run_id: &RunId) -> Result<String, KernelError> {
137 let events = store.scan(run_id, 1)?;
138 let mut hasher = Sha256::new();
139 for se in &events {
140 if let Event::ActionRequested { action_id, payload } = &se.event {
141 hasher.update(action_id.as_bytes());
143 if let Ok(json) = serde_json::to_string(payload) {
144 hasher.update(json.as_bytes());
145 }
146 }
147 }
148 Ok(hex::encode(hasher.finalize()))
149}
150
151fn verify_interrupt_consistency(store: &dyn EventStore, run_id: &RunId) -> VerificationResult {
153 let events = store
154 .scan(run_id, 1)
155 .map_err(|e| VerificationFailure::UnmatchedInterrupt {
156 seq: 0,
157 value: serde_json::json!(e.to_string()),
158 })?;
159 let mut interrupt_seqs: Vec<(u64, serde_json::Value)> = Vec::new();
160
161 for se in &events {
162 match &se.event {
163 Event::Interrupted { value } => {
164 interrupt_seqs.push((se.seq, value.clone()));
165 }
166 Event::Resumed { .. } => {
167 if let Some((_, _)) = interrupt_seqs.pop() {
168 } else {
170 return Err(VerificationFailure::UnmatchedResume { seq: se.seq });
171 }
172 }
173 _ => {}
174 }
175 }
176
177 if let Some((seq, value)) = interrupt_seqs.pop() {
179 return Err(VerificationFailure::UnmatchedInterrupt { seq, value });
180 }
181
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::kernel::event_store::InMemoryEventStore;
189
190 #[test]
191 fn verify_returns_ok_when_run_not_found() {
192 let store = InMemoryEventStore::new();
193 let config = VerifyConfig::default();
194 let result = ReplayVerifier::verify(&store, &"nonexistent".into(), &config);
195 assert!(matches!(result, Err(VerificationFailure::RunNotFound)));
196 }
197
198 #[test]
199 fn verify_state_hash_mismatch() {
200 let store = InMemoryEventStore::new();
201 let run_id: RunId = "r1".into();
202 store.append(&run_id, &[Event::Completed]).unwrap();
203 let config = VerifyConfig {
204 verify_state_hash: true,
205 expected_state_hash: Some("00".repeat(32)),
206 ..Default::default()
207 };
208 let result = ReplayVerifier::verify(&store, &run_id, &config);
209 assert!(matches!(
210 result,
211 Err(VerificationFailure::StateHashMismatch { .. })
212 ));
213 }
214
215 #[test]
216 fn verify_tool_checksum_mismatch() {
217 let store = InMemoryEventStore::new();
218 let run_id: RunId = "r2".into();
219 store
220 .append(
221 &run_id,
222 &[Event::ActionRequested {
223 action_id: "a1".into(),
224 payload: serde_json::json!({"tool": "foo"}),
225 }],
226 )
227 .unwrap();
228 let config = VerifyConfig {
229 verify_tool_checksum: true,
230 expected_tool_checksum: Some("ff".repeat(32)),
231 ..Default::default()
232 };
233 let result = ReplayVerifier::verify(&store, &run_id, &config);
234 assert!(matches!(
235 result,
236 Err(VerificationFailure::ToolChecksumMismatch { .. })
237 ));
238 }
239
240 #[test]
241 fn verify_interrupt_consistency_unmatched_interrupt() {
242 let store = InMemoryEventStore::new();
243 let run_id: RunId = "r3".into();
244 store
245 .append(
246 &run_id,
247 &[
248 Event::Interrupted {
249 value: serde_json::json!({"reason": "ask"}),
250 },
251 Event::Completed,
252 ],
253 )
254 .unwrap();
255 let config = VerifyConfig {
256 verify_interrupt_consistency: true,
257 ..Default::default()
258 };
259 let result = ReplayVerifier::verify(&store, &run_id, &config);
260 assert!(matches!(
261 result,
262 Err(VerificationFailure::UnmatchedInterrupt { .. })
263 ));
264 }
265
266 #[test]
267 fn verify_interrupt_consistency_ok() {
268 let store = InMemoryEventStore::new();
269 let run_id: RunId = "r4".into();
270 store
271 .append(
272 &run_id,
273 &[
274 Event::Interrupted {
275 value: serde_json::json!({"reason": "ask"}),
276 },
277 Event::Resumed {
278 value: serde_json::json!("user input"),
279 },
280 Event::Completed,
281 ],
282 )
283 .unwrap();
284 let config = VerifyConfig {
285 verify_interrupt_consistency: true,
286 ..Default::default()
287 };
288 let result = ReplayVerifier::verify(&store, &run_id, &config);
289 assert!(result.is_ok(), "expected Ok, got {:?}", result);
290 }
291
292 #[test]
293 fn tool_checksum_returns_same_for_same_calls() {
294 let store = InMemoryEventStore::new();
295 let run_id: RunId = "r5".into();
296 store
297 .append(
298 &run_id,
299 &[
300 Event::ActionRequested {
301 action_id: "a1".into(),
302 payload: serde_json::json!({"tool": "foo", "input": 1}),
303 },
304 Event::ActionRequested {
305 action_id: "a2".into(),
306 payload: serde_json::json!({"tool": "bar", "input": 2}),
307 },
308 ],
309 )
310 .unwrap();
311 let c1 = ReplayVerifier::tool_checksum(&store, &run_id).unwrap();
312 let c2 = ReplayVerifier::tool_checksum(&store, &run_id).unwrap();
313 assert_eq!(c1, c2);
314 }
315}