Skip to main content

oris_kernel/kernel/
replay_verifier.rs

1//! Replay verification API: cryptographically verify run integrity.
2//!
3//! This module provides [ReplayVerifier] to validate the integrity of a run:
4//! - **State hash equality**: verifies event stream hash matches expected.
5//! - **Tool checksum**: hashes all tool calls in the run.
6//! - **Interrupt consistency**: every Interrupt must have a matching Resumed.
7
8use 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/// Verification failure reasons.
18#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
19pub enum VerificationFailure {
20    /// State hash mismatch.
21    StateHashMismatch { expected: String, actual: String },
22    /// Tool checksum mismatch.
23    ToolChecksumMismatch { expected: String, actual: String },
24    /// Interrupt without a matching Resumed.
25    UnmatchedInterrupt { seq: u64, value: serde_json::Value },
26    /// Resumed without a matching Interrupt.
27    UnmatchedResume { seq: u64 },
28    /// Run not found in event store.
29    RunNotFound,
30}
31
32/// Verification result: Ok(()) if all checks pass, or Err with the failure reason.
33pub type VerificationResult = Result<(), VerificationFailure>;
34
35/// Verification config: what to verify.
36#[derive(Clone, Debug, Default)]
37pub struct VerifyConfig {
38    /// Verify state hash equality (requires expected_state_hash).
39    pub verify_state_hash: bool,
40    /// Expected state hash (32 bytes hex string, if verifying).
41    pub expected_state_hash: Option<String>,
42    /// Verify tool call checksum.
43    pub verify_tool_checksum: bool,
44    /// Expected tool checksum (hex string, if verifying).
45    pub expected_tool_checksum: Option<String>,
46    /// Verify interrupt consistency (every Interrupt has a Resumed).
47    pub verify_interrupt_consistency: bool,
48}
49
50/// Replay verifier: validates run integrity.
51pub struct ReplayVerifier;
52
53impl ReplayVerifier {
54    /// Verifies a run's integrity according to the config.
55    pub fn verify(
56        store: &dyn EventStore,
57        run_id: &RunId,
58        config: &VerifyConfig,
59    ) -> VerificationResult {
60        // Check run exists
61        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        // 1. State hash equality
72        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        // 2. Tool checksum
98        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        // 3. Interrupt consistency
116        if config.verify_interrupt_consistency {
117            verify_interrupt_consistency(store, run_id)?;
118        }
119
120        Ok(())
121    }
122
123    /// Returns the tool call checksum for a run (for later verification).
124    pub fn tool_checksum(store: &dyn EventStore, run_id: &RunId) -> Result<String, KernelError> {
125        Ok(compute_tool_checksum(store, run_id)?)
126    }
127
128    /// Returns the state hash for a run (for later verification).
129    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
135/// Computes SHA-256 of all tool calls in the run.
136fn 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            // Include action_id and payload in checksum
142            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
151/// Verifies every Interrupt has a matching Resumed.
152fn 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                    // matched - OK
169                } else {
170                    return Err(VerificationFailure::UnmatchedResume { seq: se.seq });
171                }
172            }
173            _ => {}
174        }
175    }
176
177    // Any unmatched Interrupts left?
178    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}