K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
5.2 KiB
Rust
177 lines
5.2 KiB
Rust
//! 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<E> {
|
||
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<E> {
|
||
events: Vec<Envelope<E>>,
|
||
}
|
||
|
||
impl<E> EventLog<E> {
|
||
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<E>) -> 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<Item = &Envelope<E>> {
|
||
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<T: Serialize>(value: &T) -> Vec<u8> {
|
||
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<T: Serialize>(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<T: Serialize>(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<u8>,
|
||
}
|
||
|
||
impl Snapshot {
|
||
pub fn take<T: Serialize>(state: &T, through: EventSeq) -> Self {
|
||
Self {
|
||
through,
|
||
state: canonical_bytes(state),
|
||
}
|
||
}
|
||
|
||
pub fn restore<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
|
||
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<u8> = 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<String, u8> = snap.restore().unwrap();
|
||
assert_eq!(state, restored);
|
||
assert_eq!(state_hash_hex(&state), state_hash_hex(&restored));
|
||
}
|
||
}
|