//! K10 replay bundles (GameKernel §2.4, MetricsAndScenarios §4). //! //! K10: *"A replay bundle (`.cbreplay`) contains manifest, command log, //! initial snapshot, and failed expectations; the runner's `--replay` //! re-executes it bit-identically."* Unimplemented until CB-WP-0006 T06 — //! `games/` had no `replays/` directory, no `.cbreplay` reader or writer, //! and `cb-sim` had no flag parsing at all, so `--replay` had nowhere to //! go. INTENT names this design decision **8 of 10**. //! //! MetricsAndScenarios §4 states the point: *"A bug report without a //! replay bundle is information; with one, it is work an agent can //! start."* //! //! **Dev-only**, behind the `scenarios` feature, so it is charged to //! AM-4b (9.4% headroom) and not to the shipped runtime. //! //! ## Why replay can fail, and must be able to //! //! ADR-0005 §6 required four controls, two of which the adversarial //! reviewer supplied. The two that shape this module: //! //! * the comparison hash is **read out of the bundle**, written by the //! process that produced it — never recomputed in the replaying process, //! which would be `assert_eq!(h, h)` and is exactly the AM-7 defect; //! * the recorded **seed must reproduce the recorded initial snapshot**, //! so mutating the manifest seed makes replay fail. Without that check a //! replay restored from the snapshot would ignore the seed entirely and //! the control could not bite. use std::path::{Path, PathBuf}; use cb_events::store::{parse, LogStore}; use cb_events::{state_hash_hex, FileLogStore}; use serde::{Deserialize, Serialize}; use crate::scenario::{CommandStep, ScenarioGame}; use crate::{ScenarioFile, Setup}; pub const MANIFEST: &str = "manifest.yaml"; pub const COMMANDS: &str = "commands.log"; pub const INITIAL: &str = "initial.snapshot"; pub const EXPECTED: &str = "expected.yaml"; /// `manifest.yaml` — everything needed to re-execute, and the hash the /// replay must reproduce. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub scenario: String, pub seed: u64, pub setup: Setup, pub schema_ver: u16, /// Written by the producing process. The replay compares against /// **this**, never against a value it computed itself. pub end_state_hash: String, /// Hash of the initial state, so a mutated seed is detectable. pub initial_state_hash: String, pub commit: String, } /// `expected.yaml` — the assertions that failed, expected vs actual. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Expected { pub reason: String, pub end_state: serde_json::Value, } #[derive(Debug)] pub struct ReplayReport { pub scenario: String, pub commands: usize, pub hash: String, } fn err(context: &str, e: impl std::fmt::Display) -> String { format!("{context}: {e}") } /// Write a `.cbreplay` bundle. Called when a scenario fails. pub fn write_bundle( dir: &Path, scenario: &ScenarioFile, initial_state: &serde_json::Value, initial_state_hash: &str, end_state: &serde_json::Value, end_state_hash: &str, reason: &str, ) -> Result { let slug = scenario.scenario.replace('/', "-"); let bundle = dir.join(format!("{slug}.cbreplay")); // A stale bundle from a previous run would replay the old failure. let _ = std::fs::remove_dir_all(&bundle); std::fs::create_dir_all(&bundle).map_err(|e| err("create bundle", e))?; std::fs::write( bundle.join(INITIAL), serde_json::to_vec_pretty(initial_state).map_err(|e| err("encode initial", e))?, ) .map_err(|e| err("write initial", e))?; // The command stream goes through the K11 framing, so a truncated // bundle is detected rather than replayed short. let mut log = FileLogStore::open(bundle.join(COMMANDS)).map_err(|e| err("open log", e))?; for step in &scenario.commands { let bytes = serde_json::to_vec(step).map_err(|e| err("encode command", e))?; log.append(&bytes).map_err(|e| err("append command", e))?; } let manifest = Manifest { scenario: scenario.scenario.clone(), seed: scenario.seed, setup: scenario.setup.clone(), schema_ver: 1, end_state_hash: end_state_hash.to_string(), initial_state_hash: initial_state_hash.to_string(), commit: option_env!("CB_COMMIT").unwrap_or("unknown").to_string(), }; std::fs::write( bundle.join(MANIFEST), serde_yaml::to_string(&manifest).map_err(|e| err("encode manifest", e))?, ) .map_err(|e| err("write manifest", e))?; std::fs::write( bundle.join(EXPECTED), serde_yaml::to_string(&Expected { reason: reason.to_string(), end_state: end_state.clone(), }) .map_err(|e| err("encode expected", e))?, ) .map_err(|e| err("write expected", e))?; Ok(bundle) } /// Open a bundle for re-execution: the manifest, the restored initial /// state, and the recorded steps in order. /// /// Extracted from [`replay`] by CB-WP-0011 T02 so the inspector walks a /// bundle through **the same reader the replay gate uses**. A second /// reader would let the inspector show states a replay never reached — /// and it would be a duplicated fact, checked by nothing, in the one /// place where being wrong is silent. /// /// Every control [`replay`] performed before touching the command log is /// performed here, including the one that makes the seed load-bearing. pub fn open(bundle: &Path) -> Result<(Manifest, G, Vec), String> where G: ScenarioGame, { let manifest: Manifest = serde_yaml::from_str( &std::fs::read_to_string(bundle.join(MANIFEST)).map_err(|e| err("read manifest", e))?, ) .map_err(|e| err("parse manifest", e))?; // Control: the recorded seed and setup must reproduce the recorded // initial snapshot. Without this the seed would be inert — replay // restores from the snapshot — and a mutated-seed bundle would pass. // Hash the TYPED aggregate, matching how the bundle was written. let rebuilt = G::setup(&manifest.setup, manifest.seed)?; let rebuilt_hash = state_hash_hex(&rebuilt); if rebuilt_hash != manifest.initial_state_hash { return Err(format!( "bundle is inconsistent: seed {} and setup do not reproduce the \ recorded initial state ({} != {})", manifest.seed, rebuilt_hash, manifest.initial_state_hash )); } let initial: serde_json::Value = serde_json::from_slice( &std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?, ) .map_err(|e| err("parse initial", e))?; let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; // K11 framing: a truncated or corrupt command log is rejected here. let raw = std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))?; let records = parse(&raw).map_err(|e| err("command log", e))?; let mut steps = Vec::with_capacity(records.len()); for record in &records { steps.push(serde_json::from_slice(record).map_err(|e| err("decode step", e))?); } Ok((manifest, state, steps)) } /// Re-execute a bundle and require it to reproduce the recorded hash. /// /// Returns `Err` when the bundle is corrupt, incomplete, internally /// inconsistent, or does not reproduce — all of which are the point. A /// round-trip that cannot fail proves nothing. pub fn replay(bundle: &Path) -> Result where G: ScenarioGame, { let (manifest, mut state, steps) = open::(bundle)?; let mut applied = 0usize; for step in &steps { let (actor, command) = G::parse_command(step)?; if let Ok(produced) = state.validate(actor, &command) { for event in produced { state.fold(&event); } } applied += 1; } // Positive control: a bundle that replayed nothing must not be // reported as a successful reproduction. if applied == 0 { return Err("bundle contains no commands; nothing was replayed".to_string()); } let hash = state_hash_hex(&state); if hash != manifest.end_state_hash { return Err(format!( "replay did not reproduce: {} != recorded {}", hash, manifest.end_state_hash )); } Ok(ReplayReport { scenario: manifest.scenario, commands: applied, hash, }) } /// Read a bundle's raw command log. Used by the negative controls. pub fn raw_command_log(bundle: &Path) -> Result, String> { std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e)) } /// Overwrite a bundle's raw command log. Used by the negative controls. pub fn set_raw_command_log(bundle: &Path, bytes: &[u8]) -> Result<(), String> { std::fs::write(bundle.join(COMMANDS), bytes).map_err(|e| err("write commands", e)) }