fix(agent): provenance survives compaction; bound alert/session growth; snapshot the WAL

- ProvenanceStore::remap: compaction renumbers cache indices (which are the
  provenance record ids) but nothing renumbered the ledger, so after any
  compaction — including the automatic one in delete() — every surviving
  record's hash was filed under a different record and the next
  save_or_update raised a bogus High "integrity mismatch" alert.
- Pending anomaly alerts are capped (newest 1024 kept). Alerts never block a
  save, and a session over its write limit alerts on every write, so a caller
  that didn't drain them grew the queue without bound.
- WriteAnomalyDetector tracks at most 4096 sessions, forgetting the
  least-active half on overflow instead of leaking one entry per session id
  for the life of the process.
- snapshot() copies the pending WAL next to the .h5 copy, so a snapshot is
  the store as it is now rather than as of the last checkpoint (it used to
  silently omit up to wal_max_entries recent saves).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 06:08:53 -07:00
co-authored by Claude Fable 5.1
parent 99b907be04
commit 0744d52639
3 changed files with 128 additions and 4 deletions
+20
View File
@@ -161,6 +161,9 @@ pub struct WriteEvent {
// WriteAnomalyDetector
// ---------------------------------------------------------------------------
/// Upper bound on distinct session ids the detector tracks at once.
const MAX_TRACKED_SESSIONS: usize = 4096;
/// Tracks write events and raises alerts for suspicious behaviour.
#[derive(Debug)]
pub struct WriteAnomalyDetector {
@@ -189,6 +192,23 @@ impl WriteAnomalyDetector {
if event.timestamp > self.last_timestamp {
self.last_timestamp = event.timestamp;
}
// Bound the per-session map: a long-lived process sees an unbounded
// number of distinct session ids. When it overflows, forget the
// sessions with the fewest writes (they are furthest from the limit
// this map exists to enforce); the current one is re-added below.
if self.session_counts.len() >= MAX_TRACKED_SESSIONS
&& !self.session_counts.contains_key(&event.session_id)
{
let mut counts: Vec<u32> = self.session_counts.values().copied().collect();
let keep_from = counts.len() / 2;
counts.select_nth_unstable(keep_from);
let threshold = counts[keep_from];
self.session_counts.retain(|_, c| *c >= threshold);
if self.session_counts.len() >= MAX_TRACKED_SESSIONS {
// Every session had the same count: drop them all.
self.session_counts.clear();
}
}
*self
.session_counts
.entry(event.session_id.clone())