848fc7a072
DAG de dependencias limpio (modularidad horizontal):
- arje-brain-rules — rules + engine + dispatch (motor determinista)
- arje-brain-cognitive — observer + crystallize (estadística)
- arje-brain-audit — audit chain → CAS (accountability)
- arje-brain — umbrella de integración (introspect +
autopromote + metrics + loader)
Habilitador clave: TimedEvent movido de observer.rs a rules.rs
(engine lo necesitaba, era el único acoplo que rompía el DAG).
arje-brain re-exporta la API de los 3 sub-crates: arje-zero y chasqui
(consumidores) no requieren cambios. cargo check --workspace verde.
24 tests del brain pasan (4 rules + 6 cognitive + 5 audit + 9 umbrella).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
551 lines
20 KiB
Rust
551 lines
20 KiB
Rust
//! Audit log: cada acción mutadora del cerebro deja una entry inmutable
|
|
//! con su predecesor encadenado por SHA256 (estilo Merkle). Verificable a
|
|
//! posteriori sin confianza en quien escribe.
|
|
//!
|
|
//! Los entries viven en memoria. Para persistencia, `flush_to_cas()` los
|
|
//! escribe al content-addressable store y devuelve el SHA del head, que
|
|
//! puede guardarse en un archivo de "head pointer" (fuera de scope aquí).
|
|
|
|
use arje_brain_cognitive::crystallize::Crystal;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::VecDeque;
|
|
use ulid::Ulid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AuditEntry {
|
|
/// Sequence number monotónico desde el inicio del log.
|
|
pub seq: u64,
|
|
/// Wall-clock al insertar.
|
|
pub timestamp_ms: u64,
|
|
/// SHA256 del entry anterior. None para el primer entry.
|
|
pub prev_sha: Option<[u8; 32]>,
|
|
/// SHA256 de este entry (auto-calculado al construir).
|
|
pub sha: [u8; 32],
|
|
/// Acción registrada.
|
|
pub action: AuditAction,
|
|
}
|
|
|
|
/// Sin `#[serde(tag)]`: bincode requiere external tagging (default serde
|
|
/// para enums) para no usar `deserialize_any`. JSON sigue legible.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum AuditAction {
|
|
PromoteCrystal { rule_id: Ulid, crystal: Crystal },
|
|
RemoveRule { rule_id: Ulid },
|
|
LoadRulesFile { path: String, count: usize },
|
|
}
|
|
|
|
pub struct AuditLog {
|
|
entries: VecDeque<AuditEntry>,
|
|
next_seq: u64,
|
|
/// Cap del log en memoria. Entries más viejos se descartan tras flush.
|
|
cap: usize,
|
|
/// Total acumulado de entries flusheadas a CAS.
|
|
flushed_count: u64,
|
|
/// SHA del último entry persistido a CAS — el "head pointer" del log.
|
|
last_flushed_sha: Option<[u8; 32]>,
|
|
/// Path opcional donde escribir el head pointer tras cada flush.
|
|
head_pointer_path: Option<std::path::PathBuf>,
|
|
/// Subscribers a entries en tiempo real. Cada `append` empuja a todos.
|
|
/// Subscribers cuyo receiver se dropeó se purgan en el siguiente push.
|
|
subscribers: Vec<tokio::sync::mpsc::UnboundedSender<AuditEntry>>,
|
|
/// Wall-clock del último flush exitoso a CAS. None si aún no se flush.
|
|
last_flush_at_ms: Option<u64>,
|
|
}
|
|
|
|
impl AuditLog {
|
|
pub fn new() -> Self {
|
|
Self::with_cap(512)
|
|
}
|
|
|
|
pub fn with_cap(cap: usize) -> Self {
|
|
Self {
|
|
entries: VecDeque::new(),
|
|
next_seq: 0,
|
|
cap,
|
|
flushed_count: 0,
|
|
last_flushed_sha: None,
|
|
head_pointer_path: None,
|
|
subscribers: Vec::new(),
|
|
last_flush_at_ms: None,
|
|
}
|
|
}
|
|
|
|
/// Registra un nuevo subscriber. El receiver recibe cada `AuditEntry`
|
|
/// futuro hasta que el receiver se dropee (subscriber se purga al
|
|
/// siguiente `append`).
|
|
pub fn subscribe(&mut self) -> tokio::sync::mpsc::UnboundedReceiver<AuditEntry> {
|
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
self.subscribers.push(tx);
|
|
rx
|
|
}
|
|
|
|
pub fn subscriber_count(&self) -> usize { self.subscribers.len() }
|
|
|
|
pub fn with_head_pointer(mut self, path: std::path::PathBuf) -> Self {
|
|
self.head_pointer_path = Some(path);
|
|
self
|
|
}
|
|
|
|
/// Apendea una acción. Calcula el SHA encadenado contra el último entry.
|
|
pub fn append(&mut self, action: AuditAction) -> AuditEntry {
|
|
let prev_sha = self.entries.back().map(|e| e.sha);
|
|
let timestamp_ms = now_ms();
|
|
let seq = self.next_seq;
|
|
self.next_seq += 1;
|
|
|
|
// Pre-construct con sha en cero, luego calcular sha sobre el
|
|
// serializado canónico, luego sobreescribir el campo.
|
|
let mut entry = AuditEntry {
|
|
seq, timestamp_ms, prev_sha, sha: [0u8; 32], action,
|
|
};
|
|
entry.sha = compute_sha(&entry);
|
|
|
|
if self.entries.len() >= self.cap {
|
|
self.entries.pop_front();
|
|
}
|
|
self.entries.push_back(entry.clone());
|
|
// Empujar a subscribers, purgando los muertos in-place.
|
|
self.subscribers.retain(|tx| tx.send(entry.clone()).is_ok());
|
|
entry
|
|
}
|
|
|
|
pub fn recent(&self, limit: usize) -> impl Iterator<Item = &AuditEntry> {
|
|
let n = if limit == 0 { self.entries.len() } else { limit.min(self.entries.len()) };
|
|
self.entries.iter().skip(self.entries.len() - n)
|
|
}
|
|
|
|
pub fn len(&self) -> usize { self.entries.len() }
|
|
pub fn is_empty(&self) -> bool { self.entries.is_empty() }
|
|
|
|
pub fn head_sha(&self) -> Option<[u8; 32]> {
|
|
self.entries.back().map(|e| e.sha)
|
|
}
|
|
|
|
/// Persiste el entry pasado al CAS y devuelve su SHA. Pensado para
|
|
/// snapshots externos — el log en memoria sigue intacto.
|
|
pub fn persist_to_cas(entry: &AuditEntry) -> anyhow::Result<[u8; 32]> {
|
|
let bytes = serde_json::to_vec(entry)?;
|
|
let sha = arje_cas::store(&bytes)?;
|
|
Ok(sha)
|
|
}
|
|
|
|
/// Persiste TODOS los entries actuales al CAS y actualiza el head pointer.
|
|
/// Idempotente: re-flushar dos veces da los mismos SHAs (CAS dedup).
|
|
/// Devuelve cuántas entries se flushearon en esta pasada.
|
|
///
|
|
/// Forma canónica: serializamos `entry` con `sha = [0; 32]` (formato
|
|
/// pre-hash). El CAS computa sha256 sobre esos bytes y devuelve un SHA
|
|
/// que por construcción coincide con `entry.sha` calculado al append.
|
|
pub fn flush_to_cas(&mut self) -> anyhow::Result<usize> {
|
|
let mut written = 0;
|
|
let mut last_sha = self.last_flushed_sha;
|
|
for entry in &self.entries {
|
|
if entry.seq < self.flushed_count { continue; }
|
|
let bytes = canonical_bytes(entry);
|
|
let sha = arje_cas::store(&bytes)?;
|
|
debug_assert_eq!(sha, entry.sha,
|
|
"CAS sha != entry.sha — fórmula canónica rota");
|
|
last_sha = Some(sha);
|
|
written += 1;
|
|
}
|
|
self.flushed_count += written as u64;
|
|
self.last_flushed_sha = last_sha;
|
|
if written > 0 {
|
|
self.last_flush_at_ms = Some(now_ms());
|
|
}
|
|
// Persistir head pointer si está configurado.
|
|
if let (Some(path), Some(sha)) = (&self.head_pointer_path, last_sha) {
|
|
let pointer = AuditHeadPointer {
|
|
last_seq: self.next_seq.saturating_sub(1),
|
|
last_sha: sha,
|
|
flushed_count: self.flushed_count,
|
|
timestamp_ms: now_ms(),
|
|
};
|
|
let json = serde_json::to_vec_pretty(&pointer)?;
|
|
// Escritura atómica: tmp + rename
|
|
let tmp = path.with_extension("tmp");
|
|
if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
|
|
std::fs::write(&tmp, json)?;
|
|
std::fs::rename(&tmp, path)?;
|
|
}
|
|
Ok(written)
|
|
}
|
|
|
|
pub fn flushed_count(&self) -> u64 { self.flushed_count }
|
|
pub fn last_flushed_sha(&self) -> Option<[u8; 32]> { self.last_flushed_sha }
|
|
pub fn last_flush_at_ms(&self) -> Option<u64> { self.last_flush_at_ms }
|
|
|
|
/// Segundos transcurridos desde el último flush. None si nunca se flush.
|
|
pub fn last_flush_age_secs(&self) -> Option<f64> {
|
|
let then = self.last_flush_at_ms?;
|
|
let now = now_ms();
|
|
Some((now.saturating_sub(then)) as f64 / 1000.0)
|
|
}
|
|
}
|
|
|
|
/// Pointer al head del audit log — escrito atómicamente en disco tras cada
|
|
/// flush. Permite verificar la integridad del log sin escanearlo entero:
|
|
/// el cliente lee el head, recupera el blob desde CAS, valida `prev_sha`
|
|
/// recursivamente hasta el genesis.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AuditHeadPointer {
|
|
pub last_seq: u64,
|
|
pub last_sha: [u8; 32],
|
|
pub flushed_count: u64,
|
|
pub timestamp_ms: u64,
|
|
}
|
|
|
|
/// Reporte de un replay: número de actions aplicadas + reglas finales.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ReplayReport {
|
|
pub applied: u64,
|
|
pub final_rule_count: usize,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Reporte de verificación de la cadena audit.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VerificationReport {
|
|
/// Cuántas entries se recorrieron y verificaron exitosamente.
|
|
pub verified: u64,
|
|
/// Si hubo error, el seq donde se detectó.
|
|
pub broken_at_seq: Option<u64>,
|
|
/// Detalles del error si hubo.
|
|
pub error: Option<String>,
|
|
/// SHA del genesis (primer entry; prev_sha = None).
|
|
pub genesis_sha: Option<[u8; 32]>,
|
|
}
|
|
|
|
/// Recorre la cadena del audit log desde `start_sha` hacia atrás vía `prev_sha`
|
|
/// hasta el genesis. Para cada entry valida:
|
|
/// 1. CAS contiene un blob bajo ese SHA
|
|
/// 2. sha256(blob) == SHA esperado (defensa contra tampering del CAS)
|
|
/// 3. El blob deserializa a AuditEntry con sha=[0;32] (forma canónica)
|
|
///
|
|
/// Devuelve un VerificationReport con el conteo, posibles errores y
|
|
/// el SHA del genesis (útil para clientes que quieren cachearlo).
|
|
pub fn verify_chain_from_cas(start_sha: [u8; 32]) -> VerificationReport {
|
|
let mut current = Some(start_sha);
|
|
let mut verified = 0u64;
|
|
let mut last_seen: Option<AuditEntry> = None;
|
|
|
|
while let Some(sha) = current {
|
|
let path = arje_cas::cas_root().join(arje_cas::hex(&sha));
|
|
let bytes = match std::fs::read(&path) {
|
|
Ok(b) => b,
|
|
Err(e) => return VerificationReport {
|
|
verified,
|
|
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
|
error: Some(format!("CAS read {}: {e}", path.display())),
|
|
genesis_sha: None,
|
|
},
|
|
};
|
|
// Verificación 1: el blob hashea a la SHA esperada (CAS contract).
|
|
let actual = arje_cas::sha256_of(&bytes);
|
|
if actual != sha {
|
|
return VerificationReport {
|
|
verified,
|
|
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
|
error: Some(format!(
|
|
"CAS tamper en {}: expected {} got {}",
|
|
path.display(), arje_cas::hex(&sha), arje_cas::hex(&actual)
|
|
)),
|
|
genesis_sha: None,
|
|
};
|
|
}
|
|
// Verificación 2: deserialize. El blob canónico tiene sha=[0;32].
|
|
let mut entry: AuditEntry = match serde_json::from_slice(&bytes) {
|
|
Ok(e) => e,
|
|
Err(e) => return VerificationReport {
|
|
verified,
|
|
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
|
error: Some(format!("deserialize: {e}")),
|
|
genesis_sha: None,
|
|
},
|
|
};
|
|
// Re-poblar el sha en el entry para reportar coherentemente.
|
|
entry.sha = sha;
|
|
verified += 1;
|
|
|
|
let prev = entry.prev_sha;
|
|
last_seen = Some(entry);
|
|
current = prev;
|
|
}
|
|
|
|
VerificationReport {
|
|
verified,
|
|
broken_at_seq: None,
|
|
error: None,
|
|
genesis_sha: last_seen.as_ref().map(|e| e.sha),
|
|
}
|
|
}
|
|
|
|
/// Devuelve el set de SHAs alcanzables desde `start_sha` siguiendo
|
|
/// `prev_sha` hasta el genesis. Usado por el GC del CAS para construir
|
|
/// las "raíces vivas" del audit log.
|
|
pub fn reachable_from_head(start_sha: [u8; 32]) -> std::collections::HashSet<[u8; 32]> {
|
|
let mut set = std::collections::HashSet::new();
|
|
let mut current = Some(start_sha);
|
|
while let Some(sha) = current {
|
|
if !set.insert(sha) { break; } // ciclo (no debería pasar) — corta
|
|
let path = arje_cas::cas_root().join(arje_cas::hex(&sha));
|
|
let bytes = match std::fs::read(&path) { Ok(b) => b, Err(_) => break };
|
|
let entry: AuditEntry = match serde_json::from_slice(&bytes) {
|
|
Ok(e) => e, Err(_) => break,
|
|
};
|
|
current = entry.prev_sha;
|
|
}
|
|
set
|
|
}
|
|
|
|
/// Recorre la cadena entera (head→genesis) y reconstruye la lista de
|
|
/// actions en orden cronológico (oldest first). Útil tanto para replay
|
|
/// como para auditoría retrospectiva.
|
|
pub fn collect_chain_from_cas(start_sha: [u8; 32]) -> anyhow::Result<Vec<AuditEntry>> {
|
|
let mut entries = Vec::new();
|
|
let mut current = Some(start_sha);
|
|
while let Some(sha) = current {
|
|
let path = arje_cas::cas_root().join(arje_cas::hex(&sha));
|
|
let bytes = std::fs::read(&path)?;
|
|
let mut entry: AuditEntry = serde_json::from_slice(&bytes)?;
|
|
entry.sha = sha;
|
|
let prev = entry.prev_sha;
|
|
entries.push(entry);
|
|
current = prev;
|
|
}
|
|
// entries está en orden head→genesis. Reverse para chronological.
|
|
entries.reverse();
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Aplica las actions de la cadena en orden cronológico contra un engine
|
|
/// fresco. PromoteCrystal → insert. RemoveRule → remove. LoadRulesFile →
|
|
/// log informativo (los archivos pueden no existir en el ambiente actual).
|
|
pub fn replay_chain(
|
|
start_sha: [u8; 32],
|
|
engine: &mut arje_brain_rules::engine::RuleEngine,
|
|
) -> ReplayReport {
|
|
let entries = match collect_chain_from_cas(start_sha) {
|
|
Ok(es) => es,
|
|
Err(e) => return ReplayReport {
|
|
applied: 0, final_rule_count: engine.len(),
|
|
error: Some(format!("collect chain: {e}")),
|
|
},
|
|
};
|
|
let mut applied = 0u64;
|
|
for entry in &entries {
|
|
match &entry.action {
|
|
AuditAction::PromoteCrystal { rule_id, crystal } => {
|
|
let mut rule = arje_brain_cognitive::crystallize::crystal_to_rule(crystal);
|
|
rule.id = *rule_id; // preservar identidad histórica
|
|
engine.insert(rule);
|
|
}
|
|
AuditAction::RemoveRule { rule_id } => {
|
|
engine.remove(*rule_id);
|
|
}
|
|
AuditAction::LoadRulesFile { path: _, count: _ } => {
|
|
// Los archivos referenciados por path pueden haber cambiado
|
|
// o no existir. Log y skip — el replay sólo reconstruye
|
|
// promotes/removes que tienen estado en CAS.
|
|
}
|
|
}
|
|
applied += 1;
|
|
}
|
|
ReplayReport {
|
|
applied,
|
|
final_rule_count: engine.len(),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
impl Default for AuditLog {
|
|
fn default() -> Self { Self::new() }
|
|
}
|
|
|
|
fn now_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// SHA256 sobre el entry en forma canónica (sha=[0;32]). Hash y CAS storage
|
|
/// ven los mismos bytes, así que `arje_cas::store(canonical)` devuelve el
|
|
/// mismo SHA que `compute_sha(entry)`.
|
|
fn compute_sha(entry: &AuditEntry) -> [u8; 32] {
|
|
let bytes = canonical_bytes(entry);
|
|
arje_cas::sha256_of(&bytes)
|
|
}
|
|
|
|
/// Forma canónica: el entry serializado JSON con `sha = [0; 32]`.
|
|
/// JSON sin pretty-print es determinístico para nuestros tipos.
|
|
fn canonical_bytes(entry: &AuditEntry) -> Vec<u8> {
|
|
let canonical = AuditEntry {
|
|
sha: [0u8; 32],
|
|
..entry.clone()
|
|
};
|
|
serde_json::to_vec(&canonical).unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn chain_links_consecutive_entries() {
|
|
let mut log = AuditLog::new();
|
|
let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
|
|
let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
|
|
assert!(e1.prev_sha.is_none());
|
|
assert_eq!(e2.prev_sha, Some(e1.sha));
|
|
assert_ne!(e1.sha, e2.sha);
|
|
}
|
|
|
|
#[test]
|
|
fn seq_monotonic() {
|
|
let mut log = AuditLog::new();
|
|
let e1 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
|
|
let e2 = log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
|
|
assert_eq!(e2.seq, e1.seq + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn cap_evicts_oldest() {
|
|
let mut log = AuditLog::with_cap(3);
|
|
for _ in 0..5 {
|
|
log.append(AuditAction::RemoveRule { rule_id: Ulid::new() });
|
|
}
|
|
assert_eq!(log.len(), 3);
|
|
// El primer seq superviviente debe ser 2.
|
|
assert_eq!(log.recent(0).next().unwrap().seq, 2);
|
|
}
|
|
|
|
// ---------- Tests de integración con CAS real (en directorio temporal) ----------
|
|
|
|
use arje_brain_rules::engine::RuleEngine;
|
|
use std::sync::Mutex;
|
|
|
|
/// Lock para serializar tests que mutan ENTE_CAS_ROOT (test threads
|
|
/// comparten env vars). Sin esto, dos tests en paralelo pisan el path.
|
|
static CAS_TEST_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
fn with_temp_cas<F: FnOnce()>(f: F) {
|
|
let _guard = CAS_TEST_LOCK.lock().unwrap();
|
|
let dir = std::env::temp_dir().join(format!("ente-cas-test-{}", Ulid::new()));
|
|
std::env::set_var("ENTE_CAS_ROOT", &dir);
|
|
let _cleanup = scopeguard(&dir);
|
|
f();
|
|
}
|
|
|
|
fn scopeguard(dir: &std::path::Path) -> impl Drop + '_ {
|
|
struct G<'a>(&'a std::path::Path);
|
|
impl<'a> Drop for G<'a> {
|
|
fn drop(&mut self) {
|
|
std::env::remove_var("ENTE_CAS_ROOT");
|
|
let _ = std::fs::remove_dir_all(self.0);
|
|
}
|
|
}
|
|
G(dir)
|
|
}
|
|
|
|
fn dummy_crystal(ant: EventKind, con: EventKind) -> Crystal {
|
|
Crystal {
|
|
antecedent: ant,
|
|
consequent: con,
|
|
conditional_prob: 0.9,
|
|
pmi: 1.5,
|
|
support: 7,
|
|
gap_stats: None,
|
|
}
|
|
}
|
|
|
|
use arje_brain_rules::rules::EventKind;
|
|
|
|
#[test]
|
|
fn flush_round_trip_preserves_chain() {
|
|
with_temp_cas(|| {
|
|
let mut log = AuditLog::new();
|
|
let id1 = Ulid::new();
|
|
let id2 = Ulid::new();
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id1,
|
|
crystal: dummy_crystal(EventKind::EnteSpawned, EventKind::EnteDied),
|
|
});
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id2,
|
|
crystal: dummy_crystal(EventKind::BusAnnounce, EventKind::BusInvoke),
|
|
});
|
|
log.append(AuditAction::RemoveRule { rule_id: id1 });
|
|
|
|
assert_eq!(log.flush_to_cas().unwrap(), 3);
|
|
let head = log.last_flushed_sha().expect("head set");
|
|
let report = verify_chain_from_cas(head);
|
|
assert!(report.error.is_none(), "verification failed: {:?}", report.error);
|
|
assert_eq!(report.verified, 3);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn replay_reconstructs_engine_state() {
|
|
with_temp_cas(|| {
|
|
let mut log = AuditLog::new();
|
|
let id1: Ulid = "01KQR3000000000000000000A1".parse().unwrap();
|
|
let id2: Ulid = "01KQR3000000000000000000A2".parse().unwrap();
|
|
let id3: Ulid = "01KQR3000000000000000000A3".parse().unwrap();
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id1,
|
|
crystal: dummy_crystal(EventKind::EnteSpawned, EventKind::EnteDied),
|
|
});
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id2,
|
|
crystal: dummy_crystal(EventKind::BusAnnounce, EventKind::BusInvoke),
|
|
});
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id3,
|
|
crystal: dummy_crystal(EventKind::DeviceAdded, EventKind::DeviceRemoved),
|
|
});
|
|
log.append(AuditAction::RemoveRule { rule_id: id2 });
|
|
log.flush_to_cas().unwrap();
|
|
let head = log.last_flushed_sha().unwrap();
|
|
|
|
let mut engine = RuleEngine::empty();
|
|
let rep = replay_chain(head, &mut engine);
|
|
assert!(rep.error.is_none(), "replay error: {:?}", rep.error);
|
|
assert_eq!(rep.applied, 4);
|
|
assert_eq!(engine.len(), 2, "id2 should be removed, id1 + id3 remain");
|
|
// Ulids preservados
|
|
let ids: Vec<Ulid> = engine.rules().map(|r| r.id).collect();
|
|
assert!(ids.contains(&id1));
|
|
assert!(!ids.contains(&id2));
|
|
assert!(ids.contains(&id3));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn replay_after_eviction_still_works() {
|
|
with_temp_cas(|| {
|
|
// Cap pequeño: la mayoría de entries se evictan de memoria pero
|
|
// siguen en CAS. Replay debe poder reconstruir desde CAS solo.
|
|
let mut log = AuditLog::with_cap(2);
|
|
let mut ids = Vec::new();
|
|
for _ in 0..6 {
|
|
let id = Ulid::new();
|
|
ids.push(id);
|
|
log.append(AuditAction::PromoteCrystal {
|
|
rule_id: id,
|
|
crystal: dummy_crystal(EventKind::EnteSpawned, EventKind::EnteDied),
|
|
});
|
|
log.flush_to_cas().unwrap();
|
|
}
|
|
assert_eq!(log.len(), 2, "cap eviction limita memoria");
|
|
let head = log.last_flushed_sha().unwrap();
|
|
|
|
let mut engine = RuleEngine::empty();
|
|
let rep = replay_chain(head, &mut engine);
|
|
assert!(rep.error.is_none());
|
|
assert_eq!(rep.applied, 6);
|
|
assert_eq!(engine.len(), 6);
|
|
});
|
|
}
|
|
}
|