Skip to main content

khive_pack_brain/
persist.rs

1//! Brain state persistence — snapshot upsert and namespace-scoped reload.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6// Test-only interleaving hook for ensure_loaded.
7//
8// When set, every cold ensure_loaded call (after the async DB load, before the
9// final tracker lock) will:
10//   1. Send () on `reached_tx` to signal the test controller "I am at the hook".
11//   2. Await `proceed_rx` before continuing.
12//
13// This lets a test precisely inject the following interleaving:
14//   - Loader B reaches hook  →  test controller receives reached signal
15//   - Test controller runs Loader A to completion and mutates state
16//   - Test controller sends on proceed_tx  →  Loader B continues
17//
18// With the old code (no re-check), Loader B then clobbered A's mutation.
19// With the fix (re-check under final guard), Loader B sees is_active=true
20// and returns early, preserving A's mutation.
21#[cfg(test)]
22pub(crate) struct LoadHook {
23    pub reached_tx: tokio::sync::oneshot::Sender<()>,
24    pub proceed_rx: tokio::sync::oneshot::Receiver<()>,
25}
26
27#[cfg(test)]
28pub(crate) static POST_LOAD_HOOK: std::sync::Mutex<Option<LoadHook>> = std::sync::Mutex::new(None);
29
30#[cfg(test)]
31pub(crate) fn set_post_load_hook(hook: LoadHook) {
32    *POST_LOAD_HOOK.lock().unwrap() = Some(hook);
33}
34
35#[cfg(test)]
36pub(crate) fn clear_post_load_hook() {
37    *POST_LOAD_HOOK.lock().unwrap() = None;
38}
39
40use serde_json::Value;
41
42use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError};
43use khive_storage::types::{SqlStatement, SqlValue};
44use khive_storage::{SqlAccess, SqlWriter};
45
46use khive_brain_core::{
47    validate_brain_state_snapshot_with_capacity, BrainSignal, BrainState, BrainStateSnapshot,
48};
49
50use crate::event::interpret;
51
52const SNAPSHOT_PROFILE_ID: &str = "__brain__";
53const DEFAULT_SNAPSHOT_BATCH_SIZE: u64 = 5;
54
55/// Tracks loaded namespaces and dirty event counts; owns the per-namespace state map.
56pub struct PersistenceTracker {
57    /// Namespace currently reflected in the shared `BrainState` slot, if any.
58    pub(crate) active_namespace: Option<String>,
59    /// Saved snapshots of in-memory state for namespaces that have been initialised
60    /// but are not currently in the active slot.  Used for save-restore on switch.
61    saved_states: HashMap<String, BrainState>,
62    /// Namespaces for which state has been initialised (from DB or fresh default).
63    pub(crate) loaded_namespaces: HashMap<String, ()>,
64    dirty_counts: HashMap<String, u64>,
65    snapshot_batch_size: u64,
66    /// Pre-load accumulator for hook signals that arrive before `ensure_loaded` runs.
67    ///
68    /// When `on_dispatch` fires for a namespace that has never been loaded from the
69    /// DB, the signal is queued here rather than applied to a speculative
70    /// `BrainState`.  `ensure_loaded` drains this queue *after* the snapshot +
71    /// event-replay path completes, so the ordering guarantee is:
72    ///   persisted snapshot → replayed events → queued hook signals
73    ///
74    /// The namespace is deliberately NOT added to `loaded_namespaces` while
75    /// signals are pending; that prevents `ensure_loaded` from skipping the DB
76    /// round-trip for a namespace that may have existing persisted history.
77    pending_hook_signals: HashMap<String, Vec<BrainSignal>>,
78}
79
80impl Default for PersistenceTracker {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl PersistenceTracker {
87    /// Create a fresh `PersistenceTracker` with no loaded namespaces.
88    pub fn new() -> Self {
89        Self {
90            active_namespace: None,
91            saved_states: HashMap::new(),
92            loaded_namespaces: HashMap::new(),
93            dirty_counts: HashMap::new(),
94            snapshot_batch_size: DEFAULT_SNAPSHOT_BATCH_SIZE,
95            pending_hook_signals: HashMap::new(),
96        }
97    }
98
99    /// Return `true` if `namespace` has been initialised (from DB or fresh default).
100    pub fn is_loaded(&self, namespace: &str) -> bool {
101        self.loaded_namespaces.contains_key(namespace)
102    }
103
104    /// Return `true` if `namespace` is the currently active namespace slot.
105    pub fn is_active(&self, namespace: &str) -> bool {
106        self.active_namespace.as_deref() == Some(namespace)
107    }
108
109    /// Register `namespace` as loaded and set it as the active namespace.
110    pub fn mark_loaded(&mut self, namespace: String) {
111        self.loaded_namespaces.insert(namespace.clone(), ());
112        self.active_namespace = Some(namespace);
113    }
114
115    /// Save `from_state` for `from_namespace`, then activate `to_namespace` (returning saved state if any).
116    pub fn swap_namespace(
117        &mut self,
118        from_namespace: &str,
119        from_state: BrainState,
120        to_namespace: String,
121    ) -> Option<BrainState> {
122        self.saved_states
123            .insert(from_namespace.to_string(), from_state);
124        let saved = self.saved_states.remove(&to_namespace);
125        self.active_namespace = Some(to_namespace);
126        saved
127    }
128
129    /// Increment the dirty event count for `namespace`; return `true` when a snapshot is due.
130    pub fn increment_dirty(&mut self, namespace: &str) -> bool {
131        let count = self.dirty_counts.entry(namespace.to_string()).or_insert(0);
132        *count += 1;
133        *count >= self.snapshot_batch_size
134    }
135
136    pub fn reset_dirty(&mut self, namespace: &str) {
137        self.dirty_counts.insert(namespace.to_string(), 0);
138    }
139
140    /// Return the total_events counter for any namespace stored in `saved_states`
141    /// (saved-off namespaces) or `pending_hook_signals` (cold pre-load queue).
142    /// Returns `None` when no state has been initialised for the given namespace.
143    ///
144    /// Note: does NOT return the counter for the active namespace, which lives in
145    /// the shared `BrainState` slot, not in `saved_states`.
146    #[cfg(test)]
147    pub(crate) fn total_events_for(&self, namespace: &str) -> Option<u64> {
148        if let Some(s) = self.saved_states.get(namespace) {
149            return Some(s.balanced_recall.total_events);
150        }
151        self.pending_hook_signals
152            .get(namespace)
153            .map(|signals| signals.len() as u64)
154    }
155
156    /// Apply a signal to the state bucket that owns `namespace`.
157    ///
158    /// - Active namespace: returns `ApplyTarget::ActiveSlot` — caller must apply
159    ///   the signal to the shared `BrainState` lock while holding the dispatch gate.
160    /// - Saved (loaded) namespace: applies directly to `saved_states` and returns
161    ///   `ApplyTarget::Done`.
162    /// - Cold/unknown namespace: enqueues the signal in `pending_hook_signals` and
163    ///   returns `ApplyTarget::Done`.  The namespace is NOT marked loaded; the DB
164    ///   round-trip is preserved for the first `ensure_loaded` call.  The queue is
165    ///   drained by `ensure_loaded` *after* snapshot restore and event replay, so
166    ///   the ordering guarantee is: snapshot → replayed events → queued signals.
167    ///
168    /// No event is silently dropped regardless of which slot is currently active.
169    pub(crate) fn route_signal(
170        &mut self,
171        namespace: &str,
172        signal: &khive_brain_core::BrainSignal,
173        entity_capacity: usize,
174    ) -> ApplyTarget {
175        if self.is_active(namespace) {
176            return ApplyTarget::ActiveSlot;
177        }
178
179        if self.is_loaded(namespace) {
180            // Namespace has been loaded from the DB but is currently saved off.
181            // Apply directly to its saved BrainState.
182            if let Some(state) = self.saved_states.get_mut(namespace) {
183                state.balanced_recall.apply_signal(signal);
184                crate::sync_balanced_recall_record(state);
185            }
186            return ApplyTarget::Done;
187        }
188
189        // Cold/unknown namespace: queue the signal for deferred application.
190        // Do NOT mark the namespace loaded — ensure_loaded must still perform
191        // the DB snapshot + event-replay before draining this queue.
192        let _ = entity_capacity;
193        self.pending_hook_signals
194            .entry(namespace.to_string())
195            .or_default()
196            .push(signal.clone());
197        ApplyTarget::Done
198    }
199
200    /// Drain and return any pending hook signals for `namespace`.
201    ///
202    /// Called by `ensure_loaded` after the normal load path (snapshot + replay)
203    /// completes, so queued signals land on top of persisted history.
204    pub(crate) fn drain_pending_signals(&mut self, namespace: &str) -> Vec<BrainSignal> {
205        self.pending_hook_signals
206            .remove(namespace)
207            .unwrap_or_default()
208    }
209}
210
211/// Indicates where a dispatched signal should be applied by the caller.
212pub(crate) enum ApplyTarget {
213    /// The namespace is active — the caller must apply the signal to the shared
214    /// `BrainState` slot (which the caller already holds behind the dispatch gate).
215    ActiveSlot,
216    /// Signal was applied inside `PersistenceTracker`; caller has nothing left to do.
217    Done,
218}
219
220fn sql_err(context: &str, e: impl std::fmt::Display) -> RuntimeError {
221    RuntimeError::Internal(format!("brain persistence {context}: {e}"))
222}
223
224/// Append one `brain_event_log` row using an already-acquired writer (plain
225/// writer or an open transaction — both implement `SqlWriter`).
226async fn append_brain_event_on_writer(
227    writer: &mut dyn SqlWriter,
228    namespace: &str,
229    profile_id: &str,
230    event_kind: &str,
231    payload: &Value,
232    created_at_us: i64,
233) -> Result<(), RuntimeError> {
234    let payload_str = serde_json::to_string(payload).map_err(|e| sql_err("serialize event", e))?;
235
236    writer
237        .execute(SqlStatement {
238            sql: "INSERT INTO brain_event_log (profile_id, namespace, event_kind, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5)".into(),
239            params: vec![
240                SqlValue::Text(profile_id.to_string()),
241                SqlValue::Text(namespace.to_string()),
242                SqlValue::Text(event_kind.to_string()),
243                SqlValue::Text(payload_str),
244                SqlValue::Integer(created_at_us),
245            ],
246            label: Some("brain_event_log_append".into()),
247        })
248        .await
249        .map_err(|e| sql_err("append event", e))?;
250
251    Ok(())
252}
253
254/// Upsert the namespace's `brain_profile_snapshots` row using an
255/// already-acquired writer (plain writer or an open transaction).
256async fn upsert_snapshot_on_writer(
257    writer: &mut dyn SqlWriter,
258    namespace: &str,
259    snapshot: &BrainStateSnapshot,
260    updated_at_us: i64,
261) -> Result<(), RuntimeError> {
262    let snapshot_json =
263        serde_json::to_string(snapshot).map_err(|e| sql_err("serialize snapshot", e))?;
264
265    writer
266        .execute(SqlStatement {
267            sql: "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(profile_id, namespace) DO UPDATE SET snapshot_json = excluded.snapshot_json, updated_at = excluded.updated_at".into(),
268            params: vec![
269                SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
270                SqlValue::Text(namespace.to_string()),
271                SqlValue::Text(snapshot_json),
272                SqlValue::Integer(updated_at_us),
273            ],
274            label: Some("brain_snapshot_upsert".into()),
275        })
276        .await
277        .map_err(|e| sql_err("upsert snapshot", e))?;
278
279    Ok(())
280}
281
282pub async fn append_brain_event(
283    sql: &dyn SqlAccess,
284    namespace: &str,
285    profile_id: &str,
286    event_kind: &str,
287    payload: &Value,
288    created_at_us: i64,
289) -> Result<(), RuntimeError> {
290    let mut writer = sql.writer().await.map_err(|e| sql_err("writer", e))?;
291    append_brain_event_on_writer(
292        writer.as_mut(),
293        namespace,
294        profile_id,
295        event_kind,
296        payload,
297        created_at_us,
298    )
299    .await
300}
301
302pub async fn upsert_snapshot(
303    sql: &dyn SqlAccess,
304    namespace: &str,
305    snapshot: &BrainStateSnapshot,
306    updated_at_us: i64,
307) -> Result<(), RuntimeError> {
308    let mut writer = sql.writer().await.map_err(|e| sql_err("writer", e))?;
309    upsert_snapshot_on_writer(writer.as_mut(), namespace, snapshot, updated_at_us).await
310}
311
312/// Descriptor for a single durable `BrainState` mutation (issues #457/#458):
313/// which brain-event-log row to append alongside the snapshot upsert.
314pub struct BrainMutationEvent {
315    pub profile_id: String,
316    pub event_kind: String,
317    pub payload: Value,
318}
319
320/// Apply a `BrainState` mutation with durability as part of the success
321/// contract (issues #457/#458).
322///
323/// `mutate` runs against a *proposed* copy of the state (never the live
324/// `state` mutex) built via a snapshot round-trip. Its result and the
325/// resulting snapshot are then persisted — brain event-log append AND
326/// snapshot upsert — as ONE atomic unit via `SqlAccess::atomic_unit`. Only
327/// after that unit commits does the proposed state replace the live state
328/// and the tracker get marked clean. If `mutate` fails, or the atomic unit
329/// fails to append, upsert, or commit, this returns `Err` and the live state
330/// is left completely untouched — there is no in-memory mutation to roll
331/// back because it was never applied to the shared state in the first
332/// place. See `crates/khive-pack-brain/docs/api/persist.md` for why this
333/// takes `&dyn SqlAccess` and why it uses `atomic_unit` over a manual
334/// transaction.
335pub async fn persist_brain_state_mutation<R>(
336    sql: &dyn SqlAccess,
337    token: &NamespaceToken,
338    tracker: &Mutex<PersistenceTracker>,
339    state: &Mutex<BrainState>,
340    event: BrainMutationEvent,
341    entity_capacity: usize,
342    mutate: impl FnOnce(&mut BrainState) -> Result<R, RuntimeError>,
343) -> Result<R, RuntimeError> {
344    let namespace = token.namespace().as_str().to_string();
345    let now_us = chrono::Utc::now().timestamp_micros();
346
347    let mut proposed = {
348        let current = state.lock().unwrap();
349        BrainState::from_snapshot(current.to_snapshot(), entity_capacity)
350    };
351
352    let result = mutate(&mut proposed)?;
353    let snapshot = proposed.to_snapshot();
354
355    let namespace_for_op = namespace.clone();
356    let profile_id = event.profile_id;
357    let event_kind = event.event_kind;
358    let payload = event.payload;
359    let op: khive_storage::AtomicUnitOp = Box::new(move |writer| {
360        Box::pin(async move {
361            append_brain_event_on_writer(
362                writer,
363                &namespace_for_op,
364                &profile_id,
365                &event_kind,
366                &payload,
367                now_us,
368            )
369            .await
370            .map_err(|e| {
371                khive_storage::StorageError::driver(
372                    khive_storage::StorageCapability::Sql,
373                    "brain_persist_append_event",
374                    e,
375                )
376            })?;
377            upsert_snapshot_on_writer(writer, &namespace_for_op, &snapshot, now_us)
378                .await
379                .map_err(|e| {
380                    khive_storage::StorageError::driver(
381                        khive_storage::StorageCapability::Sql,
382                        "brain_persist_upsert_snapshot",
383                        e,
384                    )
385                })?;
386            Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
387        })
388    });
389
390    sql.atomic_unit(op).await?;
391
392    {
393        let mut live = state.lock().unwrap();
394        *live = proposed;
395    }
396    {
397        let mut t = tracker.lock().unwrap();
398        t.reset_dirty(&namespace);
399        t.mark_loaded(namespace);
400    }
401
402    Ok(result)
403}
404
405pub async fn load_latest_snapshot(
406    sql: &dyn SqlAccess,
407    namespace: &str,
408    entity_capacity: usize,
409) -> Result<Option<(BrainStateSnapshot, i64)>, RuntimeError> {
410    let mut reader = sql.reader().await.map_err(|e| sql_err("reader", e))?;
411    let row = reader
412        .query_row(SqlStatement {
413            sql: "SELECT snapshot_json, updated_at FROM brain_profile_snapshots WHERE profile_id = ?1 AND namespace = ?2 ORDER BY updated_at DESC LIMIT 1".into(),
414            params: vec![
415                SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
416                SqlValue::Text(namespace.to_string()),
417            ],
418            label: Some("brain_snapshot_load".into()),
419        })
420        .await
421        .map_err(|e| sql_err("load snapshot", e))?;
422
423    match row {
424        None => Ok(None),
425        Some(row) => {
426            let json_str = match row.get("snapshot_json") {
427                Some(SqlValue::Text(s)) => s.clone(),
428                _ => return Err(sql_err("load snapshot", "missing snapshot_json column")),
429            };
430            let updated_at = match row.get("updated_at") {
431                Some(SqlValue::Integer(n)) => *n,
432                _ => return Err(sql_err("load snapshot", "missing updated_at column")),
433            };
434            let snapshot: BrainStateSnapshot =
435                serde_json::from_str(&json_str).map_err(|e| sql_err("deserialize snapshot", e))?;
436            validate_brain_state_snapshot_with_capacity(&snapshot, entity_capacity)
437                .map_err(|e| sql_err("snapshot invariant violation", e))?;
438            Ok(Some((snapshot, updated_at)))
439        }
440    }
441}
442
443/// A single row that was quarantined during replay, with enough metadata to
444/// diagnose and re-examine the bad entry without re-running a replay.
445pub struct QuarantinedRow {
446    /// Row primary key from `brain_event_log`.
447    pub id: i64,
448    /// Profile id recorded at write time (may be empty string if the column was null).
449    pub profile_id: String,
450    /// ISO-8601 / epoch-µs created_at value as recorded in the table.
451    pub created_at: i64,
452    /// Human-readable description of why the row was quarantined.
453    pub reason: String,
454    /// Leading ~200 chars of the raw payload for quick inspection (truncated with "…").
455    pub payload_snippet: String,
456}
457
458/// Result of a replay load: valid events and the full quarantine manifest.
459pub struct LoadEventsResult {
460    pub events: Vec<khive_storage::event::Event>,
461    /// Rows that were skipped due to structural or semantic validation failure.
462    /// The physical rows remain in `brain_event_log`; this vec makes them queryable.
463    pub quarantined: Vec<QuarantinedRow>,
464}
465
466impl LoadEventsResult {
467    /// Convenience accessor: number of quarantined rows.
468    pub fn quarantine_count(&self) -> usize {
469        self.quarantined.len()
470    }
471}
472
473pub async fn load_events_since(
474    sql: &dyn SqlAccess,
475    namespace: &str,
476    since_us: i64,
477) -> Result<LoadEventsResult, RuntimeError> {
478    let mut reader = sql.reader().await.map_err(|e| sql_err("reader", e))?;
479    let rows = reader
480        .query_all(SqlStatement {
481            sql: "SELECT id, profile_id, event_kind, payload, created_at \
482                  FROM brain_event_log \
483                  WHERE namespace = ?1 AND created_at > ?2 \
484                  ORDER BY created_at ASC, id ASC"
485                .into(),
486            params: vec![
487                SqlValue::Text(namespace.to_string()),
488                SqlValue::Integer(since_us),
489            ],
490            label: Some("brain_events_replay".into()),
491        })
492        .await
493        .map_err(|e| sql_err("load events", e))?;
494
495    let mut events = Vec::with_capacity(rows.len());
496    let mut quarantined: Vec<QuarantinedRow> = Vec::new();
497
498    for row in &rows {
499        let row_id = match row.get("id") {
500            Some(SqlValue::Integer(i)) => *i,
501            _ => 0,
502        };
503        let profile_id = match row.get("profile_id") {
504            Some(SqlValue::Text(s)) => s.clone(),
505            _ => String::new(),
506        };
507        let created_at = match row.get("created_at") {
508            Some(SqlValue::Integer(i)) => *i,
509            _ => 0,
510        };
511
512        let mut push_quarantine = |reason: String, payload_raw: &str| {
513            let snippet = if payload_raw.len() > 200 {
514                let end = payload_raw.floor_char_boundary(200);
515                format!("{}…", &payload_raw[..end])
516            } else {
517                payload_raw.to_string()
518            };
519            eprintln!(
520                "[brain] event-log replay: quarantined row id={row_id} profile={profile_id:?}: {reason}"
521            );
522            quarantined.push(QuarantinedRow {
523                id: row_id,
524                profile_id: profile_id.clone(),
525                created_at,
526                reason,
527                payload_snippet: snippet,
528            });
529        };
530
531        let payload_str = match row.get("payload") {
532            Some(SqlValue::Text(s)) => s,
533            _ => {
534                push_quarantine("missing or non-text payload column".into(), "");
535                continue;
536            }
537        };
538        let event = match serde_json::from_str::<khive_storage::event::Event>(payload_str) {
539            Ok(ev) => ev,
540            Err(e) => {
541                push_quarantine(format!("malformed event JSON: {e}"), payload_str);
542                continue;
543            }
544        };
545        // Semantic validation: a brain.feedback row with an invalid section_signals
546        // payload must be quarantined whole — before any posterior state mutation.
547        // This is the shared contract with the live brain.feedback handler.
548        if event.verb == "brain.feedback" {
549            if let Some(ss) = event.payload.get("section_signals") {
550                if let Err(e) = crate::validate_section_signals(ss) {
551                    push_quarantine(
552                        format!("semantically invalid section_signals: {e}"),
553                        payload_str,
554                    );
555                    continue;
556                }
557            }
558        }
559        events.push(event);
560    }
561    if !quarantined.is_empty() {
562        eprintln!(
563            "[brain] event-log replay: {} row(s) quarantined out of {} total; \
564             replayed {} clean event(s)",
565            quarantined.len(),
566            rows.len(),
567            events.len()
568        );
569    }
570    Ok(LoadEventsResult {
571        events,
572        quarantined,
573    })
574}
575
576pub async fn ensure_loaded(
577    runtime: &KhiveRuntime,
578    token: &NamespaceToken,
579    tracker: &Mutex<PersistenceTracker>,
580    state: &Mutex<BrainState>,
581    entity_capacity: usize,
582) -> Result<(), RuntimeError> {
583    let namespace = token.namespace().as_str().to_string();
584
585    {
586        let t = tracker.lock().unwrap();
587        if t.is_active(&namespace) {
588            return Ok(());
589        }
590    }
591
592    let already_loaded = {
593        let t = tracker.lock().unwrap();
594        t.is_loaded(&namespace)
595    };
596
597    let brain_state: Option<BrainState> = if already_loaded {
598        None
599    } else {
600        let sql = runtime.sql();
601        let snapshot_result =
602            load_latest_snapshot(sql.as_ref(), &namespace, entity_capacity).await?;
603
604        let bs = if let Some((snapshot, updated_at)) = snapshot_result {
605            let replay_result = load_events_since(sql.as_ref(), &namespace, updated_at).await?;
606
607            let mut bs = BrainState::from_snapshot(snapshot, entity_capacity);
608
609            for event in &replay_result.events {
610                let signal = interpret(event);
611                bs.balanced_recall.apply_signal(&signal);
612
613                let serving_profile = event
614                    .payload
615                    .get("served_by_profile_id")
616                    .and_then(|v| v.as_str())
617                    .unwrap_or("balanced-recall-v1");
618
619                {
620                    let section_state =
621                        crate::ensure_section_state_seeded(&mut bs.section_states, serving_profile);
622                    section_state.apply_signal(&signal);
623                }
624            }
625
626            crate::sync_balanced_recall_record(&mut bs);
627            bs
628        } else {
629            BrainState::new(entity_capacity)
630        };
631        Some(bs)
632    };
633
634    // In test builds, honour the injected interleaving hook (if any).
635    // The hook fires only for cold loads (brain_state.is_some() at this
636    // point) so warm-path and already-loaded early-returns are unaffected.
637    #[cfg(test)]
638    if brain_state.is_some() {
639        let hook = POST_LOAD_HOOK.lock().unwrap().take();
640        if let Some(h) = hook {
641            // Signal the test controller: "I am at the hook."
642            let _ = h.reached_tx.send(());
643            // Wait for the controller to give the go-ahead.
644            let _ = h.proceed_rx.await;
645        }
646    }
647
648    // Lock order: tracker → state (always in this sequence).
649    // Reversing this order anywhere would risk deadlock; do not change.
650    //
651    // Publication is atomic: active_namespace, *state, and loaded_namespaces
652    // are all updated inside a single tracker-held critical section.
653    //
654    // Re-check under the final guard: a concurrent loader may have completed
655    // the same namespace while this task awaited the async DB load.  If so,
656    // discard the stale brain_state we loaded and defer to the live slot.
657    {
658        let mut t = tracker.lock().unwrap();
659
660        // Re-check 1: another loader already published this namespace as active.
661        // The shared state slot already contains the live state; return without
662        // clobbering it with our stale cold-loaded copy.
663        if t.is_active(&namespace) {
664            return Ok(());
665        }
666
667        // Re-check 2: another loader completed a cold load for this namespace
668        // (it is now in saved_states) while this task was awaiting the DB.
669        // Our brain_state was derived from a snapshot that is now stale relative
670        // to any mutations that occurred after that loader published.  Discard
671        // it so the save-restore path below uses the live saved state instead.
672        let fresh_brain_state = if t.is_loaded(&namespace) {
673            None
674        } else {
675            brain_state
676        };
677
678        let current_ns = t.active_namespace.clone();
679
680        // Clone the parts of the current shared state we need for save-restore,
681        // then immediately release the state lock so we can re-acquire it for
682        // the final write (Rust Mutexes are not reentrant).
683        let new_state = {
684            let current_state = state.lock().unwrap();
685
686            if let Some(ref from_ns) = current_ns {
687                let saved_current = BrainState {
688                    profiles: current_state.profiles.clone(),
689                    balanced_recall: khive_brain_core::BalancedRecallState::from_snapshot(
690                        current_state.balanced_recall.to_snapshot(),
691                        entity_capacity,
692                    ),
693                    profile_states: current_state
694                        .profile_states
695                        .iter()
696                        .map(|(k, v)| {
697                            (
698                                k.clone(),
699                                khive_brain_core::BalancedRecallState::from_snapshot(
700                                    v.to_snapshot(),
701                                    entity_capacity,
702                                ),
703                            )
704                        })
705                        .collect(),
706                    bindings: current_state.bindings.clone(),
707                    section_states: current_state
708                        .section_states
709                        .iter()
710                        .map(|(k, v)| {
711                            (
712                                k.clone(),
713                                khive_brain_core::SectionPosteriorState::from_snapshot(
714                                    v.to_snapshot(),
715                                ),
716                            )
717                        })
718                        .collect(),
719                    router_state: current_state.router_state.clone(),
720                    adapter_set: current_state.adapter_set.clone(),
721                };
722                // Release the state guard before mutating the tracker (save-restore
723                // path) so the re-acquire below cannot deadlock.
724                drop(current_state);
725
726                let restored = t.swap_namespace(from_ns, saved_current, namespace.clone());
727                fresh_brain_state
728                    .or(restored)
729                    .unwrap_or_else(|| BrainState::new(entity_capacity))
730            } else {
731                drop(current_state);
732                // No active namespace yet — first load.  active_namespace is set
733                // below together with the state write and loaded_namespaces mark.
734                fresh_brain_state.unwrap_or_else(|| BrainState::new(entity_capacity))
735            }
736        };
737
738        // Drain any hook signals that arrived before this load completed.
739        // These are signals queued by `route_signal` while the namespace was
740        // still cold (no prior `ensure_loaded`).  Applying them here, after the
741        // snapshot + replay path, preserves ordering:
742        //   persisted snapshot → replayed events → queued hook signals
743        let pending = t.drain_pending_signals(&namespace);
744        let mut final_state = new_state;
745        for sig in &pending {
746            final_state.balanced_recall.apply_signal(sig);
747        }
748        if !pending.is_empty() {
749            crate::sync_balanced_recall_record(&mut final_state);
750        }
751
752        // Write the new state while the tracker lock is still held.
753        // After this line active_namespace, *state, and loaded_namespaces are
754        // all consistent; no concurrent dispatch can observe a partial view.
755        *state.lock().unwrap() = final_state;
756
757        // For the no-active-namespace (first-load) path swap_namespace was not
758        // called, so we set active_namespace here before marking loaded.
759        if current_ns.is_none() {
760            t.active_namespace = Some(namespace.clone());
761        }
762
763        t.loaded_namespaces.insert(namespace, ());
764    }
765
766    Ok(())
767}
768
769// ── BRAIN-007: event-log replay quarantine diagnostics ────────────────────────
770
771#[cfg(test)]
772mod brain_007_replay_quarantine {
773    use super::*;
774    use khive_brain_core::BrainState;
775    use khive_runtime::{KhiveRuntime, Namespace};
776    use khive_storage::event::Event;
777    use khive_types::{EventKind, SubstrateKind};
778    use uuid::Uuid;
779
780    async fn insert_raw_payload_at(
781        rt: &KhiveRuntime,
782        namespace: &str,
783        payload: &str,
784        created_at: i64,
785    ) {
786        let sql = rt.sql();
787        let mut writer = sql.writer().await.expect("writer");
788        writer
789            .execute(SqlStatement {
790                sql: "INSERT INTO brain_event_log (profile_id, namespace, event_kind, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5)".into(),
791                params: vec![
792                    SqlValue::Text("test-profile".to_string()),
793                    SqlValue::Text(namespace.to_string()),
794                    SqlValue::Text("brain.feedback".to_string()),
795                    SqlValue::Text(payload.to_string()),
796                    SqlValue::Integer(created_at),
797                ],
798                label: None,
799            })
800            .await
801            .expect("insert raw row");
802    }
803
804    async fn insert_raw_payload(rt: &KhiveRuntime, namespace: &str, payload: &str) {
805        insert_raw_payload_at(rt, namespace, payload, 1_000_000).await;
806    }
807
808    fn make_valid_event_json(namespace: &str) -> String {
809        let ev = Event::new(
810            namespace,
811            "recall",
812            EventKind::Audit,
813            SubstrateKind::Note,
814            "brain",
815        );
816        serde_json::to_string(&ev).expect("serialize event")
817    }
818
819    /// Build a brain.feedback event JSON with optional section_signals payload.
820    fn make_feedback_event_json(
821        namespace: &str,
822        section_signals: Option<serde_json::Value>,
823    ) -> String {
824        let mut ev = Event::new(
825            namespace,
826            "brain.feedback",
827            EventKind::Audit,
828            SubstrateKind::Event,
829            "brain",
830        );
831        ev.target_id = Some(Uuid::new_v4());
832        let mut payload = serde_json::json!({"signal": "useful"});
833        if let Some(ss) = section_signals {
834            payload["section_signals"] = ss;
835        }
836        ev.payload = payload;
837        serde_json::to_string(&ev).expect("serialize feedback event")
838    }
839
840    // ── structural quarantine (pre-existing) ──────────────────────────────────
841
842    #[tokio::test]
843    async fn malformed_json_rows_are_quarantined_not_panicked() {
844        let rt = KhiveRuntime::memory().expect("memory runtime");
845        let token = rt.authorize(Namespace::local()).expect("token");
846        let ns = token.namespace().as_str();
847        let sql = rt.sql();
848
849        // Insert one malformed row (not valid JSON) and one valid serialized Event.
850        insert_raw_payload(&rt, ns, "this is not valid json {{").await;
851        insert_raw_payload(&rt, ns, &make_valid_event_json(ns)).await;
852
853        // load_events_since must return without panicking, quarantining the bad row.
854        let result = load_events_since(sql.as_ref(), ns, 0)
855            .await
856            .expect("load must not fail on malformed rows");
857
858        // Only the valid event should be returned.
859        assert_eq!(
860            result.events.len(),
861            1,
862            "one valid event expected; malformed row must be quarantined, not panic"
863        );
864        assert_eq!(result.quarantine_count(), 1, "quarantine_count must be 1");
865    }
866
867    #[tokio::test]
868    async fn all_malformed_rows_quarantined_returns_empty_vec() {
869        let rt = KhiveRuntime::memory().expect("memory runtime");
870        let token = rt.authorize(Namespace::local()).expect("token");
871        let ns = token.namespace().as_str();
872        let sql = rt.sql();
873
874        insert_raw_payload(&rt, ns, "bad json").await;
875        insert_raw_payload(&rt, ns, "{invalid}").await;
876
877        let result = load_events_since(sql.as_ref(), ns, 0)
878            .await
879            .expect("load must succeed even when all rows are malformed");
880
881        assert!(
882            result.events.is_empty(),
883            "all malformed rows must be quarantined"
884        );
885        assert_eq!(result.quarantine_count(), 2, "quarantine_count must be 2");
886    }
887
888    #[tokio::test]
889    async fn clean_rows_replay_without_quarantine() {
890        let rt = KhiveRuntime::memory().expect("memory runtime");
891        let token = rt.authorize(Namespace::local()).expect("token");
892        let ns = token.namespace().as_str();
893        let sql = rt.sql();
894
895        for _ in 0..3 {
896            insert_raw_payload(&rt, ns, &make_valid_event_json(ns)).await;
897        }
898
899        let result = load_events_since(sql.as_ref(), ns, 0)
900            .await
901            .expect("clean rows must replay without error");
902
903        assert_eq!(result.events.len(), 3, "all 3 clean rows must be returned");
904        assert_eq!(result.quarantine_count(), 0, "quarantine_count must be 0");
905    }
906
907    // ── semantic quarantine (BRAIN-007 new coverage) ──────────────────────────
908
909    #[tokio::test]
910    async fn empty_section_signals_quarantined() {
911        let rt = KhiveRuntime::memory().expect("memory runtime");
912        let token = rt.authorize(Namespace::local()).expect("token");
913        let ns = token.namespace().as_str();
914        let sql = rt.sql();
915
916        // brain.feedback with section_signals: {} must be quarantined whole.
917        let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
918        insert_raw_payload(&rt, ns, &poison).await;
919
920        let result = load_events_since(sql.as_ref(), ns, 0)
921            .await
922            .expect("load must not fail");
923
924        assert!(
925            result.events.is_empty(),
926            "empty section_signals must be quarantined"
927        );
928        assert_eq!(result.quarantine_count(), 1);
929    }
930
931    #[tokio::test]
932    async fn unknown_section_signals_quarantined() {
933        let rt = KhiveRuntime::memory().expect("memory runtime");
934        let token = rt.authorize(Namespace::local()).expect("token");
935        let ns = token.namespace().as_str();
936        let sql = rt.sql();
937
938        // Unknown section key must be quarantined.
939        let poison = make_feedback_event_json(
940            ns,
941            Some(serde_json::json!({"not_a_real_section": "useful"})),
942        );
943        insert_raw_payload(&rt, ns, &poison).await;
944
945        let result = load_events_since(sql.as_ref(), ns, 0)
946            .await
947            .expect("load must not fail");
948
949        assert!(
950            result.events.is_empty(),
951            "unknown section must be quarantined"
952        );
953        assert_eq!(result.quarantine_count(), 1);
954    }
955
956    #[tokio::test]
957    async fn semantic_signal_in_section_signals_quarantined() {
958        let rt = KhiveRuntime::memory().expect("memory runtime");
959        let token = rt.authorize(Namespace::local()).expect("token");
960        let ns = token.namespace().as_str();
961        let sql = rt.sql();
962
963        // Section fold only accepts useful|not_useful|wrong; semantic event kinds
964        // (explicit_positive, correction, …) must be quarantined.
965        let poison = make_feedback_event_json(
966            ns,
967            Some(serde_json::json!({"overview": "explicit_positive"})),
968        );
969        insert_raw_payload(&rt, ns, &poison).await;
970
971        let result = load_events_since(sql.as_ref(), ns, 0)
972            .await
973            .expect("load must not fail");
974
975        assert!(
976            result.events.is_empty(),
977            "semantic signal in section_signals must be quarantined"
978        );
979        assert_eq!(result.quarantine_count(), 1);
980    }
981
982    // ── state isolation: bad rows must not advance posterior state ────────────
983
984    /// Seed a snapshot, insert bad rows at FIRST / LAST / interleaved positions,
985    /// then call the real ensure_loaded path and assert posterior state is unchanged.
986    async fn seed_snapshot(rt: &KhiveRuntime, namespace: &str) -> BrainStateSnapshot {
987        let state = BrainState::new(16);
988        let snapshot = state.to_snapshot();
989        let sql = rt.sql();
990        upsert_snapshot(sql.as_ref(), namespace, &snapshot, 500_000)
991            .await
992            .expect("seed snapshot");
993        snapshot
994    }
995
996    /// Assert that section posteriors and epoch are at the initial (baseline) values,
997    /// meaning no section-fold mutation occurred from any replayed event.
998    /// Does NOT assert balanced_recall state — clean recall/search events legitimately
999    /// advance that without touching section state.
1000    fn assert_section_posteriors_at_baseline(state: &BrainState, baseline: &BrainState) {
1001        for key in state.section_states.keys() {
1002            let s = &state.section_states[key];
1003            let b = &baseline.section_states[key];
1004            assert_eq!(
1005                s.total_events, b.total_events,
1006                "section_states[{key}].total_events changed; bad row must not advance section state"
1007            );
1008            assert_eq!(
1009                s.exploration_epoch, b.exploration_epoch,
1010                "section_states[{key}].exploration_epoch changed; bad row must not advance section state"
1011            );
1012            for (st, p) in &s.posteriors {
1013                let bp = &b.posteriors[st];
1014                assert!(
1015                    (p.alpha() - bp.alpha()).abs() < 1e-12
1016                        && (p.beta() - bp.beta()).abs() < 1e-12,
1017                    "section posterior for {:?} changed: got alpha={} beta={}, expected alpha={} beta={}; \
1018                     bad row must not mutate posteriors",
1019                    st, p.alpha(), p.beta(), bp.alpha(), bp.beta()
1020                );
1021            }
1022        }
1023    }
1024
1025    #[tokio::test]
1026    async fn bad_row_first_does_not_mutate_posterior_state() {
1027        let rt = KhiveRuntime::memory().expect("memory runtime");
1028        let token = rt.authorize(Namespace::local()).expect("token");
1029        let ns = token.namespace().as_str();
1030
1031        seed_snapshot(&rt, ns).await;
1032
1033        // Row 1 (bad — semantic poison): created_at=600_000 (after snapshot at 500_000)
1034        let poison =
1035            make_feedback_event_json(ns, Some(serde_json::json!({"overview": "correction"})));
1036        insert_raw_payload_at(&rt, ns, &poison, 600_001).await;
1037
1038        // Row 2 (clean recall event): created_at=600_002
1039        insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_002).await;
1040
1041        // Use load_events_since directly and replay manually to assert isolation.
1042        let sql = rt.sql();
1043        let result = load_events_since(sql.as_ref(), ns, 500_000)
1044            .await
1045            .expect("load must not fail");
1046
1047        assert_eq!(
1048            result.quarantine_count(),
1049            1,
1050            "bad first row must be quarantined"
1051        );
1052        assert_eq!(result.events.len(), 1, "one clean event must pass through");
1053
1054        // Apply the clean events to a fresh state and confirm section state is at baseline.
1055        let baseline = BrainState::new(16);
1056        let mut state = BrainState::new(16);
1057        for event in &result.events {
1058            let signal = crate::event::interpret(event);
1059            state.balanced_recall.apply_signal(&signal);
1060            for section_state in state.section_states.values_mut() {
1061                section_state.apply_signal(&signal);
1062            }
1063        }
1064        // The single clean event is a recall, not a feedback; section state unchanged.
1065        assert_section_posteriors_at_baseline(&state, &baseline);
1066    }
1067
1068    #[tokio::test]
1069    async fn bad_row_last_does_not_mutate_posterior_state() {
1070        let rt = KhiveRuntime::memory().expect("memory runtime");
1071        let token = rt.authorize(Namespace::local()).expect("token");
1072        let ns = token.namespace().as_str();
1073
1074        seed_snapshot(&rt, ns).await;
1075
1076        // Row 1 (clean): created_at=600_001
1077        insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_001).await;
1078        // Row 2 (bad — empty section_signals): created_at=600_002
1079        let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
1080        insert_raw_payload_at(&rt, ns, &poison, 600_002).await;
1081
1082        let sql = rt.sql();
1083        let result = load_events_since(sql.as_ref(), ns, 500_000)
1084            .await
1085            .expect("load must not fail");
1086
1087        assert_eq!(
1088            result.quarantine_count(),
1089            1,
1090            "bad last row must be quarantined"
1091        );
1092        assert_eq!(result.events.len(), 1, "one clean event must pass through");
1093
1094        let baseline = BrainState::new(16);
1095        let mut state = BrainState::new(16);
1096        for event in &result.events {
1097            let signal = crate::event::interpret(event);
1098            state.balanced_recall.apply_signal(&signal);
1099            for section_state in state.section_states.values_mut() {
1100                section_state.apply_signal(&signal);
1101            }
1102        }
1103        assert_section_posteriors_at_baseline(&state, &baseline);
1104    }
1105
1106    #[tokio::test]
1107    async fn bad_rows_interleaved_do_not_mutate_posterior_state() {
1108        let rt = KhiveRuntime::memory().expect("memory runtime");
1109        let token = rt.authorize(Namespace::local()).expect("token");
1110        let ns = token.namespace().as_str();
1111
1112        seed_snapshot(&rt, ns).await;
1113
1114        // clean, bad, clean, bad, clean
1115        insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_001).await;
1116        let p1 = make_feedback_event_json(
1117            ns,
1118            Some(serde_json::json!({"overview": "explicit_negative"})),
1119        );
1120        insert_raw_payload_at(&rt, ns, &p1, 600_002).await;
1121        insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_003).await;
1122        let p2 = make_feedback_event_json(ns, Some(serde_json::json!({})));
1123        insert_raw_payload_at(&rt, ns, &p2, 600_004).await;
1124        insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_005).await;
1125
1126        let sql = rt.sql();
1127        let result = load_events_since(sql.as_ref(), ns, 500_000)
1128            .await
1129            .expect("load must not fail");
1130
1131        assert_eq!(
1132            result.quarantine_count(),
1133            2,
1134            "2 bad rows must be quarantined"
1135        );
1136        assert_eq!(result.events.len(), 3, "3 clean rows must pass through");
1137
1138        // Apply only clean events; section posteriors must stay at baseline.
1139        let baseline = BrainState::new(16);
1140        let mut state = BrainState::new(16);
1141        for event in &result.events {
1142            let signal = crate::event::interpret(event);
1143            state.balanced_recall.apply_signal(&signal);
1144            for section_state in state.section_states.values_mut() {
1145                section_state.apply_signal(&signal);
1146            }
1147        }
1148        assert_section_posteriors_at_baseline(&state, &baseline);
1149    }
1150
1151    // ── quarantine metadata: id and reason must be returned, not just counted ──
1152
1153    #[tokio::test]
1154    async fn quarantined_rows_return_id_and_reason() {
1155        let rt = KhiveRuntime::memory().expect("memory runtime");
1156        let token = rt.authorize(Namespace::local()).expect("token");
1157        let ns = token.namespace().as_str();
1158        let sql = rt.sql();
1159
1160        // Two bad rows: one malformed JSON, one semantic poison.
1161        insert_raw_payload(&rt, ns, "not json at all").await;
1162        let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
1163        insert_raw_payload(&rt, ns, &poison).await;
1164
1165        let result = load_events_since(sql.as_ref(), ns, 0)
1166            .await
1167            .expect("load must not fail");
1168
1169        assert_eq!(
1170            result.quarantine_count(),
1171            2,
1172            "both bad rows must be quarantined"
1173        );
1174        assert!(result.events.is_empty(), "no clean events expected");
1175
1176        // Each quarantined entry must carry a non-zero id, non-empty reason,
1177        // correct profile_id, non-zero created_at, and non-empty payload_snippet.
1178        for qr in &result.quarantined {
1179            assert!(
1180                qr.id > 0,
1181                "quarantined row id must be a real autoincrement id; got {}",
1182                qr.id
1183            );
1184            assert!(
1185                !qr.reason.is_empty(),
1186                "quarantined row must carry a non-empty reason string"
1187            );
1188            assert_eq!(
1189                qr.profile_id, "test-profile",
1190                "quarantined row profile_id must match inserted value; got {:?}",
1191                qr.profile_id
1192            );
1193            assert!(
1194                qr.created_at > 0,
1195                "quarantined row created_at must be non-zero; got {}",
1196                qr.created_at
1197            );
1198            assert!(
1199                !qr.payload_snippet.is_empty(),
1200                "quarantined row payload_snippet must be non-empty"
1201            );
1202        }
1203
1204        // First row: malformed JSON — reason must mention JSON or malformed.
1205        assert!(
1206            result.quarantined[0].reason.contains("malformed")
1207                || result.quarantined[0].reason.contains("JSON")
1208                || result.quarantined[0].reason.contains("json"),
1209            "first quarantine reason must describe malformed JSON; got: {:?}",
1210            result.quarantined[0].reason
1211        );
1212        // First row payload_snippet must contain a recognizable fragment of the bad payload.
1213        assert!(
1214            result.quarantined[0].payload_snippet.contains("not json"),
1215            "first row snippet must contain 'not json'; got: {:?}",
1216            result.quarantined[0].payload_snippet
1217        );
1218
1219        // Second row: semantic poison (empty section_signals) — reason must mention section_signals.
1220        assert!(
1221            result.quarantined[1].reason.contains("section_signals"),
1222            "second quarantine reason must mention section_signals; got: {:?}",
1223            result.quarantined[1].reason
1224        );
1225        // Second row payload_snippet must be non-empty (it's valid JSON, so it has content).
1226        assert!(
1227            !result.quarantined[1].payload_snippet.is_empty(),
1228            "second row payload_snippet must be non-empty; got: {:?}",
1229            result.quarantined[1].payload_snippet
1230        );
1231    }
1232
1233    // ── char-boundary safety: multibyte payload does not panic ───────────────
1234
1235    #[tokio::test]
1236    async fn payload_snippet_truncation_safe_on_multibyte_chars() {
1237        let rt = KhiveRuntime::memory().expect("memory runtime");
1238        let token = rt.authorize(Namespace::local()).expect("token");
1239        let ns = token.namespace().as_str();
1240        let sql = rt.sql();
1241
1242        // '日' is 3 bytes in UTF-8; 250 repetitions = 750 bytes, well over the 200-byte limit.
1243        // A naive &s[..200] would land mid-char and panic.
1244        let long_multibyte: String = "日".repeat(250);
1245        insert_raw_payload(&rt, ns, &long_multibyte).await;
1246
1247        // Must not panic.
1248        let result = load_events_since(sql.as_ref(), ns, 0)
1249            .await
1250            .expect("load must not fail");
1251
1252        assert_eq!(result.quarantine_count(), 1);
1253        let qr = &result.quarantined[0];
1254
1255        // Snippet must be valid UTF-8 (Rust strings are always valid UTF-8, but
1256        // verify it is non-empty and ≤ 200 bytes as a byte-length check).
1257        assert!(
1258            !qr.payload_snippet.is_empty(),
1259            "snippet must be non-empty for a non-empty payload"
1260        );
1261        // The trailing ellipsis is a multi-byte char itself; strip it before measuring.
1262        let snippet_body = qr.payload_snippet.trim_end_matches('…');
1263        assert!(
1264            snippet_body.len() <= 200,
1265            "snippet body (sans ellipsis) must be ≤200 bytes; got {} bytes",
1266            snippet_body.len()
1267        );
1268        // The snippet body must itself be valid UTF-8 (Rust ensures this, but also
1269        // assert it only contains the expected character).
1270        assert!(
1271            snippet_body.chars().all(|c| c == '日'),
1272            "snippet body must contain only '日' chars; got: {:?}",
1273            snippet_body
1274        );
1275    }
1276}
1277
1278// ── BRAINCORE-AUD-001: entity-posterior cache-capacity enforcement on load ────
1279
1280#[cfg(test)]
1281mod braincore_aud_001_capacity {
1282    use uuid::Uuid;
1283
1284    use super::*;
1285    use khive_brain_core::BetaPosterior;
1286    use khive_runtime::{KhiveRuntime, Namespace};
1287
1288    /// Oversized persisted snapshot restore is bounded/rejected: a snapshot
1289    /// with more entity posteriors than `ENTITY_CACHE_CAPACITY` must fail
1290    /// capacity-aware validation at the load boundary rather than silently
1291    /// restoring past the configured bound.
1292    #[tokio::test]
1293    async fn oversized_brain_snapshot_load_returns_runtime_error() {
1294        let rt = KhiveRuntime::memory().expect("memory runtime");
1295        let token = rt.authorize(Namespace::local()).expect("token");
1296        let ns = token.namespace().as_str();
1297        let sql = rt.sql();
1298
1299        let capacity = 2;
1300        let mut state = BrainState::new(capacity);
1301        for _ in 0..capacity {
1302            state
1303                .balanced_recall
1304                .entity_posteriors
1305                .get_or_insert(Uuid::new_v4(), BetaPosterior::default);
1306        }
1307        let mut snapshot = state.to_snapshot();
1308        // Force one entry past the configured capacity — simulates a crafted
1309        // or legacy oversized snapshot that live inserts could never produce.
1310        snapshot
1311            .balanced_recall
1312            .entity_posteriors
1313            .insert(Uuid::new_v4(), BetaPosterior::default());
1314
1315        upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1316            .await
1317            .expect("seed oversized snapshot");
1318
1319        let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1320        assert!(
1321            result.is_err(),
1322            "snapshot with entity_posteriors.len() > capacity must be rejected at load"
1323        );
1324        let err = result.unwrap_err().to_string();
1325        assert!(
1326            err.contains("snapshot invariant violation"),
1327            "error must name the invariant-violation load boundary, got: {err}"
1328        );
1329    }
1330
1331    /// A snapshot within the configured capacity restores without error.
1332    #[tokio::test]
1333    async fn in_capacity_brain_snapshot_load_succeeds() {
1334        let rt = KhiveRuntime::memory().expect("memory runtime");
1335        let token = rt.authorize(Namespace::local()).expect("token");
1336        let ns = token.namespace().as_str();
1337        let sql = rt.sql();
1338
1339        let capacity = 2;
1340        let state = BrainState::new(capacity);
1341        let snapshot = state.to_snapshot();
1342        upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1343            .await
1344            .expect("seed snapshot");
1345
1346        let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1347        assert!(result.is_ok(), "in-capacity snapshot must load cleanly");
1348    }
1349
1350    /// PR #535: a persisted snapshot with `{A, B}`
1351    /// entity_posteriors, `entity_posterior_order = [A]`, and an EXPLICIT
1352    /// `entity_posteriors_version = 0` must be rejected at the
1353    /// `load_latest_snapshot` boundary rather than silently normalized by
1354    /// `restore` to `[A, B]`. Version 0 with a non-empty order is not the
1355    /// legacy empty-order compatibility case.
1356    #[tokio::test]
1357    async fn version_zero_snapshot_with_explicit_partial_order_rejected_at_load() {
1358        let rt = KhiveRuntime::memory().expect("memory runtime");
1359        let token = rt.authorize(Namespace::local()).expect("token");
1360        let ns = token.namespace().as_str();
1361        let sql = rt.sql();
1362
1363        let capacity = 10;
1364        let mut state = BrainState::new(capacity);
1365        let a = Uuid::new_v4();
1366        let b = Uuid::new_v4();
1367        state
1368            .balanced_recall
1369            .entity_posteriors
1370            .get_or_insert(a, BetaPosterior::default);
1371        state
1372            .balanced_recall
1373            .entity_posteriors
1374            .get_or_insert(b, BetaPosterior::default);
1375
1376        let mut snapshot = state.to_snapshot();
1377        snapshot.balanced_recall.entity_posteriors_version = 0;
1378        snapshot.balanced_recall.entity_posterior_order = vec![a];
1379
1380        upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1381            .await
1382            .expect("seed corrupt version-0 snapshot");
1383
1384        let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1385        assert!(
1386            result.is_err(),
1387            "version-0 snapshot with non-empty partial order must be rejected at load, not normalized"
1388        );
1389        let err = result.unwrap_err().to_string();
1390        assert!(
1391            err.contains("snapshot invariant violation"),
1392            "error must name the load-boundary invariant violation, got: {err}"
1393        );
1394    }
1395
1396    /// Same corruption as above, but with `entity_posteriors_version` OMITTED
1397    /// from the stored JSON entirely (rather than set to an explicit `0`) —
1398    /// the `#[serde(default)]` path that made this reachable in the first
1399    /// place. Inserted as a raw row (bypassing the typed `upsert_snapshot`,
1400    /// which always serializes the full struct) to reproduce exactly what a
1401    /// pre-versioning writer, or a hand-crafted snapshot, would have stored.
1402    #[tokio::test]
1403    async fn version_omitted_snapshot_with_partial_order_rejected_at_load() {
1404        let rt = KhiveRuntime::memory().expect("memory runtime");
1405        let token = rt.authorize(Namespace::local()).expect("token");
1406        let ns = token.namespace().as_str();
1407        let sql = rt.sql();
1408
1409        let capacity = 10;
1410        let mut state = BrainState::new(capacity);
1411        let a = Uuid::new_v4();
1412        let b = Uuid::new_v4();
1413        state
1414            .balanced_recall
1415            .entity_posteriors
1416            .get_or_insert(a, BetaPosterior::default);
1417        state
1418            .balanced_recall
1419            .entity_posteriors
1420            .get_or_insert(b, BetaPosterior::default);
1421
1422        let mut snapshot = state.to_snapshot();
1423        snapshot.balanced_recall.entity_posterior_order = vec![a];
1424
1425        let mut json = serde_json::to_value(&snapshot).expect("serialize snapshot");
1426        json["balanced_recall"]
1427            .as_object_mut()
1428            .expect("balanced_recall is an object")
1429            .remove("entity_posteriors_version");
1430        let json_str = serde_json::to_string(&json).expect("re-serialize snapshot");
1431
1432        // Round-trip through the typed struct to confirm the omitted field
1433        // really does serde-default to 0 before we bypass the typed insert
1434        // path — this is the precondition the fix closes.
1435        let reparsed: BrainStateSnapshot =
1436            serde_json::from_str(&json_str).expect("deserialize snapshot with omitted version");
1437        assert_eq!(
1438            reparsed.balanced_recall.entity_posteriors_version, 0,
1439            "omitted entity_posteriors_version must serde-default to 0"
1440        );
1441
1442        let mut writer = sql.writer().await.expect("writer");
1443        writer
1444            .execute(SqlStatement {
1445                sql: "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) VALUES (?1, ?2, ?3, ?4)".into(),
1446                params: vec![
1447                    SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
1448                    SqlValue::Text(ns.to_string()),
1449                    SqlValue::Text(json_str),
1450                    SqlValue::Integer(500_000),
1451                ],
1452                label: None,
1453            })
1454            .await
1455            .expect("insert raw snapshot row with omitted version");
1456        drop(writer);
1457
1458        let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1459        assert!(
1460            result.is_err(),
1461            "snapshot with omitted entity_posteriors_version and non-empty partial order must be rejected at load"
1462        );
1463        let err = result.unwrap_err().to_string();
1464        assert!(
1465            err.contains("snapshot invariant violation"),
1466            "error must name the load-boundary invariant violation, got: {err}"
1467        );
1468    }
1469}
1470
1471// ── ADR-067 Fork C slice 2: persist_brain_state_mutation
1472// write-queue routing ──────────────────────────────────────────────────────
1473
1474#[cfg(test)]
1475mod persist_write_queue_routing {
1476    use super::*;
1477    use khive_brain_core::BrainState;
1478    use khive_runtime::{KhiveRuntime, Namespace};
1479
1480    /// Proves `persist_brain_state_mutation` is actually enqueued on the
1481    /// pool's shared `WriterTaskHandle` channel when the write queue is
1482    /// enabled, mirroring `fold_gate.rs`'s equivalent routing test (same
1483    /// `queue_depth` + occupier-parked-on-oneshot technique; see that test's
1484    /// doc comment for why a wall-clock test can't distinguish this).
1485    #[tokio::test]
1486    async fn persist_brain_state_mutation_routes_through_writer_task_when_flag_enabled() {
1487        // Token minted from an in-memory runtime: never touches the write
1488        // queue / env var, used only for its `NamespaceToken`.
1489        let token_rt = KhiveRuntime::memory().expect("memory runtime for token minting");
1490        let token = token_rt
1491            .authorize(Namespace::local())
1492            .expect("authorize local namespace");
1493
1494        // The actual storage this test exercises: a bare, file-backed,
1495        // write-queue-enabled pool/bridge — no KhiveRuntime, no env var.
1496        let dir = tempfile::tempdir().expect("tempdir");
1497        let db_path = dir.path().join("persist-write-queue-routing.db");
1498        let pool_cfg = khive_db::PoolConfig {
1499            path: Some(db_path),
1500            write_queue_enabled: true,
1501            ..khive_db::PoolConfig::default()
1502        };
1503        let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
1504        {
1505            let mut writer = pool.writer().expect("writer");
1506            khive_db::run_migrations(writer.conn_mut()).expect("migrations");
1507        }
1508        let sql: std::sync::Arc<dyn SqlAccess> =
1509            std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
1510
1511        let writer_task = pool
1512            .writer_task_handle()
1513            .unwrap()
1514            .expect("writer task must be spawned with the flag on for a file-backed pool");
1515
1516        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1517        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1518        let occupier = {
1519            let writer_task = writer_task.clone();
1520            tokio::spawn(async move {
1521                writer_task
1522                    .send(move |_conn| {
1523                        let _ = started_tx.send(());
1524                        let _ = release_rx.blocking_recv();
1525                        Ok::<(), khive_storage::StorageError>(())
1526                    })
1527                    .await
1528            })
1529        };
1530
1531        started_rx
1532            .await
1533            .expect("occupier must signal it has started running inside the writer task");
1534        assert_eq!(
1535            writer_task.queue_depth(),
1536            0,
1537            "channel must start empty once the occupier has been dequeued and is running"
1538        );
1539
1540        let tracker: Mutex<PersistenceTracker> = Mutex::new(PersistenceTracker::new());
1541        let state: Mutex<BrainState> = Mutex::new(BrainState::new(16));
1542
1543        let persist_task = tokio::spawn(async move {
1544            persist_brain_state_mutation(
1545                sql.as_ref(),
1546                &token,
1547                &tracker,
1548                &state,
1549                BrainMutationEvent {
1550                    profile_id: "write-queue-routing-profile".to_string(),
1551                    event_kind: "brain.test_mutation".to_string(),
1552                    payload: serde_json::json!({"probe": true}),
1553                },
1554                16,
1555                |_state: &mut BrainState| -> Result<(), RuntimeError> { Ok(()) },
1556            )
1557            .await
1558        });
1559
1560        let mut saw_enqueued = false;
1561        for _ in 0..100 {
1562            if writer_task.queue_depth() >= 1 {
1563                saw_enqueued = true;
1564                break;
1565            }
1566            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1567        }
1568        assert!(
1569            saw_enqueued,
1570            "persist_brain_state_mutation's atomic_unit request never appeared in the \
1571             writer task's channel while the occupier held the single drain slot — \
1572             atomic_unit is not routing this call through the shared writer task"
1573        );
1574
1575        release_tx
1576            .send(())
1577            .expect("occupier must still be waiting on the release signal");
1578        occupier
1579            .await
1580            .expect("occupier task must not panic")
1581            .expect("occupier write must succeed");
1582
1583        persist_task
1584            .await
1585            .expect("persist_task must not panic")
1586            .expect("persist_brain_state_mutation must succeed once unblocked");
1587    }
1588}