//! cb-events — event envelope, append-only log, snapshots, canonical //! serialization, and state hashing (GameKernel §2.4, K4, K7, K9–K11). pub mod store; pub use store::{ conformance as log_store_conformance, FileLogStore, LogStore, MemLogStore, StoreError, }; use cb_kernel::{EventSeq, GameId}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; /// Versioned event envelope (GameKernel K4). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Envelope { pub seq: EventSeq, pub game_id: GameId, pub round: u8, pub schema_ver: u16, pub payload: E, } /// In-memory append-only event log of typed envelopes. /// /// Durable framing and the `LogStore` port live in [`store`]; this type is /// the typed, sequence-enforcing layer above it. Until CB-WP-0006 T05 the /// comment here promised file-backed storage "in a later loop pass" — that /// pass is this one. #[derive(Debug, Default)] pub struct EventLog { events: Vec>, } impl EventLog { pub fn new() -> Self { Self { events: Vec::new() } } /// Append with sequence enforcement: envelopes must arrive in strictly /// increasing `seq` order (GameKernel K11). pub fn append(&mut self, envelope: Envelope) -> Result<(), LogError> { if let Some(last) = self.events.last() { if envelope.seq.0 != last.seq.0 + 1 { return Err(LogError::NonMonotonicSeq { expected: last.seq.0 + 1, got: envelope.seq.0, }); } } self.events.push(envelope); Ok(()) } pub fn len(&self) -> usize { self.events.len() } pub fn is_empty(&self) -> bool { self.events.is_empty() } pub fn iter(&self) -> impl Iterator> { self.events.iter() } } #[derive(Debug, PartialEq, Eq)] pub enum LogError { NonMonotonicSeq { expected: u64, got: u64 }, } impl core::fmt::Display for LogError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { LogError::NonMonotonicSeq { expected, got } => { write!(f, "non-monotonic event seq: expected {expected}, got {got}") } } } } /// Canonical serialization: serde_json with struct-declaration field order /// and ordered maps (kernel state uses BTreeMap per GameKernel K6), so the /// same state always yields the same bytes. pub fn canonical_bytes(value: &T) -> Vec { serde_json::to_vec(value).expect("canonical serialization must not fail") } /// SHA-256 state hash over the canonical serialization (GameKernel K7). pub fn state_hash(value: &T) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(canonical_bytes(value)); hasher.finalize().into() } /// Hex form of [`state_hash`], for scenario `expect.state_hash` fields. pub fn state_hash_hex(value: &T) -> String { let hash = state_hash(value); let mut out = String::with_capacity(64); for byte in hash { use core::fmt::Write; write!(out, "{byte:02x}").expect("writing to String cannot fail"); } out } /// A snapshot pairs the canonical state bytes with the last included /// event (GameKernel K9). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Snapshot { pub through: EventSeq, pub state: Vec, } impl Snapshot { pub fn take(state: &T, through: EventSeq) -> Self { Self { through, state: canonical_bytes(state), } } pub fn restore(&self) -> Result { serde_json::from_slice(&self.state) } } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; #[test] fn log_rejects_seq_gaps() { let mut log: EventLog = EventLog::new(); let env = |seq| Envelope { seq: EventSeq(seq), game_id: GameId(1), round: 1, schema_ver: 1, payload: 0u8, }; log.append(env(0)).unwrap(); log.append(env(1)).unwrap(); assert_eq!( log.append(env(3)), Err(LogError::NonMonotonicSeq { expected: 2, got: 3 }) ); } /// K7: identical state → identical hash; any change → different hash. #[test] fn state_hash_is_stable_and_sensitive() { let mut a = BTreeMap::new(); a.insert("stress", 2u8); let mut b = BTreeMap::new(); b.insert("stress", 2u8); assert_eq!(state_hash_hex(&a), state_hash_hex(&b)); b.insert("stress", 3u8); assert_ne!(state_hash_hex(&a), state_hash_hex(&b)); } /// K9: snapshot → restore is identity on the canonical form. #[test] fn snapshot_roundtrip() { let mut state = BTreeMap::new(); state.insert("round".to_string(), 3u8); let snap = Snapshot::take(&state, EventSeq(17)); let restored: BTreeMap = snap.restore().unwrap(); assert_eq!(state, restored); assert_eq!(state_hash_hex(&state), state_hash_hex(&restored)); } }