1use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::error::Error;
11use std::fmt;
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16pub const CRASH_REPLAY_SCHEMA_VERSION: &str = "1";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct CrashReplayCheckpoint {
20 pub id: String,
21 pub ordinal: u32,
22 pub description: String,
23}
24
25impl CrashReplayCheckpoint {
26 pub fn new(ordinal: u32, id: impl Into<String>, description: impl Into<String>) -> Self {
27 Self {
28 id: id.into(),
29 ordinal,
30 description: description.into(),
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum CrashReplayPhase {
38 AdvanceToCheckpoint,
39 InjectCrash,
40 Restart,
41 CheckInvariants,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct CrashReplayEvent {
46 pub checkpoint_id: String,
47 pub phase: CrashReplayPhase,
48 pub ok: bool,
49 pub detail: String,
50}
51
52impl CrashReplayEvent {
53 fn ok(
54 checkpoint: &CrashReplayCheckpoint,
55 phase: CrashReplayPhase,
56 detail: impl Into<String>,
57 ) -> Self {
58 Self {
59 checkpoint_id: checkpoint.id.clone(),
60 phase,
61 ok: true,
62 detail: detail.into(),
63 }
64 }
65
66 fn failed(
67 checkpoint: &CrashReplayCheckpoint,
68 phase: CrashReplayPhase,
69 detail: impl Into<String>,
70 ) -> Self {
71 Self {
72 checkpoint_id: checkpoint.id.clone(),
73 phase,
74 ok: false,
75 detail: detail.into(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct CrashReplayInvariant {
82 pub checkpoint_id: String,
83 pub name: String,
84 pub passed: bool,
85 pub detail: String,
86}
87
88impl CrashReplayInvariant {
89 pub fn passed(
90 checkpoint: &CrashReplayCheckpoint,
91 name: impl Into<String>,
92 detail: impl Into<String>,
93 ) -> Self {
94 Self {
95 checkpoint_id: checkpoint.id.clone(),
96 name: name.into(),
97 passed: true,
98 detail: detail.into(),
99 }
100 }
101
102 pub fn failed(
103 checkpoint: &CrashReplayCheckpoint,
104 name: impl Into<String>,
105 detail: impl Into<String>,
106 ) -> Self {
107 Self {
108 checkpoint_id: checkpoint.id.clone(),
109 name: name.into(),
110 passed: false,
111 detail: detail.into(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum CrashReplayVerdict {
119 Clean,
120 Failed,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct CrashReplayReport {
125 pub schema_version: String,
126 pub scenario_id: String,
127 pub state_machine: String,
128 pub verdict: CrashReplayVerdict,
129 pub checkpoints: Vec<CrashReplayCheckpoint>,
130 pub events: Vec<CrashReplayEvent>,
131 pub invariants: Vec<CrashReplayInvariant>,
132}
133
134impl CrashReplayReport {
135 pub fn validate(&self) -> Result<(), CrashReplayValidationError> {
136 if self.schema_version != CRASH_REPLAY_SCHEMA_VERSION {
137 return Err(CrashReplayValidationError::UnsupportedSchemaVersion {
138 expected: CRASH_REPLAY_SCHEMA_VERSION,
139 actual: self.schema_version.clone(),
140 });
141 }
142 if self.scenario_id.trim().is_empty() {
143 return Err(CrashReplayValidationError::EmptyScenarioId);
144 }
145 if self.state_machine.trim().is_empty() {
146 return Err(CrashReplayValidationError::EmptyStateMachine);
147 }
148 if self.checkpoints.is_empty() {
149 return Err(CrashReplayValidationError::NoCheckpoints);
150 }
151 if self.verdict == CrashReplayVerdict::Clean && self.invariants.is_empty() {
152 return Err(CrashReplayValidationError::CleanReportWithoutInvariants);
153 }
154
155 let mut checkpoint_ids = BTreeSet::new();
156 let mut previous_ordinal = None;
157 for (index, checkpoint) in self.checkpoints.iter().enumerate() {
158 if checkpoint.id.trim().is_empty() {
159 return Err(CrashReplayValidationError::EmptyCheckpointId { index });
160 }
161 if checkpoint.description.trim().is_empty() {
162 return Err(CrashReplayValidationError::EmptyCheckpointDescription { index });
163 }
164 if let Some(previous) = previous_ordinal
165 && checkpoint.ordinal <= previous
166 {
167 return Err(CrashReplayValidationError::NonMonotoneCheckpointOrdinal {
168 index,
169 previous,
170 current: checkpoint.ordinal,
171 });
172 }
173 if !checkpoint_ids.insert(checkpoint.id.as_str()) {
174 return Err(CrashReplayValidationError::DuplicateCheckpointId {
175 index,
176 checkpoint_id: checkpoint.id.clone(),
177 });
178 }
179 previous_ordinal = Some(checkpoint.ordinal);
180 }
181
182 let mut checked_checkpoints = BTreeSet::new();
183 for (index, event) in self.events.iter().enumerate() {
184 if event.checkpoint_id.trim().is_empty() {
185 return Err(CrashReplayValidationError::EmptyEventCheckpointId { index });
186 }
187 if !checkpoint_ids.contains(event.checkpoint_id.as_str()) {
188 return Err(CrashReplayValidationError::UnknownEventCheckpoint {
189 index,
190 checkpoint_id: event.checkpoint_id.clone(),
191 });
192 }
193 if event.detail.trim().is_empty() {
194 return Err(CrashReplayValidationError::EmptyEventDetail { index });
195 }
196 if event.ok && event.phase == CrashReplayPhase::CheckInvariants {
197 checked_checkpoints.insert(event.checkpoint_id.as_str());
198 }
199 }
200
201 let mut invariant_checkpoints = BTreeSet::new();
202 for (index, invariant) in self.invariants.iter().enumerate() {
203 if invariant.checkpoint_id.trim().is_empty() {
204 return Err(CrashReplayValidationError::EmptyInvariantCheckpointId { index });
205 }
206 if !checkpoint_ids.contains(invariant.checkpoint_id.as_str()) {
207 return Err(CrashReplayValidationError::UnknownInvariantCheckpoint {
208 index,
209 checkpoint_id: invariant.checkpoint_id.clone(),
210 });
211 }
212 if invariant.name.trim().is_empty() {
213 return Err(CrashReplayValidationError::EmptyInvariantName { index });
214 }
215 if invariant.detail.trim().is_empty() {
216 return Err(CrashReplayValidationError::EmptyInvariantDetail { index });
217 }
218 if invariant.passed {
219 invariant_checkpoints.insert(invariant.checkpoint_id.as_str());
220 }
221 }
222 if self.verdict == CrashReplayVerdict::Clean
223 && (self.events.iter().any(|event| !event.ok)
224 || self.invariants.iter().any(|invariant| !invariant.passed))
225 {
226 return Err(CrashReplayValidationError::CleanReportContainsFailure);
227 }
228 if self.verdict == CrashReplayVerdict::Clean {
229 if self.events.is_empty() {
230 return Err(CrashReplayValidationError::CleanReportWithoutEvents);
231 }
232 for checkpoint in &self.checkpoints {
233 if !checked_checkpoints.contains(checkpoint.id.as_str()) {
234 return Err(
235 CrashReplayValidationError::CleanReportMissingCheckpointEvent {
236 checkpoint_id: checkpoint.id.clone(),
237 },
238 );
239 }
240 if !invariant_checkpoints.contains(checkpoint.id.as_str()) {
241 return Err(
242 CrashReplayValidationError::CleanReportMissingCheckpointInvariant {
243 checkpoint_id: checkpoint.id.clone(),
244 },
245 );
246 }
247 }
248 }
249
250 Ok(())
251 }
252
253 pub fn save_json(&self, path: &Path) -> Result<(), CrashReplayIoError> {
254 self.validate()?;
255 if let Some(parent) = path
256 .parent()
257 .filter(|parent| !parent.as_os_str().is_empty())
258 {
259 fs::create_dir_all(parent)?;
260 }
261 let json = serde_json::to_vec_pretty(self)?;
262 let temp_path = write_crash_replay_json_temp_file(path, &json)?;
263 replace_crash_replay_json_from_temp(&temp_path, path)?;
264 Ok(())
265 }
266
267 pub fn load_json(path: &Path) -> Result<Self, CrashReplayIoError> {
268 let bytes = fs::read(path)?;
269 let report: Self = serde_json::from_slice(&bytes)?;
270 report.validate()?;
271 Ok(report)
272 }
273}
274
275fn write_crash_replay_json_temp_file(path: &Path, contents: &[u8]) -> io::Result<PathBuf> {
276 for _ in 0..100 {
277 let temp_path = unique_crash_replay_json_temp_path(path)?;
278 match write_crash_replay_json_temp_file_at(&temp_path, contents) {
279 Ok(()) => return Ok(temp_path),
280 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
281 Err(err) => return Err(err),
282 }
283 }
284
285 Err(io::Error::new(
286 io::ErrorKind::AlreadyExists,
287 format!(
288 "failed to allocate unique crash replay temp path for {}",
289 path.display()
290 ),
291 ))
292}
293
294fn write_crash_replay_json_temp_file_at(path: &Path, contents: &[u8]) -> io::Result<()> {
295 use std::io::Write;
296
297 let mut file = fs::OpenOptions::new()
298 .write(true)
299 .create_new(true)
300 .open(path)?;
301 file.write_all(contents)?;
302 file.sync_all()
303}
304
305fn replace_crash_replay_json_from_temp(temp_path: &Path, final_path: &Path) -> io::Result<()> {
306 fs::rename(temp_path, final_path)?;
307 sync_parent_directory(final_path)
308}
309
310#[cfg(not(windows))]
311fn sync_parent_directory(path: &Path) -> io::Result<()> {
312 let Some(parent) = path.parent() else {
313 return Ok(());
314 };
315 fs::File::open(parent)?.sync_all()
316}
317
318#[cfg(windows)]
319fn sync_parent_directory(_path: &Path) -> io::Result<()> {
320 Ok(())
321}
322
323fn unique_crash_replay_json_temp_path(path: &Path) -> io::Result<PathBuf> {
324 let timestamp = std::time::SystemTime::now()
325 .duration_since(std::time::UNIX_EPOCH)
326 .unwrap_or_default()
327 .as_nanos();
328 let nonce = crash_replay_temp_path_nonce()?;
329 let file_name = path
330 .file_name()
331 .and_then(|name| name.to_str())
332 .unwrap_or("crash-replay-report.json");
333
334 Ok(path.with_file_name(format!(".{file_name}.tmp.{timestamp}.{nonce:016x}")))
335}
336
337fn crash_replay_temp_path_nonce() -> io::Result<u64> {
338 use ring::rand::SecureRandom;
339
340 let mut random_bytes = [0u8; 8];
341 ring::rand::SystemRandom::new()
342 .fill(&mut random_bytes)
343 .map_err(|_| io::Error::other("failed to generate crash replay temp path nonce"))?;
344 Ok(u64::from_le_bytes(random_bytes))
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct CrashReplayError {
349 pub action: String,
350 pub detail: String,
351}
352
353impl CrashReplayError {
354 pub fn new(action: impl Into<String>, detail: impl Into<String>) -> Self {
355 Self {
356 action: action.into(),
357 detail: detail.into(),
358 }
359 }
360
361 pub fn from_error(action: impl Into<String>, error: impl fmt::Display) -> Self {
362 Self::new(action, error.to_string())
363 }
364}
365
366impl fmt::Display for CrashReplayError {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 write!(f, "{}: {}", self.action, self.detail)
369 }
370}
371
372impl Error for CrashReplayError {}
373
374#[derive(Debug)]
375pub enum CrashReplayValidationError {
376 UnsupportedSchemaVersion {
377 expected: &'static str,
378 actual: String,
379 },
380 EmptyScenarioId,
381 EmptyStateMachine,
382 NoCheckpoints,
383 EmptyCheckpointId {
384 index: usize,
385 },
386 EmptyCheckpointDescription {
387 index: usize,
388 },
389 DuplicateCheckpointId {
390 index: usize,
391 checkpoint_id: String,
392 },
393 NonMonotoneCheckpointOrdinal {
394 index: usize,
395 previous: u32,
396 current: u32,
397 },
398 CleanReportWithoutInvariants,
399 CleanReportWithoutEvents,
400 CleanReportContainsFailure,
401 CleanReportMissingCheckpointEvent {
402 checkpoint_id: String,
403 },
404 CleanReportMissingCheckpointInvariant {
405 checkpoint_id: String,
406 },
407 EmptyEventCheckpointId {
408 index: usize,
409 },
410 UnknownEventCheckpoint {
411 index: usize,
412 checkpoint_id: String,
413 },
414 EmptyEventDetail {
415 index: usize,
416 },
417 EmptyInvariantCheckpointId {
418 index: usize,
419 },
420 UnknownInvariantCheckpoint {
421 index: usize,
422 checkpoint_id: String,
423 },
424 EmptyInvariantName {
425 index: usize,
426 },
427 EmptyInvariantDetail {
428 index: usize,
429 },
430}
431
432impl fmt::Display for CrashReplayValidationError {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 match self {
435 Self::UnsupportedSchemaVersion { expected, actual } => {
436 write!(
437 f,
438 "unsupported crash replay schema version {actual}; expected {expected}"
439 )
440 }
441 Self::EmptyScenarioId => write!(f, "crash replay scenario_id cannot be empty"),
442 Self::EmptyStateMachine => write!(f, "crash replay state_machine cannot be empty"),
443 Self::NoCheckpoints => write!(f, "crash replay report must include checkpoints"),
444 Self::EmptyCheckpointId { index } => {
445 write!(f, "crash replay checkpoint #{index} has an empty id")
446 }
447 Self::EmptyCheckpointDescription { index } => write!(
448 f,
449 "crash replay checkpoint #{index} has an empty description"
450 ),
451 Self::DuplicateCheckpointId {
452 index,
453 checkpoint_id,
454 } => write!(
455 f,
456 "crash replay checkpoint #{index} duplicates checkpoint id {checkpoint_id}"
457 ),
458 Self::NonMonotoneCheckpointOrdinal {
459 index,
460 previous,
461 current,
462 } => write!(
463 f,
464 "crash replay checkpoint #{index} ordinal {current} must be greater than previous ordinal {previous}"
465 ),
466 Self::CleanReportWithoutInvariants => {
467 write!(f, "clean crash replay report must include invariants")
468 }
469 Self::CleanReportWithoutEvents => {
470 write!(f, "clean crash replay report must include events")
471 }
472 Self::CleanReportContainsFailure => {
473 write!(
474 f,
475 "clean crash replay report contains failed events or invariants"
476 )
477 }
478 Self::CleanReportMissingCheckpointEvent { checkpoint_id } => write!(
479 f,
480 "clean crash replay report has no successful invariant-check event for checkpoint {checkpoint_id}"
481 ),
482 Self::CleanReportMissingCheckpointInvariant { checkpoint_id } => write!(
483 f,
484 "clean crash replay report has no passing invariant for checkpoint {checkpoint_id}"
485 ),
486 Self::EmptyEventCheckpointId { index } => {
487 write!(f, "crash replay event #{index} has an empty checkpoint id")
488 }
489 Self::UnknownEventCheckpoint {
490 index,
491 checkpoint_id,
492 } => write!(
493 f,
494 "crash replay event #{index} references unknown checkpoint {checkpoint_id}"
495 ),
496 Self::EmptyEventDetail { index } => {
497 write!(f, "crash replay event #{index} has an empty detail")
498 }
499 Self::EmptyInvariantCheckpointId { index } => write!(
500 f,
501 "crash replay invariant #{index} has an empty checkpoint id"
502 ),
503 Self::UnknownInvariantCheckpoint {
504 index,
505 checkpoint_id,
506 } => write!(
507 f,
508 "crash replay invariant #{index} references unknown checkpoint {checkpoint_id}"
509 ),
510 Self::EmptyInvariantName { index } => {
511 write!(f, "crash replay invariant #{index} has an empty name")
512 }
513 Self::EmptyInvariantDetail { index } => {
514 write!(f, "crash replay invariant #{index} has an empty detail")
515 }
516 }
517 }
518}
519
520impl Error for CrashReplayValidationError {}
521
522#[derive(Debug)]
523pub enum CrashReplayIoError {
524 Io(io::Error),
525 Json(serde_json::Error),
526 Validation(CrashReplayValidationError),
527}
528
529impl fmt::Display for CrashReplayIoError {
530 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
531 match self {
532 Self::Io(err) => write!(f, "crash replay I/O error: {err}"),
533 Self::Json(err) => write!(f, "crash replay JSON error: {err}"),
534 Self::Validation(err) => write!(f, "crash replay validation error: {err}"),
535 }
536 }
537}
538
539impl Error for CrashReplayIoError {
540 fn source(&self) -> Option<&(dyn Error + 'static)> {
541 match self {
542 Self::Io(err) => Some(err),
543 Self::Json(err) => Some(err),
544 Self::Validation(err) => Some(err),
545 }
546 }
547}
548
549impl From<io::Error> for CrashReplayIoError {
550 fn from(err: io::Error) -> Self {
551 Self::Io(err)
552 }
553}
554
555impl From<serde_json::Error> for CrashReplayIoError {
556 fn from(err: serde_json::Error) -> Self {
557 Self::Json(err)
558 }
559}
560
561impl From<CrashReplayValidationError> for CrashReplayIoError {
562 fn from(err: CrashReplayValidationError) -> Self {
563 Self::Validation(err)
564 }
565}
566
567pub fn replay_named_checkpoints<S, MakeState, Advance, Restart, Check>(
568 scenario_id: impl Into<String>,
569 state_machine: impl Into<String>,
570 mut checkpoints: Vec<CrashReplayCheckpoint>,
571 mut make_state: MakeState,
572 mut advance_to_checkpoint: Advance,
573 mut restart: Restart,
574 mut check_invariants: Check,
575) -> CrashReplayReport
576where
577 MakeState: FnMut() -> Result<S, CrashReplayError>,
578 Advance: FnMut(&mut S, &CrashReplayCheckpoint) -> Result<(), CrashReplayError>,
579 Restart: FnMut(&mut S) -> Result<(), CrashReplayError>,
580 Check: FnMut(&S, &CrashReplayCheckpoint) -> Vec<CrashReplayInvariant>,
581{
582 checkpoints.sort_by_key(|checkpoint| checkpoint.ordinal);
583 let mut report = CrashReplayReport {
584 schema_version: CRASH_REPLAY_SCHEMA_VERSION.to_string(),
585 scenario_id: scenario_id.into(),
586 state_machine: state_machine.into(),
587 verdict: CrashReplayVerdict::Clean,
588 checkpoints: checkpoints.clone(),
589 events: Vec::new(),
590 invariants: Vec::new(),
591 };
592
593 if checkpoints.is_empty() {
594 report.verdict = CrashReplayVerdict::Failed;
595 return report;
596 }
597
598 for checkpoint in checkpoints {
599 let mut state = match make_state() {
600 Ok(state) => state,
601 Err(err) => {
602 report.verdict = CrashReplayVerdict::Failed;
603 report.events.push(CrashReplayEvent::failed(
604 &checkpoint,
605 CrashReplayPhase::AdvanceToCheckpoint,
606 format!("failed creating fresh state: {err}"),
607 ));
608 continue;
609 }
610 };
611
612 match advance_to_checkpoint(&mut state, &checkpoint) {
613 Ok(()) => report.events.push(CrashReplayEvent::ok(
614 &checkpoint,
615 CrashReplayPhase::AdvanceToCheckpoint,
616 "advanced to checkpoint",
617 )),
618 Err(err) => {
619 report.verdict = CrashReplayVerdict::Failed;
620 report.events.push(CrashReplayEvent::failed(
621 &checkpoint,
622 CrashReplayPhase::AdvanceToCheckpoint,
623 err.to_string(),
624 ));
625 continue;
626 }
627 }
628
629 report.events.push(CrashReplayEvent::ok(
630 &checkpoint,
631 CrashReplayPhase::InjectCrash,
632 "simulated process stop at named checkpoint",
633 ));
634
635 match restart(&mut state) {
636 Ok(()) => report.events.push(CrashReplayEvent::ok(
637 &checkpoint,
638 CrashReplayPhase::Restart,
639 "restart action completed",
640 )),
641 Err(err) => {
642 report.verdict = CrashReplayVerdict::Failed;
643 report.events.push(CrashReplayEvent::failed(
644 &checkpoint,
645 CrashReplayPhase::Restart,
646 err.to_string(),
647 ));
648 continue;
649 }
650 }
651
652 let invariants = check_invariants(&state, &checkpoint);
653 if invariants.is_empty() {
654 report.verdict = CrashReplayVerdict::Failed;
655 report.events.push(CrashReplayEvent::failed(
656 &checkpoint,
657 CrashReplayPhase::CheckInvariants,
658 "checkpoint produced no invariants",
659 ));
660 continue;
661 }
662
663 let failed = invariants.iter().any(|invariant| !invariant.passed);
664 if failed {
665 report.verdict = CrashReplayVerdict::Failed;
666 }
667 report.events.push(CrashReplayEvent {
668 checkpoint_id: checkpoint.id.clone(),
669 phase: CrashReplayPhase::CheckInvariants,
670 ok: !failed,
671 detail: format!("{} invariant(s) evaluated", invariants.len()),
672 });
673 report.invariants.extend(invariants);
674 }
675
676 report
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::policy_registry::{
683 PolicyControllerStatus, PolicyFallbackState, policy_registry_snapshot,
684 };
685 use crate::search::policy::{
686 CHUNKING_STRATEGY_VERSION, SEMANTIC_SCHEMA_VERSION, SemanticPolicy,
687 };
688 use crate::search::semantic_manifest::{
689 ArtifactRecord, BuildCheckpoint, SemanticManifest, TierKind,
690 };
691 use serde_json::{Value, json};
692 use std::path::PathBuf;
693 use tempfile::TempDir;
694
695 #[derive(Debug)]
696 struct SemanticReplayState {
697 temp_dir: TempDir,
698 loaded: Option<SemanticManifest>,
699 }
700
701 impl SemanticReplayState {
702 fn data_dir(&self) -> &Path {
703 self.temp_dir.path()
704 }
705 }
706
707 fn semantic_checkpoint() -> BuildCheckpoint {
708 BuildCheckpoint {
709 tier: TierKind::Fast,
710 embedder_id: "fnv1a-384".to_string(),
711 last_offset: 8,
712 docs_embedded: 13,
713 conversations_processed: 2,
714 total_conversations: 5,
715 db_fingerprint: "semantic-fp".to_string(),
716 schema_version: SEMANTIC_SCHEMA_VERSION,
717 chunking_version: CHUNKING_STRATEGY_VERSION,
718 saved_at_ms: 1_700_000_000_000,
719 last_message_id: None,
720 cursor_exhausted: false,
721 }
722 }
723
724 fn semantic_artifact() -> ArtifactRecord {
725 ArtifactRecord {
726 tier: TierKind::Fast,
727 embedder_id: "fnv1a-384".to_string(),
728 model_revision: "hash".to_string(),
729 schema_version: SEMANTIC_SCHEMA_VERSION,
730 chunking_version: CHUNKING_STRATEGY_VERSION,
731 dimension: 384,
732 doc_count: 13,
733 conversation_count: 5,
734 db_fingerprint: "semantic-fp".to_string(),
735 index_path: "vector_index/fast.fsvi".to_string(),
736 size_bytes: 4096,
737 started_at_ms: 1_700_000_000_000,
738 completed_at_ms: 1_700_000_060_000,
739 ready: true,
740 }
741 }
742
743 #[test]
744 fn semantic_manifest_state_machine_replays_checkpoint_and_publish_crashes() {
745 let checkpoints = vec![
746 CrashReplayCheckpoint::new(
747 10,
748 "semantic_checkpoint_saved",
749 "semantic checkpoint persisted before artifact publish",
750 ),
751 CrashReplayCheckpoint::new(
752 20,
753 "semantic_artifact_published",
754 "semantic artifact published and checkpoint cleared",
755 ),
756 ];
757
758 let report =
759 replay_named_checkpoints(
760 "semantic-manifest-save-restart",
761 "semantic_manifest",
762 checkpoints,
763 || {
764 Ok(SemanticReplayState {
765 temp_dir: tempfile::tempdir()
766 .map_err(|err| CrashReplayError::from_error("create tempdir", err))?,
767 loaded: None,
768 })
769 },
770 |state, checkpoint| {
771 let mut manifest = SemanticManifest::default();
772 manifest.refresh_backlog(5, "semantic-fp");
773 manifest.save_checkpoint(semantic_checkpoint());
774 if checkpoint.id == "semantic_artifact_published" {
775 manifest.publish_artifact(semantic_artifact());
776 }
777 manifest
778 .save(state.data_dir())
779 .map_err(|err| CrashReplayError::from_error("save semantic manifest", err))
780 },
781 |state| {
782 state.loaded = SemanticManifest::load(state.data_dir()).map_err(|err| {
783 CrashReplayError::from_error("load semantic manifest", err)
784 })?;
785 Ok(())
786 },
787 |state, checkpoint| {
788 let mut invariants = Vec::new();
789 let Some(manifest) = &state.loaded else {
790 return vec![CrashReplayInvariant::failed(
791 checkpoint,
792 "semantic_manifest_loaded",
793 "manifest did not load after restart",
794 )];
795 };
796
797 invariants.push(CrashReplayInvariant::passed(
798 checkpoint,
799 "semantic_manifest_loaded",
800 "manifest loaded after restart",
801 ));
802 match checkpoint.id.as_str() {
803 "semantic_checkpoint_saved" => {
804 invariants.push(if manifest.checkpoint.is_some()
805 && manifest.fast_tier.is_none()
806 {
807 CrashReplayInvariant::passed(
808 checkpoint,
809 "checkpoint_without_torn_artifact",
810 "restart sees resumable checkpoint and no half-published artifact",
811 )
812 } else {
813 CrashReplayInvariant::failed(
814 checkpoint,
815 "checkpoint_without_torn_artifact",
816 format!(
817 "checkpoint={:?} fast_tier={:?}",
818 manifest.checkpoint, manifest.fast_tier
819 ),
820 )
821 });
822 }
823 "semantic_artifact_published" => {
824 invariants.push(if manifest.checkpoint.is_none()
825 && manifest.fast_tier.as_ref().is_some_and(|artifact| artifact.ready)
826 {
827 CrashReplayInvariant::passed(
828 checkpoint,
829 "published_artifact_clears_checkpoint",
830 "restart sees ready artifact and no stale matching checkpoint",
831 )
832 } else {
833 CrashReplayInvariant::failed(
834 checkpoint,
835 "published_artifact_clears_checkpoint",
836 format!(
837 "checkpoint={:?} fast_tier={:?}",
838 manifest.checkpoint, manifest.fast_tier
839 ),
840 )
841 });
842 }
843 _ => invariants.push(CrashReplayInvariant::failed(
844 checkpoint,
845 "known_checkpoint",
846 "unexpected semantic checkpoint",
847 )),
848 }
849 invariants
850 },
851 );
852
853 assert_eq!(report.verdict, CrashReplayVerdict::Clean);
854 assert_eq!(report.checkpoints.len(), 2);
855 assert_eq!(report.invariants.len(), 4);
856 assert!(
857 report.validate().is_ok(),
858 "semantic replay report should validate: {report:?}"
859 );
860 }
861
862 #[derive(Debug)]
863 struct PolicyReplayState {
864 pipeline: Value,
865 semantic_available: bool,
866 semantic_fallback_mode: Option<&'static str>,
867 snapshot_statuses: Vec<(String, PolicyControllerStatus, PolicyFallbackState)>,
868 }
869
870 fn policy_pipeline_fixture(mode: &str, reason: &str) -> Value {
871 json!({
872 "pipeline_channel_size": 128,
873 "pipeline_max_message_bytes_in_flight": 1048576,
874 "page_prep_workers": 12,
875 "staged_merge_workers": 4,
876 "staged_shard_builders": 8,
877 "controller_mode": "auto",
878 "controller_restore_clear_samples": 3,
879 "controller_restore_hold_ms": 5000,
880 "controller_loadavg_high_watermark_1m": 1.75,
881 "controller_loadavg_low_watermark_1m": 0.75,
882 "runtime": {
883 "controller_mode": mode,
884 "controller_reason": reason
885 }
886 })
887 }
888
889 #[test]
890 fn policy_registry_state_machine_replays_deterministic_controller_snapshots() {
891 let checkpoints = vec![
892 CrashReplayCheckpoint::new(
893 10,
894 "semantic_fallback_snapshot",
895 "semantic controller reports lexical fallback",
896 ),
897 CrashReplayCheckpoint::new(
898 20,
899 "lexical_throttle_snapshot",
900 "lexical rebuild controller reports pressure fallback",
901 ),
902 ];
903
904 let report = replay_named_checkpoints(
905 "policy-registry-recompute-restart",
906 "policy_registry",
907 checkpoints,
908 || {
909 Ok(PolicyReplayState {
910 pipeline: policy_pipeline_fixture("steady", "pipeline settings active"),
911 semantic_available: true,
912 semantic_fallback_mode: None,
913 snapshot_statuses: Vec::new(),
914 })
915 },
916 |state, checkpoint| {
917 match checkpoint.id.as_str() {
918 "semantic_fallback_snapshot" => {
919 state.semantic_available = false;
920 state.semantic_fallback_mode = Some("lexical");
921 }
922 "lexical_throttle_snapshot" => {
923 state.pipeline =
924 policy_pipeline_fixture("throttled", "load pressure reduced workers");
925 }
926 _ => {
927 return Err(CrashReplayError::new(
928 "advance policy checkpoint",
929 "unknown checkpoint",
930 ));
931 }
932 }
933 Ok(())
934 },
935 |state| {
936 let policy = SemanticPolicy::compiled_defaults();
937 let snapshot = policy_registry_snapshot(
938 &policy,
939 state.semantic_available,
940 state.semantic_fallback_mode,
941 &state.pipeline,
942 );
943 state.snapshot_statuses = snapshot
944 .controllers
945 .into_iter()
946 .map(|controller| {
947 (
948 controller.controller_id,
949 controller.status,
950 controller.fallback_state,
951 )
952 })
953 .collect();
954 Ok(())
955 },
956 |state, checkpoint| {
957 let ids: Vec<_> = state
958 .snapshot_statuses
959 .iter()
960 .map(|(id, _, _)| id.as_str())
961 .collect();
962 let mut invariants =
963 vec![if ids == ["lexical_rebuild_pipeline", "semantic_search"] {
964 CrashReplayInvariant::passed(
965 checkpoint,
966 "controller_ids_sorted",
967 "controller ids are deterministic and sorted",
968 )
969 } else {
970 CrashReplayInvariant::failed(
971 checkpoint,
972 "controller_ids_sorted",
973 format!("unexpected controller ids: {ids:?}"),
974 )
975 }];
976
977 let expected_controller = match checkpoint.id.as_str() {
978 "semantic_fallback_snapshot" => "semantic_search",
979 "lexical_throttle_snapshot" => "lexical_rebuild_pipeline",
980 _ => "unknown",
981 };
982 let controller = state
983 .snapshot_statuses
984 .iter()
985 .find(|(id, _, _)| id == expected_controller);
986 invariants.push(match controller {
987 Some((
988 _id,
989 PolicyControllerStatus::Fallback,
990 PolicyFallbackState::Conservative,
991 )) => CrashReplayInvariant::passed(
992 checkpoint,
993 "conservative_fallback_reported",
994 "checkpoint recompute reports conservative fallback",
995 ),
996 other => CrashReplayInvariant::failed(
997 checkpoint,
998 "conservative_fallback_reported",
999 format!("unexpected controller status: {other:?}"),
1000 ),
1001 });
1002 invariants
1003 },
1004 );
1005
1006 assert_eq!(report.verdict, CrashReplayVerdict::Clean);
1007 assert!(
1008 report.validate().is_ok(),
1009 "policy replay report should validate: {report:?}"
1010 );
1011 }
1012
1013 #[derive(Debug)]
1014 struct LexicalPublishFixtureState {
1015 temp_dir: TempDir,
1016 live_path: PathBuf,
1017 staged_path: PathBuf,
1018 backup_path: PathBuf,
1019 }
1020
1021 impl LexicalPublishFixtureState {
1022 fn new() -> Result<Self, CrashReplayError> {
1023 let temp_dir = tempfile::tempdir()
1024 .map_err(|err| CrashReplayError::from_error("create tempdir", err))?;
1025 let live_path = temp_dir.path().join("live-generation.txt");
1026 let staged_path = temp_dir.path().join("staged-generation.txt");
1027 let backup_path = temp_dir.path().join("live-generation.bak");
1028 fs::write(&live_path, "old-generation")
1029 .map_err(|err| CrashReplayError::from_error("seed live generation", err))?;
1030 Ok(Self {
1031 temp_dir,
1032 live_path,
1033 staged_path,
1034 backup_path,
1035 })
1036 }
1037
1038 fn write_staged(&self) -> Result<(), CrashReplayError> {
1039 fs::write(&self.staged_path, "new-generation")
1040 .map_err(|err| CrashReplayError::from_error("write staged generation", err))
1041 }
1042
1043 fn park_live(&self) -> Result<(), CrashReplayError> {
1044 fs::rename(&self.live_path, &self.backup_path)
1045 .map_err(|err| CrashReplayError::from_error("park live generation", err))
1046 }
1047
1048 fn publish_staged(&self) -> Result<(), CrashReplayError> {
1049 fs::rename(&self.staged_path, &self.live_path)
1050 .map_err(|err| CrashReplayError::from_error("publish staged generation", err))
1051 }
1052 }
1053
1054 #[test]
1055 fn lexical_publish_fixture_replays_park_and_swap_crash_windows() {
1056 let checkpoints = vec![
1057 CrashReplayCheckpoint::new(
1058 10,
1059 "staged_written",
1060 "staged generation exists before live path is touched",
1061 ),
1062 CrashReplayCheckpoint::new(
1063 20,
1064 "live_parked",
1065 "live generation has been parked but staged is not yet live",
1066 ),
1067 CrashReplayCheckpoint::new(
1068 30,
1069 "staged_published",
1070 "staged generation has been promoted to live",
1071 ),
1072 ];
1073
1074 let report = replay_named_checkpoints(
1075 "lexical-publish-fixture-restart",
1076 "lexical_publish",
1077 checkpoints,
1078 LexicalPublishFixtureState::new,
1079 |state, checkpoint| {
1080 state.write_staged()?;
1081 match checkpoint.id.as_str() {
1082 "staged_written" => {}
1083 "live_parked" => {
1084 state.park_live()?;
1085 }
1086 "staged_published" => {
1087 state.park_live()?;
1088 state.publish_staged()?;
1089 }
1090 _ => {
1091 return Err(CrashReplayError::new(
1092 "advance lexical publish checkpoint",
1093 "unknown checkpoint",
1094 ));
1095 }
1096 }
1097 Ok(())
1098 },
1099 |state| {
1100 if !state.live_path.exists() && state.backup_path.exists() {
1101 fs::rename(&state.backup_path, &state.live_path)
1102 .map_err(|err| CrashReplayError::from_error("restore parked live", err))?;
1103 }
1104 Ok(())
1105 },
1106 |state, checkpoint| {
1107 let live = fs::read_to_string(&state.live_path).ok();
1108 let expected = match checkpoint.id.as_str() {
1109 "staged_written" | "live_parked" => "old-generation",
1110 "staged_published" => "new-generation",
1111 _ => "unknown",
1112 };
1113
1114 vec![
1115 if state.temp_dir.path().exists() {
1116 CrashReplayInvariant::passed(
1117 checkpoint,
1118 "fixture_root_retained",
1119 "fixture root remains available for artifact inspection",
1120 )
1121 } else {
1122 CrashReplayInvariant::failed(
1123 checkpoint,
1124 "fixture_root_retained",
1125 "fixture root disappeared before invariant checks",
1126 )
1127 },
1128 if live.as_deref() == Some(expected) {
1129 CrashReplayInvariant::passed(
1130 checkpoint,
1131 "live_generation_is_old_or_new",
1132 format!("live generation recovered as {expected}"),
1133 )
1134 } else {
1135 CrashReplayInvariant::failed(
1136 checkpoint,
1137 "live_generation_is_old_or_new",
1138 format!("expected {expected}, got {live:?}"),
1139 )
1140 },
1141 ]
1142 },
1143 );
1144
1145 assert_eq!(report.verdict, CrashReplayVerdict::Clean);
1146 assert!(
1147 report.validate().is_ok(),
1148 "lexical publish replay report should validate: {report:?}"
1149 );
1150 }
1151
1152 #[derive(Debug)]
1153 struct BackupRecoveryFixtureState {
1154 temp_dir: TempDir,
1155 canonical_db: PathBuf,
1156 backup_dir: PathBuf,
1157 manifest: Option<Value>,
1158 }
1159
1160 impl BackupRecoveryFixtureState {
1161 fn new() -> Result<Self, CrashReplayError> {
1162 let temp_dir = tempfile::tempdir()
1163 .map_err(|err| CrashReplayError::from_error("create tempdir", err))?;
1164 let canonical_db = temp_dir.path().join("cass.db");
1165 let backup_dir = temp_dir.path().join("backup");
1166 fs::write(&canonical_db, "canonical-main")
1167 .map_err(|err| CrashReplayError::from_error("seed canonical db", err))?;
1168 fs::write(temp_dir.path().join("cass.db-wal"), "canonical-wal")
1169 .map_err(|err| CrashReplayError::from_error("seed canonical wal", err))?;
1170 fs::create_dir_all(&backup_dir)
1171 .map_err(|err| CrashReplayError::from_error("create backup dir", err))?;
1172 Ok(Self {
1173 temp_dir,
1174 canonical_db,
1175 backup_dir,
1176 manifest: None,
1177 })
1178 }
1179
1180 fn copy_main(&self) -> Result<(), CrashReplayError> {
1181 fs::copy(&self.canonical_db, self.backup_dir.join("cass.db"))
1182 .map(|_| ())
1183 .map_err(|err| CrashReplayError::from_error("copy backup main", err))
1184 }
1185
1186 fn copy_wal_and_manifest(&self) -> Result<(), CrashReplayError> {
1187 fs::copy(
1188 self.temp_dir.path().join("cass.db-wal"),
1189 self.backup_dir.join("cass.db-wal"),
1190 )
1191 .map_err(|err| CrashReplayError::from_error("copy backup wal", err))?;
1192 let manifest = json!({
1193 "schema_version": 1,
1194 "complete": true,
1195 "files": ["cass.db", "cass.db-wal"],
1196 });
1197 let bytes = serde_json::to_vec_pretty(&manifest)
1198 .map_err(|err| CrashReplayError::from_error("encode backup manifest", err))?;
1199 fs::write(self.backup_dir.join("manifest.json"), bytes)
1200 .map_err(|err| CrashReplayError::from_error("write backup manifest", err))
1201 }
1202 }
1203
1204 #[test]
1205 fn backup_recovery_fixture_replays_incomplete_and_complete_bundle_crashes() {
1206 let checkpoints = vec![
1207 CrashReplayCheckpoint::new(
1208 10,
1209 "backup_main_copied",
1210 "backup main file copied before bundle manifest exists",
1211 ),
1212 CrashReplayCheckpoint::new(
1213 20,
1214 "backup_manifest_written",
1215 "backup sidecars and manifest mark the bundle complete",
1216 ),
1217 ];
1218
1219 let report = replay_named_checkpoints(
1220 "backup-recovery-fixture-restart",
1221 "backup_recovery",
1222 checkpoints,
1223 BackupRecoveryFixtureState::new,
1224 |state, checkpoint| {
1225 state.copy_main()?;
1226 match checkpoint.id.as_str() {
1227 "backup_main_copied" => {}
1228 "backup_manifest_written" => {
1229 state.copy_wal_and_manifest()?;
1230 }
1231 _ => {
1232 return Err(CrashReplayError::new(
1233 "advance backup recovery checkpoint",
1234 "unknown checkpoint",
1235 ));
1236 }
1237 }
1238 Ok(())
1239 },
1240 |state| {
1241 let manifest_path = state.backup_dir.join("manifest.json");
1242 state.manifest = if manifest_path.exists() {
1243 let bytes = fs::read(&manifest_path)
1244 .map_err(|err| CrashReplayError::from_error("read backup manifest", err))?;
1245 Some(serde_json::from_slice(&bytes).map_err(|err| {
1246 CrashReplayError::from_error("parse backup manifest", err)
1247 })?)
1248 } else {
1249 None
1250 };
1251 Ok(())
1252 },
1253 |state, checkpoint| {
1254 let canonical = fs::read_to_string(&state.canonical_db).ok();
1255 let mut invariants = vec![if canonical.as_deref() == Some("canonical-main") {
1256 CrashReplayInvariant::passed(
1257 checkpoint,
1258 "canonical_db_preserved",
1259 "restart did not replace the canonical DB from an incomplete backup",
1260 )
1261 } else {
1262 CrashReplayInvariant::failed(
1263 checkpoint,
1264 "canonical_db_preserved",
1265 format!("unexpected canonical DB content: {canonical:?}"),
1266 )
1267 }];
1268
1269 match checkpoint.id.as_str() {
1270 "backup_main_copied" => {
1271 invariants.push(if state.manifest.is_none() {
1272 CrashReplayInvariant::passed(
1273 checkpoint,
1274 "partial_backup_not_marked_complete",
1275 "main-only backup has no manifest and is not advertised recoverable",
1276 )
1277 } else {
1278 CrashReplayInvariant::failed(
1279 checkpoint,
1280 "partial_backup_not_marked_complete",
1281 format!("unexpected manifest: {:?}", state.manifest),
1282 )
1283 });
1284 }
1285 "backup_manifest_written" => {
1286 let complete = state
1287 .manifest
1288 .as_ref()
1289 .and_then(|manifest| manifest.get("complete"))
1290 .and_then(Value::as_bool)
1291 == Some(true);
1292 let files_match = state
1293 .manifest
1294 .as_ref()
1295 .and_then(|manifest| manifest.get("files"))
1296 .and_then(Value::as_array)
1297 .map(|files| {
1298 let mut names = files.iter().filter_map(Value::as_str);
1299 matches!(
1300 (names.next(), names.next(), names.next()),
1301 (Some("cass.db"), Some("cass.db-wal"), None)
1302 )
1303 })
1304 == Some(true);
1305 let wal_exists = state.backup_dir.join("cass.db-wal").exists();
1306 invariants.push(if complete && files_match && wal_exists {
1307 CrashReplayInvariant::passed(
1308 checkpoint,
1309 "complete_backup_manifest_matches_sidecars",
1310 "complete manifest is present only with expected sidecars",
1311 )
1312 } else {
1313 CrashReplayInvariant::failed(
1314 checkpoint,
1315 "complete_backup_manifest_matches_sidecars",
1316 format!(
1317 "complete={complete} files_match={files_match} wal_exists={wal_exists}"
1318 ),
1319 )
1320 });
1321 }
1322 _ => invariants.push(CrashReplayInvariant::failed(
1323 checkpoint,
1324 "known_backup_checkpoint",
1325 "unexpected backup checkpoint",
1326 )),
1327 }
1328 invariants
1329 },
1330 );
1331
1332 assert_eq!(report.verdict, CrashReplayVerdict::Clean);
1333 assert!(
1334 report.validate().is_ok(),
1335 "backup recovery replay report should validate: {report:?}"
1336 );
1337 }
1338
1339 #[test]
1340 fn crash_replay_report_round_trips_as_artifact_manifest()
1341 -> Result<(), Box<dyn std::error::Error>> {
1342 let temp_dir = tempfile::tempdir()?;
1343 let path = temp_dir
1344 .path()
1345 .join("artifacts/crash-replay/crash-replay-report.json");
1346 let checkpoints = vec![CrashReplayCheckpoint::new(
1347 1,
1348 "only_checkpoint",
1349 "single checkpoint for artifact round-trip",
1350 )];
1351 let report = replay_named_checkpoints(
1352 "artifact-round-trip",
1353 "harness",
1354 checkpoints,
1355 || Ok(()),
1356 |_state, _checkpoint| Ok(()),
1357 |_state| Ok(()),
1358 |_state, checkpoint| {
1359 vec![CrashReplayInvariant::passed(
1360 checkpoint,
1361 "round_trip_invariant",
1362 "round-trip invariant passed",
1363 )]
1364 },
1365 );
1366
1367 report.save_json(&path)?;
1368 let loaded = CrashReplayReport::load_json(&path)?;
1369
1370 assert_eq!(loaded, report);
1371 Ok(())
1372 }
1373
1374 #[cfg(unix)]
1375 #[test]
1376 fn crash_replay_report_save_json_replaces_existing_symlink_without_following()
1377 -> Result<(), Box<dyn std::error::Error>> {
1378 use std::os::unix::fs::symlink;
1379
1380 let temp_dir = tempfile::tempdir()?;
1381 let outside_dir = tempfile::tempdir()?;
1382 let report_dir = temp_dir.path().join("artifacts/crash-replay");
1383 fs::create_dir_all(&report_dir)?;
1384 let path = report_dir.join("crash-replay-report.json");
1385 let protected_target = outside_dir.path().join("protected-report.json");
1386 fs::write(&protected_target, "untouched")?;
1387 symlink(&protected_target, &path)?;
1388
1389 let checkpoints = vec![CrashReplayCheckpoint::new(
1390 1,
1391 "only_checkpoint",
1392 "single checkpoint for symlink replacement",
1393 )];
1394 let report = replay_named_checkpoints(
1395 "symlink-replacement",
1396 "harness",
1397 checkpoints,
1398 || Ok(()),
1399 |_state, _checkpoint| Ok(()),
1400 |_state| Ok(()),
1401 |_state, checkpoint| {
1402 vec![CrashReplayInvariant::passed(
1403 checkpoint,
1404 "symlink_invariant",
1405 "symlink replacement invariant passed",
1406 )]
1407 },
1408 );
1409
1410 report.save_json(&path)?;
1411
1412 assert_eq!(
1413 fs::read_to_string(&protected_target)?,
1414 "untouched",
1415 "save_json must replace the report-path symlink, not follow it"
1416 );
1417 assert!(
1418 !fs::symlink_metadata(&path)?.file_type().is_symlink(),
1419 "report path should become a regular JSON file"
1420 );
1421 assert_eq!(CrashReplayReport::load_json(&path)?, report);
1422 Ok(())
1423 }
1424
1425 #[test]
1426 fn crash_replay_validation_rejects_untrustworthy_clean_reports() {
1427 let checkpoint = CrashReplayCheckpoint::new(1, "checkpoint", "validation checkpoint");
1428 let report = CrashReplayReport {
1429 schema_version: CRASH_REPLAY_SCHEMA_VERSION.to_string(),
1430 scenario_id: "bad-clean-report".to_string(),
1431 state_machine: "harness".to_string(),
1432 verdict: CrashReplayVerdict::Clean,
1433 checkpoints: vec![checkpoint.clone()],
1434 events: vec![CrashReplayEvent {
1435 checkpoint_id: checkpoint.id.clone(),
1436 phase: CrashReplayPhase::CheckInvariants,
1437 ok: true,
1438 detail: "checked".to_string(),
1439 }],
1440 invariants: vec![CrashReplayInvariant::failed(
1441 &checkpoint,
1442 "must_not_fail",
1443 "intentional validation failure",
1444 )],
1445 };
1446
1447 assert!(matches!(
1448 report.validate(),
1449 Err(CrashReplayValidationError::CleanReportContainsFailure)
1450 ));
1451
1452 let duplicate_checkpoint = CrashReplayCheckpoint {
1453 ordinal: 2,
1454 ..checkpoint.clone()
1455 };
1456 let duplicate_report = CrashReplayReport {
1457 checkpoints: vec![checkpoint.clone(), duplicate_checkpoint],
1458 ..report.clone()
1459 };
1460 assert!(matches!(
1461 duplicate_report.validate(),
1462 Err(CrashReplayValidationError::DuplicateCheckpointId { .. })
1463 ));
1464
1465 let missing_check_event_report = CrashReplayReport {
1466 events: vec![CrashReplayEvent {
1467 checkpoint_id: checkpoint.id.clone(),
1468 phase: CrashReplayPhase::AdvanceToCheckpoint,
1469 ok: true,
1470 detail: "advanced".to_string(),
1471 }],
1472 invariants: vec![CrashReplayInvariant::passed(
1473 &checkpoint,
1474 "passing_but_unchecked",
1475 "invariant exists but no check event proves it ran",
1476 )],
1477 ..report
1478 };
1479 assert!(matches!(
1480 missing_check_event_report.validate(),
1481 Err(CrashReplayValidationError::CleanReportMissingCheckpointEvent { .. })
1482 ));
1483 }
1484}