Skip to main content

car_state/
lib.rs

1//! State management for Common Agent Runtime.
2//!
3//! Provides structured, typed state with transition logging.
4//! Every mutation produces a StateTransition record for audit and replay.
5//!
6//! ## Persistence (Parslee-ai/car#181)
7//!
8//! `StateStore::durable(path)` opens a JSONL-backed store. Each
9//! mutation appends a transition line; on construction the file is
10//! replayed to rebuild current state. This is the agent-persistence
11//! pattern documented in `docs/persistence.md`. JSONL was chosen over
12//! sqlite/sled to stay aligned with the existing JSONL persistence
13//! used by `car-eventlog` and `car-memgine` — one file shape, one
14//! reap+compact story, no native build deps.
15//!
16//! Per-key TTL is supported via `set_with_ttl` — the in-memory state
17//! drops the key when `reap_expired(now)` runs after the deadline.
18//! The on-disk file is compacted at the same time so the journal
19//! doesn't grow unbounded.
20
21pub mod crdt;
22
23use chrono::{DateTime, Duration, Utc};
24use parking_lot::{Mutex, MutexGuard};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::ffi::OsString;
29use std::fs::{File, OpenOptions};
30use std::io::{BufRead, BufReader, BufWriter, Write};
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::Arc;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36/// Durability reached by a persistence-aware state restore.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RestoreDurability {
39    /// The replacement journal and its parent directory were fsynced.
40    Durable,
41    /// The replacement journal is visible and memory adopted it, but the
42    /// parent-directory metadata flush failed, so crash persistence is unknown.
43    DurabilityUnknown { error: String },
44}
45
46/// Deterministic durability fault seam for restore integration tests.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum RestoreFailurePoint {
49    BeforePublication,
50    ParentDirectorySync,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct RestoreFailureInjector {
55    failures: Arc<std::sync::Mutex<Vec<RestoreFailurePoint>>>,
56}
57
58impl RestoreFailureInjector {
59    pub fn fail_next(&self, point: RestoreFailurePoint) {
60        self.failures
61            .lock()
62            .expect("restore failure injector lock poisoned")
63            .push(point);
64    }
65
66    fn check(&self, point: RestoreFailurePoint) -> std::io::Result<()> {
67        let mut failures = self
68            .failures
69            .lock()
70            .map_err(|_| std::io::Error::other("restore failure injector lock poisoned"))?;
71        if failures.first() == Some(&point) {
72            failures.remove(0);
73            let message = match point {
74                RestoreFailurePoint::BeforePublication => {
75                    "injected restore failure before publication"
76                }
77                RestoreFailurePoint::ParentDirectorySync => {
78                    "injected restore parent directory sync failure"
79                }
80            };
81            return Err(std::io::Error::other(message));
82        }
83        Ok(())
84    }
85}
86
87/// An explicit record of a state change.
88///
89/// `ttl_secs` is optional — when present, the key expires `ttl_secs`
90/// seconds after `timestamp`. Reads return the value while it's
91/// live; `reap_expired` drops it after the deadline. The default
92/// (None) means "keep until explicitly deleted."
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct StateTransition {
95    pub key: String,
96    pub old_value: Option<Value>,
97    pub new_value: Option<Value>,
98    pub action_id: String,
99    pub timestamp: DateTime<Utc>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub ttl_secs: Option<u64>,
102    /// The key's monotonic version *after* this transition. Persisted so
103    /// the version counter survives journal compaction and restart — a
104    /// compacted journal collapses a key's history to one line, so without
105    /// this field replay would recount from 1 and break the staleness
106    /// guarantee (neo review M2). Optional for backward-compatible reads of
107    /// pre-versioning journals.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub version: Option<u64>,
110}
111
112/// What `replay_journal` found at the end of the file, and what `durable`
113/// must do about it before opening the append writer. An interrupted append
114/// can leave the final line unterminated; resuming appends on that tail
115/// would merge two records into one malformed line that the NEXT reopen
116/// drops whole (Parslee-ai/car#1140).
117enum TailRepair {
118    /// File ends cleanly (or doesn't exist).
119    None,
120    /// The final record is complete JSON but its newline is missing — it
121    /// was replayed; write the terminator so the next append starts fresh.
122    Terminate,
123    /// The final line is a torn prefix — it was skipped; truncate the file
124    /// to this byte offset so the next append cannot merge with it.
125    TruncateTo(u64),
126}
127
128/// One journal line holding every transition of a single atomic batch
129/// mutation ([`StateStore::set_batch`]). A multi-key batch is journaled as
130/// one line so an interrupted append can never persist a prefix of the
131/// batch: replay applies the complete record, or — when the line is torn —
132/// skips it whole via the existing malformed-line path. Old-or-complete,
133/// never partial (Parslee-ai/car#1140). Single-entry batches keep the
134/// legacy bare-`StateTransition` line shape, so a journal only contains
135/// this record where a multi-key batch actually occurred.
136#[derive(Debug, Deserialize)]
137struct BatchTransitionRecord {
138    batch: Vec<StateTransition>,
139}
140
141/// Borrowed serialization twin of [`BatchTransitionRecord`] — writes the
142/// same `{"batch": [...]}` shape without cloning the transitions.
143#[derive(Serialize)]
144struct BatchTransitionRecordRef<'a> {
145    batch: &'a [StateTransition],
146}
147
148/// Thread-safe state store with transition logging.
149///
150/// All reads and writes go through this store. Every write produces a
151/// StateTransition record for audit and replay. Optionally backed by
152/// a JSONL journal file for durability across process restarts (see
153/// [`StateStore::durable`]).
154pub struct StateStore {
155    /// Serializes proposal transactions across every Runtime sharing this
156    /// store. State transitions carry action ids for compatibility, so the
157    /// shared store—not an individual Runtime—is the safe boundary that keeps
158    /// reused ids from cross-attributing concurrent proposals.
159    proposal_execution: tokio::sync::Mutex<()>,
160    state: Mutex<HashMap<String, Value>>,
161    transitions: Mutex<Vec<StateTransition>>,
162    /// Monotonic per-key version counter, bumped on every write/delete.
163    /// The basis for transactional staleness detection (survey §5.2.4):
164    /// an action that read `k` at version `v` can be flagged when `k` has
165    /// since advanced past `v`, catching belief divergence that a value
166    /// comparison alone would miss (e.g. set back to the same value).
167    versions: Mutex<HashMap<String, u64>>,
168    /// Optional JSONL-backed durability layer. When set, every
169    /// `StateTransition` appended to the in-memory log is also
170    /// appended to this file's open writer; `reap_expired` rewrites
171    /// the file to compact away dropped keys.
172    journal: Mutex<Option<Journal>>,
173    restore_failures: Option<RestoreFailureInjector>,
174    #[cfg(test)]
175    mutation_before_state_lock: Option<Arc<MutationRaceBarrier>>,
176    /// Test seam: pause a [`Self::set_batch`] after its first entry is
177    /// applied, with the state lock still held — lets a test prove a
178    /// concurrent reader blocks rather than observing a batch prefix.
179    #[cfg(test)]
180    batch_mid_apply: Option<Arc<MutationRaceBarrier>>,
181}
182
183#[cfg(test)]
184struct MutationRaceBarrier {
185    reached: std::sync::Barrier,
186    release: std::sync::Barrier,
187}
188
189#[cfg(test)]
190impl MutationRaceBarrier {
191    fn new() -> Self {
192        Self {
193            reached: std::sync::Barrier::new(2),
194            release: std::sync::Barrier::new(2),
195        }
196    }
197
198    fn pause_mutation(&self) {
199        self.reached.wait();
200        self.release.wait();
201    }
202}
203
204struct Journal {
205    path: PathBuf,
206    writer: Option<BufWriter<File>>,
207    pending_parent_sync: Option<String>,
208}
209
210impl Journal {
211    fn reopen_writer(&mut self) -> std::io::Result<()> {
212        let file = OpenOptions::new().append(true).open(&self.path)?;
213        self.writer = Some(BufWriter::new(file));
214        Ok(())
215    }
216
217    fn writer_mut(&mut self) -> std::io::Result<&mut BufWriter<File>> {
218        if self.writer.is_none() {
219            self.reopen_writer()?;
220        }
221        self.writer
222            .as_mut()
223            .ok_or_else(|| std::io::Error::other("state journal writer is unavailable"))
224    }
225}
226
227const REPLACEMENT_TEMP_ATTEMPTS: usize = 32;
228static NEXT_REPLACEMENT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
229
230struct TempFileCleanup {
231    path: PathBuf,
232    armed: bool,
233}
234
235impl TempFileCleanup {
236    fn new(path: PathBuf) -> Self {
237        Self { path, armed: true }
238    }
239
240    fn disarm(&mut self) {
241        self.armed = false;
242    }
243}
244
245impl Drop for TempFileCleanup {
246    fn drop(&mut self) {
247        if self.armed {
248            let _ = std::fs::remove_file(&self.path);
249        }
250    }
251}
252
253fn create_replacement_temp(destination: &Path) -> std::io::Result<(PathBuf, File)> {
254    let parent = destination.parent().unwrap_or_else(|| Path::new("."));
255    let file_name = destination.file_name().ok_or_else(|| {
256        std::io::Error::new(
257            std::io::ErrorKind::InvalidInput,
258            "state journal path has no file name",
259        )
260    })?;
261    let timestamp = SystemTime::now()
262        .duration_since(UNIX_EPOCH)
263        .unwrap_or_default()
264        .as_nanos();
265
266    for _ in 0..REPLACEMENT_TEMP_ATTEMPTS {
267        let id = NEXT_REPLACEMENT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
268        let mut temp_name = OsString::from(".");
269        temp_name.push(file_name);
270        temp_name.push(format!(
271            ".restore.{}.{}.{}.tmp",
272            std::process::id(),
273            timestamp,
274            id
275        ));
276        let temp_path = parent.join(temp_name);
277        let mut options = OpenOptions::new();
278        options.write(true).create_new(true);
279        #[cfg(unix)]
280        {
281            use std::os::unix::fs::OpenOptionsExt;
282            options.mode(0o600);
283        }
284        match options.open(&temp_path) {
285            Ok(file) => return Ok((temp_path, file)),
286            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
287            Err(error) => return Err(error),
288        }
289    }
290
291    Err(std::io::Error::new(
292        std::io::ErrorKind::AlreadyExists,
293        format!(
294            "could not allocate a unique state journal replacement after {REPLACEMENT_TEMP_ATTEMPTS} attempts"
295        ),
296    ))
297}
298
299#[cfg(unix)]
300fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
301    std::fs::rename(temp, destination)
302}
303
304#[cfg(target_os = "windows")]
305fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
306    use std::os::windows::ffi::OsStrExt;
307    use windows::core::PCWSTR;
308    use windows::Win32::Storage::FileSystem::{
309        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
310    };
311
312    let temp: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
313    let destination: Vec<u16> = destination
314        .as_os_str()
315        .encode_wide()
316        .chain(Some(0))
317        .collect();
318    unsafe {
319        MoveFileExW(
320            PCWSTR(temp.as_ptr()),
321            PCWSTR(destination.as_ptr()),
322            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
323        )
324    }
325    .map_err(|error| std::io::Error::other(error.to_string()))
326}
327
328#[cfg(not(any(unix, target_os = "windows")))]
329fn replace_file_atomically(temp: &Path, destination: &Path) -> std::io::Result<()> {
330    std::fs::rename(temp, destination)
331}
332
333#[cfg(unix)]
334fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
335    File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all()
336}
337
338#[cfg(target_os = "windows")]
339fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
340    // Windows does not expose a supported directory-fsync equivalent.
341    // Publication uses MoveFileExW(MOVEFILE_WRITE_THROUGH), which does not
342    // return until the move has reached durable storage, so there is no
343    // additional parent-directory handle to flush here.
344    Ok(())
345}
346
347#[cfg(not(any(unix, target_os = "windows")))]
348fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
349    Ok(())
350}
351
352impl StateStore {
353    pub fn new() -> Self {
354        Self {
355            proposal_execution: tokio::sync::Mutex::new(()),
356            state: Mutex::new(HashMap::new()),
357            transitions: Mutex::new(Vec::new()),
358            versions: Mutex::new(HashMap::new()),
359            journal: Mutex::new(None),
360            restore_failures: None,
361            #[cfg(test)]
362            mutation_before_state_lock: None,
363            #[cfg(test)]
364            batch_mid_apply: None,
365        }
366    }
367
368    /// Shared proposal-transaction guard. Hold this across proposal execution,
369    /// including rollback and replanning; actions inside the guarded proposal
370    /// may still execute concurrently according to their DAG.
371    pub async fn lock_proposal_execution(&self) -> tokio::sync::MutexGuard<'_, ()> {
372        self.proposal_execution.lock().await
373    }
374
375    /// Open a durable, JSONL-backed StateStore. If the file exists,
376    /// its transitions are replayed (last-write-wins per key, with
377    /// TTLs honored) to rebuild current state. Subsequent writes
378    /// append to the same file.
379    ///
380    /// Returns an error only on filesystem-level failures (parent
381    /// directory missing, permission denied, etc.). Malformed lines
382    /// inside the journal are skipped with a warning rather than
383    /// failing the open — agent persistence shouldn't refuse to
384    /// start over a single bad line.
385    pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
386        Self::durable_with_restore_failure_injector(path, None)
387    }
388
389    #[doc(hidden)]
390    pub fn durable_with_restore_failure_injector(
391        path: impl Into<PathBuf>,
392        restore_failures: impl Into<Option<RestoreFailureInjector>>,
393    ) -> std::io::Result<Self> {
394        let path = path.into();
395        if let Some(parent) = path.parent() {
396            if !parent.as_os_str().is_empty() {
397                std::fs::create_dir_all(parent)?;
398            }
399        }
400        let mut store = Self::new();
401        store.restore_failures = restore_failures.into();
402        match store.replay_journal(&path)? {
403            TailRepair::None => {}
404            // The final record is complete but its newline never made it to
405            // disk. It was replayed above; terminate it so the next append
406            // starts a fresh line instead of gluing onto it.
407            TailRepair::Terminate => {
408                let mut file = OpenOptions::new().append(true).open(&path)?;
409                file.write_all(b"\n")?;
410                file.sync_all()?;
411            }
412            // The final line is a torn prefix of an interrupted append. It
413            // was skipped above (old-or-complete); truncate it away so the
414            // next append cannot merge with it into one malformed line that
415            // a later reopen would then drop along with the new record.
416            TailRepair::TruncateTo(offset) => {
417                let file = OpenOptions::new().write(true).open(&path)?;
418                file.set_len(offset)?;
419                file.sync_all()?;
420            }
421        }
422        let file = OpenOptions::new().create(true).append(true).open(&path)?;
423        *store.journal.lock() = Some(Journal {
424            path,
425            writer: Some(BufWriter::new(file)),
426            pending_parent_sync: None,
427        });
428        Ok(store)
429    }
430
431    fn replay_journal(&self, path: &Path) -> std::io::Result<TailRepair> {
432        if !path.exists() {
433            return Ok(TailRepair::None);
434        }
435        let file = File::open(path)?;
436        let mut reader = BufReader::new(file);
437        let now = Utc::now();
438        let mut state = self.state.lock();
439        let mut transitions = self.transitions.lock();
440        let mut versions = self.versions.lock();
441        let mut buf = Vec::new();
442        let mut offset: u64 = 0;
443        let mut repair = TailRepair::None;
444        loop {
445            buf.clear();
446            let read = match reader.read_until(b'\n', &mut buf) {
447                Ok(0) => break,
448                Ok(n) => n,
449                Err(_) => break,
450            };
451            let line_start = offset;
452            offset += read as u64;
453            // A process can crash after writing only part of its final
454            // JSONL row — including everything but the trailing newline.
455            // The append writer resumes at EOF, so an unrepaired tail
456            // would glue the NEXT record onto this one into a single
457            // malformed line that a later reopen drops whole, losing both
458            // records. Mirror car-eventlog: a complete unterminated record
459            // is replayed and terminated; a torn prefix is skipped and
460            // truncated before any resumed append (Parslee-ai/car#1140).
461            let terminated = buf.ends_with(b"\n");
462            // Parse from the raw bytes, not a lossy string: `from_utf8_lossy`
463            // would turn invalid UTF-8 into U+FFFD and let a corrupted record
464            // parse — and mutate state with mangled values — where the
465            // previous `lines()`-based reader (and `from_slice` here)
466            // rejects it as malformed.
467            if buf.iter().all(|byte| byte.is_ascii_whitespace()) {
468                if !terminated {
469                    repair = TailRepair::TruncateTo(line_start);
470                    break;
471                }
472                continue;
473            }
474            if let Ok(t) = serde_json::from_slice::<StateTransition>(&buf) {
475                replay_transition(&mut state, &mut transitions, &mut versions, now, t);
476                if !terminated {
477                    repair = TailRepair::Terminate;
478                    break;
479                }
480                continue;
481            }
482            if let Ok(record) = serde_json::from_slice::<BatchTransitionRecord>(&buf) {
483                // A parsed batch line is complete by construction — a torn
484                // append is malformed JSON and falls through to the skip
485                // below, so replay never applies a prefix of a batch
486                // (Parslee-ai/car#1140).
487                for t in record.batch {
488                    replay_transition(&mut state, &mut transitions, &mut versions, now, t);
489                }
490                if !terminated {
491                    repair = TailRepair::Terminate;
492                    break;
493                }
494                continue;
495            }
496            if !terminated {
497                repair = TailRepair::TruncateTo(line_start);
498                break;
499            }
500            // Malformed line. Don't refuse to boot over it.
501            tracing::warn!(
502                journal = %path.display(),
503                "skipping malformed StateStore journal line"
504            );
505        }
506        Ok(repair)
507    }
508
509    fn append_journal(&self, transition: &StateTransition) {
510        let Ok(json) = serde_json::to_string(transition) else {
511            return;
512        };
513        self.append_journal_line(json);
514    }
515
516    /// Append one batch as a SINGLE journal line (see
517    /// [`BatchTransitionRecord`]). A single-entry batch keeps the legacy
518    /// bare-transition line shape; only a genuinely multi-key batch takes
519    /// the batch record, so journals stay readable by earlier CAR versions
520    /// until a multi-key batch actually occurs.
521    fn append_journal_batch(&self, batch: &[StateTransition]) {
522        match batch {
523            [] => {}
524            [single] => self.append_journal(single),
525            _ => {
526                let Ok(json) = serde_json::to_string(&BatchTransitionRecordRef { batch }) else {
527                    return;
528                };
529                self.append_journal_line(json);
530            }
531        }
532    }
533
534    fn append_journal_line(&self, json: String) {
535        let mut journal = self.journal.lock();
536        let Some(journal) = journal.as_mut() else {
537            return;
538        };
539        // Best-effort: a failed disk write tracing::warn!s but the
540        // in-memory write already succeeded. Callers who need
541        // guaranteed durability should call `sync` after batches.
542        let path = journal.path.clone();
543        let writer = match journal.writer_mut() {
544            Ok(writer) => writer,
545            Err(e) => {
546                tracing::warn!(
547                    journal = %path.display(),
548                    error = %e,
549                    "StateStore journal writer reopen failed"
550                );
551                return;
552            }
553        };
554        if let Err(e) = writeln!(writer, "{json}") {
555            tracing::warn!(
556                journal = %path.display(),
557                error = %e,
558                "StateStore journal append failed"
559            );
560            return;
561        }
562        let _ = writer.flush();
563    }
564
565    /// Atomically replace the durable journal with `state` as it existed at
566    /// the rollback boundary. The replacement file is fully flushed and
567    /// fsynced before rename, so a successful return means replay cannot
568    /// resurrect transitions discarded from memory.
569    ///
570    /// Callers hold the state lock while this runs. That matches the write
571    /// path's state -> journal lock order and prevents an append from landing
572    /// on the old file between snapshot creation and replacement.
573    fn replace_journal_with_snapshot(
574        &self,
575        state: &HashMap<String, Value>,
576        transitions: &[StateTransition],
577        versions: &HashMap<String, u64>,
578    ) -> std::io::Result<RestoreDurability> {
579        let restore_failures = self.restore_failures.as_ref();
580        self.replace_journal_with_snapshot_and_sync(state, transitions, versions, |path| {
581            if let Some(failures) = restore_failures {
582                failures.check(RestoreFailurePoint::ParentDirectorySync)?;
583            }
584            sync_parent_directory(path)
585        })
586    }
587
588    fn replace_journal_with_snapshot_and_sync<F>(
589        &self,
590        state: &HashMap<String, Value>,
591        transitions: &[StateTransition],
592        versions: &HashMap<String, u64>,
593        sync_parent: F,
594    ) -> std::io::Result<RestoreDurability>
595    where
596        F: FnOnce(&Path) -> std::io::Result<()>,
597    {
598        let mut journal = self.journal.lock();
599        let Some(journal) = journal.as_mut() else {
600            return Ok(RestoreDurability::Durable);
601        };
602        journal.writer_mut()?.flush()?;
603
604        let journal_permissions = std::fs::metadata(&journal.path)?.permissions();
605        let (tmp_path, tmp_file) = create_replacement_temp(&journal.path)?;
606        let mut cleanup = TempFileCleanup::new(tmp_path.clone());
607        let mut replacement = BufWriter::new(tmp_file);
608        let latest_by_key: HashMap<&str, &StateTransition> = transitions
609            .iter()
610            .map(|transition| (transition.key.as_str(), transition))
611            .collect();
612        let mut keys: Vec<&String> = state.keys().collect();
613        keys.sort();
614        for key in keys {
615            let previous = latest_by_key.get(key.as_str()).copied();
616            let transition = StateTransition {
617                key: key.clone(),
618                old_value: None,
619                new_value: state.get(key).cloned(),
620                action_id: previous
621                    .map(|transition| transition.action_id.clone())
622                    .unwrap_or_else(|| "restore".to_string()),
623                timestamp: previous
624                    .map(|transition| transition.timestamp)
625                    .unwrap_or_else(Utc::now),
626                ttl_secs: previous.and_then(|transition| transition.ttl_secs),
627                version: versions.get(key).copied(),
628            };
629            let line = serde_json::to_string(&transition)?;
630            writeln!(replacement, "{line}")?;
631        }
632        replacement.flush()?;
633        replacement.get_ref().set_permissions(journal_permissions)?;
634        replacement.get_ref().sync_all()?;
635        if let Some(failures) = &self.restore_failures {
636            failures.check(RestoreFailurePoint::BeforePublication)?;
637        }
638        #[cfg(target_os = "windows")]
639        {
640            // MoveFileExW cannot reliably replace a destination while either
641            // the old journal or replacement temp is open, even when the
642            // handles share delete access. Both files are already fsynced, so
643            // close them before publication and reopen the destination for
644            // subsequent appends.
645            drop(journal.writer.take());
646            drop(replacement);
647            if let Err(publication_error) = replace_file_atomically(&tmp_path, &journal.path) {
648                return match journal.reopen_writer() {
649                    Ok(()) => Err(publication_error),
650                    Err(reopen_error) => Err(std::io::Error::other(format!(
651                        "state journal replacement failed ({publication_error}); reopening the original journal also failed ({reopen_error})"
652                    ))),
653                };
654            }
655            cleanup.disarm();
656            if let Err(error) = journal.reopen_writer() {
657                let error = format!(
658                    "replacement journal was published but its append writer could not be reopened: {error}"
659                );
660                journal.pending_parent_sync = Some(error.clone());
661                return Ok(RestoreDurability::DurabilityUnknown { error });
662            }
663        }
664        #[cfg(not(target_os = "windows"))]
665        {
666            replace_file_atomically(&tmp_path, &journal.path)?;
667            cleanup.disarm();
668            journal.writer = Some(replacement);
669        }
670        match sync_parent(&journal.path) {
671            Ok(()) => {
672                journal.pending_parent_sync = None;
673                Ok(RestoreDurability::Durable)
674            }
675            Err(error) => {
676                let error = error.to_string();
677                journal.pending_parent_sync = Some(error.clone());
678                Ok(RestoreDurability::DurabilityUnknown { error })
679            }
680        }
681    }
682
683    fn reconcile_pending_parent_sync(&self) -> std::io::Result<()> {
684        let mut journal = self.journal.lock();
685        let Some(journal) = journal.as_mut() else {
686            return Ok(());
687        };
688        if journal.pending_parent_sync.is_none() {
689            return Ok(());
690        }
691        let writer = journal.writer_mut()?;
692        writer.flush()?;
693        writer.get_ref().sync_all()?;
694        if let Some(failures) = &self.restore_failures {
695            failures.check(RestoreFailurePoint::ParentDirectorySync)?;
696        }
697        sync_parent_directory(&journal.path)?;
698        journal.pending_parent_sync = None;
699        Ok(())
700    }
701
702    #[cfg(test)]
703    fn pause_before_mutation_state_lock(&self) {
704        if let Some(barrier) = &self.mutation_before_state_lock {
705            barrier.pause_mutation();
706        }
707    }
708
709    fn lock_reconciled_state_for_mutation(
710        &self,
711    ) -> std::io::Result<MutexGuard<'_, HashMap<String, Value>>> {
712        #[cfg(test)]
713        self.pause_before_mutation_state_lock();
714        let state = self.state.lock();
715        self.reconcile_pending_parent_sync()?;
716        Ok(state)
717    }
718
719    fn require_reconciled_state_for_mutation(&self) -> MutexGuard<'_, HashMap<String, Value>> {
720        self.lock_reconciled_state_for_mutation()
721            .unwrap_or_else(|error| {
722                panic!(
723                    "StateStore journal is durability-unknown; refusing mutation until parent sync succeeds: {error}"
724                )
725            })
726    }
727
728    /// Fsync the journal writer. Call after a batch of writes when
729    /// you need durability guarantees beyond best-effort flush.
730    pub fn sync(&self) -> std::io::Result<()> {
731        let mut journal = self.journal.lock();
732        let Some(journal) = journal.as_mut() else {
733            return Ok(());
734        };
735        let writer = journal.writer_mut()?;
736        writer.flush()?;
737        writer.get_ref().sync_all()?;
738        if journal.pending_parent_sync.is_some() {
739            sync_parent_directory(&journal.path)?;
740            journal.pending_parent_sync = None;
741        }
742        Ok(())
743    }
744
745    /// Drop expired keys (per `ttl_secs` on their last write) and
746    /// rewrite the journal as a compacted snapshot of the surviving
747    /// state. Returns the keys that were reaped.
748    ///
749    /// **TTL semantics**: a `ttl_secs` of 0 means "expired
750    /// immediately" — the key is reapable on the next call. There
751    /// is no "0 = forever" sentinel; use `set` (no TTL) for keys
752    /// that should never auto-expire.
753    ///
754    /// Latest-write-wins: a key rewritten WITHOUT a TTL after a
755    /// TTL'd write is NOT reaped — the more recent write
756    /// effectively cancels the TTL.
757    ///
758    /// Single-pass over the transitions log via a key→latest
759    /// index, so cost is O(n) in journal length (not O(n²)).
760    pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
761        self.reap_expired_where(now, |_| true)
762    }
763
764    /// Reap only the expired keys in one tenant's namespace (EPIC E / E3).
765    /// `tenant = Some(id)` reaps `tenant:<id>:*`; `tenant = None` reaps only
766    /// the unscoped namespace. This is the per-tenant counterpart to
767    /// [`Self::reap_expired`] (which reaps across all tenants): it lets a
768    /// per-tenant reaping budget expire one tenant's TTL'd keys without
769    /// touching another tenant's — so one tenant's memory pressure can't
770    /// evict another's state.
771    pub fn reap_expired_scoped(
772        &self,
773        now: DateTime<Utc>,
774        tenant: Option<&str>,
775    ) -> std::io::Result<Vec<String>> {
776        self.reap_expired_where(now, |k| key_in_tenant_namespace(k, tenant))
777    }
778
779    /// Shared reaping core: reap every expired key for which `keep` returns
780    /// true. Walks the transition log once to find the latest state per key.
781    fn reap_expired_where(
782        &self,
783        now: DateTime<Utc>,
784        keep: impl Fn(&str) -> bool,
785    ) -> std::io::Result<Vec<String>> {
786        let mut state = self.lock_reconciled_state_for_mutation()?;
787        let mut transitions = self.transitions.lock();
788        // Build a single-pass index of the latest transition per
789        // key. Walking the whole log once is unavoidable; doing it
790        // ONCE keeps reap O(n) in journal length.
791        let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
792        for t in transitions.iter() {
793            latest_by_key.insert(t.key.as_str(), t);
794        }
795        let expired: Vec<String> = latest_by_key
796            .values()
797            .filter_map(|t| {
798                if !keep(&t.key) {
799                    return None;
800                }
801                let ttl = t.ttl_secs?;
802                t.new_value.as_ref()?;
803                let age = now.signed_duration_since(t.timestamp);
804                (age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
805            })
806            .collect();
807        let mut reaped = Vec::new();
808        for key in expired {
809            if state.remove(&key).is_some() {
810                // Bump the version so an expiry is observable as a change
811                // (neo review N1: keeps in-memory versions in step with
812                // what replay would reconstruct).
813                let version = self.bump_version(&key);
814                reaped.push(key.clone());
815                transitions.push(StateTransition {
816                    key,
817                    old_value: None,
818                    new_value: None,
819                    action_id: "reap".to_string(),
820                    timestamp: now,
821                    ttl_secs: None,
822                    version: Some(version),
823                });
824            }
825        }
826        drop(state);
827        drop(transitions);
828        if !reaped.is_empty() {
829            self.compact_journal()?;
830        }
831        Ok(reaped)
832    }
833
834    /// Rewrite the journal as a flat snapshot of the current state —
835    /// one transition per surviving key, no replay history. Reduces
836    /// journal size without changing observable behavior.
837    ///
838    /// The state lock stays held until the replacement is installed, so a
839    /// concurrent mutation cannot append to the old file after the snapshot.
840    pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
841        let state = self.state.lock();
842        let transitions = self.transitions.lock();
843        let versions = self.versions.lock();
844        match self.replace_journal_with_snapshot(&state, &transitions, &versions)? {
845            RestoreDurability::Durable => Ok(()),
846            RestoreDurability::DurabilityUnknown { error } => Err(std::io::Error::other(format!(
847                "state journal compaction is durability-unknown: {error}"
848            ))),
849        }
850    }
851
852    pub fn get(&self, key: &str) -> Option<Value> {
853        self.state.lock().get(key).cloned()
854    }
855
856    pub fn get_or(&self, key: &str, default: Value) -> Value {
857        self.state.lock().get(key).cloned().unwrap_or(default)
858    }
859
860    pub fn exists(&self, key: &str) -> bool {
861        self.state.lock().contains_key(key)
862    }
863
864    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
865        self.set_inner(key, value, action_id, None)
866    }
867
868    /// Set a key with a TTL (seconds from now). `reap_expired`
869    /// drops the key once the deadline passes; re-setting the key
870    /// without a TTL (`set`) cancels the TTL.
871    ///
872    /// `ttl_secs == 0` means "expire immediately" (reapable on the
873    /// next `reap_expired` call). It is NOT a "no expiry" sentinel
874    /// — use the plain `set(...)` method for keys that should
875    /// never auto-expire. This differs from the Unix/Redis
876    /// convention; the distinction matters because a TTL passed
877    /// from untrusted input could otherwise silently mean
878    /// "forever" when the caller intended "never store."
879    pub fn set_with_ttl(
880        &self,
881        key: &str,
882        value: Value,
883        action_id: &str,
884        ttl_secs: u64,
885    ) -> StateTransition {
886        self.set_inner(key, value, action_id, Some(ttl_secs))
887    }
888
889    fn set_inner(
890        &self,
891        key: &str,
892        value: Value,
893        action_id: &str,
894        ttl_secs: Option<u64>,
895    ) -> StateTransition {
896        let mut state = self.require_reconciled_state_for_mutation();
897        let old = state.get(key).cloned();
898        state.insert(key.to_string(), value.clone());
899        let version = self.bump_version(key);
900
901        let t = StateTransition {
902            key: key.to_string(),
903            old_value: old,
904            new_value: Some(value),
905            action_id: action_id.to_string(),
906            timestamp: Utc::now(),
907            ttl_secs,
908            version: Some(version),
909        };
910
911        self.transitions.lock().push(t.clone());
912        self.append_journal(&t);
913        t
914    }
915
916    /// Apply several writes as ONE atomic mutation boundary
917    /// (Parslee-ai/car#1140). The state lock is held across every entry, so
918    /// a concurrent reader observes the complete old state or the complete
919    /// new state — never a prefix of the batch — and the journal receives
920    /// the whole batch as a single line ([`BatchTransitionRecord`]), giving
921    /// replay after an interrupted append the same old-or-complete
922    /// guarantee.
923    ///
924    /// Entries apply in the order given; a duplicated key's later entry
925    /// wins, each bumping the key's version. An empty batch is a no-op. A
926    /// single-entry batch behaves exactly like [`Self::set`], journal line
927    /// shape included.
928    pub fn set_batch(
929        &self,
930        entries: Vec<(String, Value)>,
931        action_id: &str,
932    ) -> Vec<StateTransition> {
933        if entries.is_empty() {
934            return Vec::new();
935        }
936        let state = self.require_reconciled_state_for_mutation();
937        self.set_batch_locked(state, entries, action_id)
938    }
939
940    fn set_batch_locked(
941        &self,
942        mut state: MutexGuard<'_, HashMap<String, Value>>,
943        entries: Vec<(String, Value)>,
944        action_id: &str,
945    ) -> Vec<StateTransition> {
946        let timestamp = Utc::now();
947        let mut batch = Vec::with_capacity(entries.len());
948        for (key, value) in entries {
949            let old = state.get(&key).cloned();
950            state.insert(key.clone(), value.clone());
951            let version = self.bump_version(&key);
952            batch.push(StateTransition {
953                key,
954                old_value: old,
955                new_value: Some(value),
956                action_id: action_id.to_string(),
957                timestamp,
958                ttl_secs: None,
959                version: Some(version),
960            });
961            #[cfg(test)]
962            if batch.len() == 1 {
963                if let Some(barrier) = &self.batch_mid_apply {
964                    barrier.pause_mutation();
965                }
966            }
967        }
968        self.transitions.lock().extend(batch.iter().cloned());
969        self.append_journal_batch(&batch);
970        batch
971    }
972
973    /// Increment the monotonic version counter for `key`, returning the new
974    /// version.
975    fn bump_version(&self, key: &str) -> u64 {
976        let mut versions = self.versions.lock();
977        let v = versions.entry(key.to_string()).or_insert(0);
978        *v += 1;
979        *v
980    }
981
982    /// Current version of `key` (number of writes/deletes applied to it),
983    /// or `None` if it was never written. Used by the transactional
984    /// conflict checker to detect stale reads (survey §5.2.4).
985    pub fn version(&self, key: &str) -> Option<u64> {
986        self.versions.lock().get(key).copied()
987    }
988
989    /// Snapshot of all key versions — the version map an action's
990    /// assumptions are checked against.
991    pub fn versions(&self) -> HashMap<String, u64> {
992        self.versions.lock().clone()
993    }
994
995    /// Atomic snapshot of both the current values and the current versions,
996    /// taken under a single consistent lock acquisition (state then
997    /// versions, matching the write path) so the two maps can't tear — a
998    /// caller never sees a value from version N+1 paired with version N
999    /// (neo review N2). This is the pair the transactional conflict checker
1000    /// (`car_verify::check_transaction`) should consume.
1001    pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
1002        let state = self.state.lock();
1003        let versions = self.versions.lock();
1004        (state.clone(), versions.clone())
1005    }
1006
1007    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1008        let mut state = self.require_reconciled_state_for_mutation();
1009        let old = state.remove(key)?;
1010        let version = self.bump_version(key);
1011
1012        let t = StateTransition {
1013            key: key.to_string(),
1014            old_value: Some(old),
1015            new_value: None,
1016            action_id: action_id.to_string(),
1017            timestamp: Utc::now(),
1018            ttl_secs: None,
1019            version: Some(version),
1020        };
1021
1022        self.transitions.lock().push(t.clone());
1023        self.append_journal(&t);
1024        Some(t)
1025    }
1026
1027    /// Deep clone of current state.
1028    pub fn snapshot(&self) -> HashMap<String, Value> {
1029        self.state.lock().clone()
1030    }
1031
1032    /// Restore state from a snapshot, truncating transitions. For durable
1033    /// stores the restored snapshot replaces the JSONL journal before the
1034    /// in-memory state is published; failures leave the current state intact.
1035    pub fn restore(
1036        &self,
1037        snapshot: HashMap<String, Value>,
1038        transition_count: usize,
1039    ) -> std::io::Result<RestoreDurability> {
1040        let restore_failures = self.restore_failures.as_ref();
1041        self.restore_with_parent_sync(snapshot, transition_count, |path| {
1042            if let Some(failures) = restore_failures {
1043                failures.check(RestoreFailurePoint::ParentDirectorySync)?;
1044            }
1045            sync_parent_directory(path)
1046        })
1047    }
1048
1049    fn restore_with_parent_sync<F>(
1050        &self,
1051        snapshot: HashMap<String, Value>,
1052        transition_count: usize,
1053        sync_parent: F,
1054    ) -> std::io::Result<RestoreDurability>
1055    where
1056        F: FnOnce(&Path) -> std::io::Result<()>,
1057    {
1058        let mut state = self.state.lock();
1059        let mut transitions = self.transitions.lock();
1060        let versions = self.versions.lock();
1061        let mut restored_transitions = transitions.clone();
1062        restored_transitions.truncate(transition_count);
1063        let durability = self.replace_journal_with_snapshot_and_sync(
1064            &snapshot,
1065            &restored_transitions,
1066            &versions,
1067            sync_parent,
1068        )?;
1069        *state = snapshot;
1070        *transitions = restored_transitions;
1071        Ok(durability)
1072    }
1073
1074    /// Snapshot only the keys belonging to one tenant's namespace
1075    /// (Parslee-ai/car#187 / EPIC E task E2). `tenant = Some(id)` captures
1076    /// `tenant:<id>:*`; `tenant = None` captures the unscoped (non-`tenant:`)
1077    /// namespace. Keys are returned in their full (prefixed) form so the
1078    /// result round-trips through [`Self::restore_scoped`]. This is the
1079    /// per-tenant counterpart to [`Self::snapshot`], which captures *all*
1080    /// tenants and so can't be used for a tenant-isolated rollback.
1081    pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
1082        let state = self.state.lock();
1083        state
1084            .iter()
1085            .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
1086            .map(|(k, v)| (k.clone(), v.clone()))
1087            .collect()
1088    }
1089
1090    /// Restore a single tenant's namespace from a scoped snapshot, leaving
1091    /// every other tenant's keys untouched (EPIC E / E2). Existing keys in
1092    /// the target namespace are dropped and replaced by `snapshot`; keys
1093    /// outside it are preserved. Fixes the cross-tenant clobber where a
1094    /// rollback via the unscoped [`Self::restore`] wiped concurrent
1095    /// tenants' state.
1096    ///
1097    /// The transition log is FILTERED, not truncated (linus review C-5):
1098    /// only this tenant's post-snapshot transitions are discarded.
1099    /// Truncating shared history dropped transitions concurrent tenants
1100    /// committed after `transition_count`, which both falsified the audit
1101    /// trail and let `reap_expired*` treat another tenant's stale TTL'd
1102    /// transition as latest — deleting a live key. `transition_count` is
1103    /// the log length captured when this tenant's snapshot was taken.
1104    pub fn restore_scoped(
1105        &self,
1106        tenant: Option<&str>,
1107        snapshot: HashMap<String, Value>,
1108        transition_count: usize,
1109    ) -> std::io::Result<RestoreDurability> {
1110        let mut state = self.state.lock();
1111        let mut transitions = self.transitions.lock();
1112        let versions = self.versions.lock();
1113        let mut restored_state = state.clone();
1114        restored_state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
1115        restored_state.extend(snapshot);
1116        let mut restored_transitions = transitions.clone();
1117        if transition_count < restored_transitions.len() {
1118            // Keep everything up to the snapshot point; after it, keep only
1119            // transitions that belong to OTHER namespaces.
1120            let tail: Vec<StateTransition> = restored_transitions
1121                .drain(transition_count..)
1122                .filter(|transition| !key_in_tenant_namespace(&transition.key, tenant))
1123                .collect();
1124            restored_transitions.extend(tail);
1125        }
1126        let durability =
1127            self.replace_journal_with_snapshot(&restored_state, &restored_transitions, &versions)?;
1128        *state = restored_state;
1129        *transitions = restored_transitions;
1130        Ok(durability)
1131    }
1132
1133    pub fn transition_count(&self) -> usize {
1134        self.transitions.lock().len()
1135    }
1136
1137    pub fn transitions(&self) -> Vec<StateTransition> {
1138        self.transitions.lock().clone()
1139    }
1140
1141    pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
1142        let transitions = self.transitions.lock();
1143        let start = index.min(transitions.len());
1144        transitions[start..].to_vec()
1145    }
1146
1147    pub fn keys(&self) -> Vec<String> {
1148        self.state.lock().keys().cloned().collect()
1149    }
1150
1151    /// Replace the entire state map without recording transitions.
1152    /// Used by checkpoint restore to avoid synthetic transition history.
1153    /// Also clears the transitions log so callers of `transitions_since()`
1154    /// don't see stale history from the discarded state.
1155    pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
1156        let mut state = self.require_reconciled_state_for_mutation();
1157        *state = snapshot;
1158        self.transitions.lock().clear();
1159    }
1160
1161    /// Build a tenant-scoped view over this store
1162    /// (Parslee-ai/car#187 phase 3 enforcement).
1163    ///
1164    /// All reads / writes go through `tenant:<tenant_id>:<key>` so
1165    /// distinct tenants can't see each other's keys. `tenant = None`
1166    /// returns a view that hits the unscoped (legacy) namespace —
1167    /// callers that don't yet have a `RuntimeScope` get pre-#187
1168    /// behaviour automatically.
1169    ///
1170    /// Cheap to construct; holds a `&self` borrow plus the tenant
1171    /// string. The view's methods take the parking-lot lock the same
1172    /// way the unscoped methods do.
1173    pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
1174        ScopedStateView {
1175            store: self,
1176            tenant,
1177        }
1178    }
1179}
1180
1181/// Apply one journaled transition during replay. Last-write-wins per key;
1182/// TTLs that already expired are dropped at replay time so stale data never
1183/// surfaces on first read. The version counter restores the persisted value
1184/// when present (survives compaction) and counts transitions for legacy
1185/// pre-versioning journals, taking the max so the counter stays monotonic
1186/// across a mix of compacted and appended lines.
1187fn replay_transition(
1188    state: &mut HashMap<String, Value>,
1189    transitions: &mut Vec<StateTransition>,
1190    versions: &mut HashMap<String, u64>,
1191    now: DateTime<Utc>,
1192    t: StateTransition,
1193) {
1194    if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
1195        if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
1196            state.remove(&t.key);
1197        } else {
1198            state.insert(t.key.clone(), value.clone());
1199        }
1200    } else if let Some(value) = &t.new_value {
1201        state.insert(t.key.clone(), value.clone());
1202    } else {
1203        state.remove(&t.key);
1204    }
1205    let entry = versions.entry(t.key.clone()).or_insert(0);
1206    let restored = t.version.unwrap_or(*entry + 1);
1207    *entry = (*entry).max(restored);
1208    transitions.push(t);
1209}
1210
1211/// Whether `key` belongs to the namespace identified by `tenant`.
1212///
1213/// `Some(id)` (non-empty) → keys prefixed `tenant:<id>:`. `None` or empty →
1214/// the unscoped namespace: every key that is NOT `tenant:`-prefixed (so an
1215/// unscoped snapshot/restore never touches any tenant's keys). This is the
1216/// predicate that makes [`StateStore::snapshot_scoped`] /
1217/// [`StateStore::restore_scoped`] tenant-isolated.
1218fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
1219    match tenant {
1220        Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
1221        _ => !key.starts_with("tenant:"),
1222    }
1223}
1224
1225/// Tenant-scoped view over a [`StateStore`]. All key arguments are
1226/// transparently prefixed with `tenant:<tenant_id>:` before hitting
1227/// the underlying store; on the way out, the prefix is stripped so
1228/// callers see their original keys.
1229///
1230/// Construct via [`StateStore::scoped`]. When `tenant` is `None`,
1231/// the prefix is empty and the view is functionally equivalent to
1232/// the unscoped methods on `StateStore` — useful for code paths
1233/// that always go through this view regardless of whether scope is
1234/// active.
1235///
1236/// # Isolation guarantee
1237///
1238/// Two views with distinct `tenant` strings cannot observe each
1239/// other's writes through `get` / `exists` / `keys`. The transitions
1240/// log still records the full (prefixed) key so audit / replay sees
1241/// the actual storage layout.
1242///
1243/// # What isolation does *not* cover (phase 3 follow-ups)
1244///
1245/// - `StateStore::snapshot` / `restore` are deliberately unscoped —
1246///   they're called at proposal boundaries for rollback and need to
1247///   see the whole map. Per-tenant partial rollback is a known
1248///   concurrency hole when multiple proposals run interleaved; the
1249///   pre-#187 baseline has the same issue, and fixing it cleanly
1250///   requires either serializing per-tenant or extending the
1251///   transactional model. Tracked as a follow-up.
1252/// - The journal file (when durability is on) records full
1253///   prefixed keys. Operators rotating tenants out can grep the
1254///   journal by prefix.
1255pub struct ScopedStateView<'a> {
1256    store: &'a StateStore,
1257    tenant: Option<&'a str>,
1258}
1259
1260impl<'a> ScopedStateView<'a> {
1261    fn full_key(&self, key: &str) -> String {
1262        match self.tenant {
1263            Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
1264            _ => key.to_string(),
1265        }
1266    }
1267
1268    fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
1269        match self.tenant {
1270            Some(t) if !t.is_empty() => {
1271                let prefix = format!("tenant:{t}:");
1272                full.strip_prefix(&prefix)
1273            }
1274            _ => Some(full),
1275        }
1276    }
1277
1278    pub fn get(&self, key: &str) -> Option<Value> {
1279        self.store.get(&self.full_key(key))
1280    }
1281
1282    pub fn get_or(&self, key: &str, default: Value) -> Value {
1283        self.store.get_or(&self.full_key(key), default)
1284    }
1285
1286    /// Snapshot only this tenant's namespace (EPIC E / E2) — the scoped
1287    /// counterpart to `StateStore::snapshot`, safe to pair with
1288    /// [`Self::restore`] for a tenant-isolated rollback.
1289    pub fn snapshot(&self) -> HashMap<String, Value> {
1290        self.store.snapshot_scoped(self.tenant)
1291    }
1292
1293    /// Single-lock snapshot of this tenant's namespace with the
1294    /// `tenant:<id>:` prefix stripped — the map a reader surface (e.g. the
1295    /// `state.snapshot` RPC) should return. Taken under ONE state-lock
1296    /// acquisition, so it can never interleave with a concurrent mutation
1297    /// batch the way a `keys()`-then-`get()` loop can — the result is the
1298    /// complete old state or the complete new state, never a mix
1299    /// (Parslee-ai/car#1140). Unlike [`Self::snapshot`], the stripped keys
1300    /// here do NOT round-trip through [`Self::restore`].
1301    pub fn snapshot_stripped(&self) -> HashMap<String, Value> {
1302        self.store
1303            .snapshot_scoped(self.tenant)
1304            .into_iter()
1305            .filter_map(|(key, value)| {
1306                self.strip_prefix(&key)
1307                    .map(|stripped| (stripped.to_string(), value))
1308            })
1309            .collect()
1310    }
1311
1312    /// Restore only this tenant's namespace from a scoped snapshot, leaving
1313    /// other tenants untouched (EPIC E / E2).
1314    pub fn restore(
1315        &self,
1316        snapshot: HashMap<String, Value>,
1317        transition_count: usize,
1318    ) -> std::io::Result<RestoreDurability> {
1319        self.store
1320            .restore_scoped(self.tenant, snapshot, transition_count)
1321    }
1322
1323    pub fn exists(&self, key: &str) -> bool {
1324        self.store.exists(&self.full_key(key))
1325    }
1326
1327    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
1328        self.store.set(&self.full_key(key), value, action_id)
1329    }
1330
1331    pub fn set_with_ttl(
1332        &self,
1333        key: &str,
1334        value: Value,
1335        action_id: &str,
1336        ttl_secs: u64,
1337    ) -> StateTransition {
1338        self.store
1339            .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
1340    }
1341
1342    /// Batch counterpart to [`Self::set`] — every entry applies inside one
1343    /// atomic mutation boundary, with keys transparently prefixed into this
1344    /// tenant's namespace (see [`StateStore::set_batch`]).
1345    pub fn set_batch(
1346        &self,
1347        entries: Vec<(String, Value)>,
1348        action_id: &str,
1349    ) -> Vec<StateTransition> {
1350        let prefixed = entries
1351            .into_iter()
1352            .map(|(key, value)| (self.full_key(&key), value))
1353            .collect();
1354        self.store.set_batch(prefixed, action_id)
1355    }
1356
1357    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1358        self.store.delete(&self.full_key(key), action_id)
1359    }
1360
1361    /// Return keys belonging to this tenant only, with the
1362    /// `tenant:<id>:` prefix stripped so callers see their original
1363    /// key names. Unscoped views (no tenant) return only keys that
1364    /// don't start with `tenant:` — preventing accidental visibility
1365    /// of scoped state through a legacy code path.
1366    pub fn keys(&self) -> Vec<String> {
1367        self.store
1368            .keys()
1369            .into_iter()
1370            .filter_map(|k| {
1371                if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
1372                    self.strip_prefix(&k).map(str::to_string)
1373                } else if k.starts_with("tenant:") {
1374                    None
1375                } else {
1376                    Some(k)
1377                }
1378            })
1379            .collect()
1380    }
1381}
1382
1383impl Default for StateStore {
1384    fn default() -> Self {
1385        Self::new()
1386    }
1387}
1388
1389impl car_ir::precondition::StateView for StateStore {
1390    fn get_value(&self, key: &str) -> Option<Value> {
1391        self.get(key)
1392    }
1393    fn key_exists(&self, key: &str) -> bool {
1394        self.exists(key)
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401    use serde_json::json;
1402
1403    #[test]
1404    fn set_and_get() {
1405        let store = StateStore::new();
1406        store.set("x", Value::from(42), "test");
1407        assert_eq!(store.get("x"), Some(Value::from(42)));
1408    }
1409
1410    #[test]
1411    fn exists() {
1412        let store = StateStore::new();
1413        assert!(!store.exists("x"));
1414        store.set("x", Value::from(1), "test");
1415        assert!(store.exists("x"));
1416    }
1417
1418    #[test]
1419    fn delete() {
1420        let store = StateStore::new();
1421        store.set("x", Value::from(1), "test");
1422        let t = store.delete("x", "test");
1423        assert!(t.is_some());
1424        assert!(!store.exists("x"));
1425    }
1426
1427    #[test]
1428    fn delete_nonexistent() {
1429        let store = StateStore::new();
1430        assert!(store.delete("x", "test").is_none());
1431    }
1432
1433    #[test]
1434    fn snapshot_and_restore() {
1435        let store = StateStore::new();
1436        store.set("x", Value::from(1), "a");
1437        let snap = store.snapshot();
1438        let tc = store.transition_count();
1439
1440        store.set("y", Value::from(2), "b");
1441        assert!(store.exists("y"));
1442
1443        store.restore(snap, tc).unwrap();
1444        assert!(store.exists("x"));
1445        assert!(!store.exists("y"));
1446        assert_eq!(store.transition_count(), 1);
1447    }
1448
1449    #[test]
1450    fn transitions_logged() {
1451        let store = StateStore::new();
1452        store.set("a", Value::from(1), "act1");
1453        store.set("b", Value::from(2), "act2");
1454
1455        let transitions = store.transitions();
1456        assert_eq!(transitions.len(), 2);
1457        assert_eq!(transitions[0].key, "a");
1458        assert_eq!(transitions[1].key, "b");
1459    }
1460
1461    #[test]
1462    fn transitions_since() {
1463        let store = StateStore::new();
1464        store.set("a", Value::from(1), "act1");
1465        let idx = store.transition_count();
1466        store.set("b", Value::from(2), "act2");
1467
1468        let since = store.transitions_since(idx);
1469        assert_eq!(since.len(), 1);
1470        assert_eq!(since[0].key, "b");
1471    }
1472
1473    #[test]
1474    fn transition_records_old_value() {
1475        let store = StateStore::new();
1476        store.set("x", Value::from(1), "first");
1477        store.set("x", Value::from(2), "second");
1478
1479        let transitions = store.transitions();
1480        assert_eq!(transitions[1].old_value, Some(Value::from(1)));
1481        assert_eq!(transitions[1].new_value, Some(Value::from(2)));
1482    }
1483
1484    #[test]
1485    fn keys() {
1486        let store = StateStore::new();
1487        store.set("a", Value::from(1), "t");
1488        store.set("b", Value::from(2), "t");
1489        let mut keys = store.keys();
1490        keys.sort();
1491        assert_eq!(keys, vec!["a", "b"]);
1492    }
1493
1494    #[test]
1495    fn transitions_since_after_restore_does_not_panic() {
1496        let store = StateStore::new();
1497        store.set("a", serde_json::json!(1), "test");
1498        store.set("b", serde_json::json!(2), "test");
1499        let count_before = store.transition_count(); // 2
1500
1501        // Restore to empty, truncating transitions to 0
1502        store.restore(HashMap::new(), 0).unwrap();
1503
1504        // Using the stale count_before (2) should not panic
1505        let result = store.transitions_since(count_before);
1506        assert!(result.is_empty());
1507    }
1508
1509    #[test]
1510    fn transitions_since_normal_usage() {
1511        let store = StateStore::new();
1512        store.set("a", serde_json::json!(1), "test");
1513        let mark = store.transition_count();
1514        store.set("b", serde_json::json!(2), "test");
1515        let since = store.transitions_since(mark);
1516        assert_eq!(since.len(), 1);
1517        assert_eq!(since[0].key, "b");
1518    }
1519
1520    #[test]
1521    fn replace_all_swaps_state_without_transitions() {
1522        let store = StateStore::new();
1523        store.set("old_key", serde_json::json!("old"), "setup");
1524
1525        let mut new_state = HashMap::new();
1526        new_state.insert("new_key".to_string(), serde_json::json!("new"));
1527        store.replace_all(new_state);
1528
1529        assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
1530        assert_eq!(store.get("old_key"), None);
1531        // After replace_all, transitions should be cleared (not preserved)
1532        assert_eq!(store.transition_count(), 0);
1533    }
1534
1535    #[test]
1536    fn durable_store_survives_reopen() {
1537        let dir = tempfile::tempdir().unwrap();
1538        let path = dir.path().join("state.jsonl");
1539        {
1540            let store = StateStore::durable(&path).unwrap();
1541            store.set("agent", serde_json::json!("planner"), "boot");
1542            store.set("turns", serde_json::json!(42), "tick");
1543            store.sync().unwrap();
1544        }
1545        let store = StateStore::durable(&path).unwrap();
1546        assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
1547        assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
1548    }
1549
1550    #[test]
1551    fn durable_store_replays_deletes() {
1552        let dir = tempfile::tempdir().unwrap();
1553        let path = dir.path().join("state.jsonl");
1554        {
1555            let store = StateStore::durable(&path).unwrap();
1556            store.set("transient", serde_json::json!("x"), "boot");
1557            store.delete("transient", "rm");
1558            store.sync().unwrap();
1559        }
1560        let store = StateStore::durable(&path).unwrap();
1561        assert!(!store.exists("transient"));
1562    }
1563
1564    #[test]
1565    fn durable_restore_survives_reopen() {
1566        let dir = tempfile::tempdir().unwrap();
1567        let path = dir.path().join("state.jsonl");
1568        {
1569            let store = StateStore::durable(&path).unwrap();
1570            store.set("existing", serde_json::json!("old"), "setup");
1571            let snapshot = store.snapshot();
1572            let transition_count = store.transition_count();
1573
1574            store.set("existing", serde_json::json!("new"), "candidate");
1575            store.set("created", serde_json::json!(true), "candidate");
1576            store.restore(snapshot, transition_count).unwrap();
1577            store.sync().unwrap();
1578
1579            assert_eq!(store.get("existing"), Some(serde_json::json!("old")));
1580            assert!(!store.exists("created"));
1581        }
1582
1583        let reopened = StateStore::durable(&path).unwrap();
1584        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1585        assert!(
1586            !reopened.exists("created"),
1587            "a full rollback must not resurrect candidate state after reopen"
1588        );
1589    }
1590
1591    #[test]
1592    fn abandoned_replacement_temps_do_not_block_restore_compaction_or_reopen() {
1593        let dir = tempfile::tempdir().unwrap();
1594        let path = dir.path().join("state.jsonl");
1595        {
1596            let store = StateStore::durable(&path).unwrap();
1597            store.set("existing", serde_json::json!("old"), "setup");
1598            store.sync().unwrap();
1599        }
1600
1601        let abandoned_transition = StateTransition {
1602            key: "intruder".to_string(),
1603            old_value: None,
1604            new_value: Some(serde_json::json!("must-not-replay")),
1605            action_id: "abandoned-temp".to_string(),
1606            timestamp: Utc::now(),
1607            ttl_secs: None,
1608            version: Some(99),
1609        };
1610        let abandoned_contents = format!(
1611            "{}\n",
1612            serde_json::to_string(&abandoned_transition).unwrap()
1613        );
1614        let abandoned_temps = [
1615            path.with_extension("jsonl.restore.tmp"),
1616            dir.path().join(".state.jsonl.restore.123.456.0.tmp"),
1617            dir.path().join(".state.jsonl.restore.789.012.1.tmp"),
1618        ];
1619        for temp in &abandoned_temps {
1620            std::fs::write(temp, &abandoned_contents).unwrap();
1621        }
1622
1623        let store = StateStore::durable(&path).unwrap();
1624        assert!(!store.exists("intruder"));
1625        let snapshot = store.snapshot();
1626        let transition_count = store.transition_count();
1627        store.set("existing", serde_json::json!("new"), "candidate");
1628        store.set("candidate_only", serde_json::json!(true), "candidate");
1629        assert_eq!(
1630            store.restore(snapshot, transition_count).unwrap(),
1631            RestoreDurability::Durable
1632        );
1633        store.set_with_ttl("expired", serde_json::json!(true), "ttl", 0);
1634        store.sync().unwrap();
1635        assert_eq!(
1636            store
1637                .reap_expired(Utc::now() + Duration::seconds(1))
1638                .unwrap(),
1639            vec!["expired".to_string()]
1640        );
1641        store.sync().unwrap();
1642        drop(store);
1643
1644        let reopened = StateStore::durable(&path).unwrap();
1645        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1646        assert!(!reopened.exists("candidate_only"));
1647        assert!(!reopened.exists("expired"));
1648        assert!(!reopened.exists("intruder"));
1649        for temp in &abandoned_temps {
1650            assert!(temp.exists(), "abandoned sibling temp should be ignored");
1651        }
1652    }
1653
1654    #[test]
1655    fn durable_restore_parent_sync_failure_keeps_live_and_reopened_state_coherent() {
1656        let dir = tempfile::tempdir().unwrap();
1657        let path = dir.path().join("state.jsonl");
1658        let store = StateStore::durable(&path).unwrap();
1659        store.set("existing", serde_json::json!("old"), "setup");
1660        let snapshot = store.snapshot();
1661        let transition_count = store.transition_count();
1662        store.set("existing", serde_json::json!("new"), "candidate");
1663
1664        #[cfg(unix)]
1665        let original_mode = {
1666            use std::os::unix::fs::PermissionsExt;
1667            std::fs::metadata(&path).unwrap().permissions().mode()
1668        };
1669        let durability = store
1670            .restore_with_parent_sync(snapshot, transition_count, |_| {
1671                Err(std::io::Error::other(
1672                    "injected parent directory sync failure",
1673                ))
1674            })
1675            .unwrap();
1676        assert!(matches!(
1677            durability,
1678            RestoreDurability::DurabilityUnknown { ref error }
1679                if error.contains("parent directory sync failure")
1680        ));
1681        assert_eq!(
1682            store.get("existing"),
1683            Some(serde_json::json!("old")),
1684            "once rename publishes the rollback journal, memory must adopt the same state"
1685        );
1686        assert!(
1687            !path.with_extension("jsonl.restore.tmp").exists(),
1688            "replacement temp must be cleaned after atomic publication"
1689        );
1690        #[cfg(unix)]
1691        {
1692            use std::os::unix::fs::PermissionsExt;
1693            assert_eq!(
1694                std::fs::metadata(&path).unwrap().permissions().mode(),
1695                original_mode,
1696                "replacement must preserve journal permissions"
1697            );
1698        }
1699        store.set("after_unknown", serde_json::json!("safe"), "later");
1700        store.sync().unwrap();
1701        drop(store);
1702
1703        let reopened = StateStore::durable(&path).unwrap();
1704        assert_eq!(
1705            reopened.get("existing"),
1706            Some(serde_json::json!("old")),
1707            "the visible journal and live rollback state must agree"
1708        );
1709        assert_eq!(
1710            reopened.get("after_unknown"),
1711            Some(serde_json::json!("safe")),
1712            "a later write must first reconcile the pending parent sync"
1713        );
1714    }
1715
1716    #[test]
1717    fn durability_unknown_store_refuses_write_until_parent_sync_reconciles() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let path = dir.path().join("state.jsonl");
1720        let failures = RestoreFailureInjector::default();
1721        let store =
1722            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1723                .unwrap();
1724        store.set("existing", serde_json::json!("old"), "setup");
1725        let snapshot = store.snapshot();
1726        let transition_count = store.transition_count();
1727        store.set("existing", serde_json::json!("new"), "candidate");
1728        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1729        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1730
1731        assert!(matches!(
1732            store.restore(snapshot, transition_count).unwrap(),
1733            RestoreDurability::DurabilityUnknown { .. }
1734        ));
1735        let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1736            store.set("must_not_write", serde_json::json!(true), "later");
1737        }));
1738        assert!(refused.is_err());
1739        assert!(!store.exists("must_not_write"));
1740        drop(store);
1741
1742        let reopened = StateStore::durable(&path).unwrap();
1743        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1744        assert!(!reopened.exists("must_not_write"));
1745    }
1746
1747    #[test]
1748    fn mutation_waiting_for_state_reconciles_restore_durability_unknown() {
1749        let dir = tempfile::tempdir().unwrap();
1750        let path = dir.path().join("state.jsonl");
1751        let failures = RestoreFailureInjector::default();
1752        let mut store =
1753            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1754                .unwrap();
1755        store.set("existing", serde_json::json!("old"), "setup");
1756        let snapshot = store.snapshot();
1757        let transition_count = store.transition_count();
1758        store.set("existing", serde_json::json!("candidate"), "candidate");
1759
1760        let barrier = Arc::new(MutationRaceBarrier::new());
1761        store.mutation_before_state_lock = Some(barrier.clone());
1762        let store = Arc::new(store);
1763        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1764        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1765
1766        let mutation_store = store.clone();
1767        let mutation = std::thread::spawn(move || {
1768            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1769                mutation_store.set("must_not_write", serde_json::json!(true), "racing");
1770            }))
1771        });
1772
1773        barrier.reached.wait();
1774        assert!(matches!(
1775            store.restore(snapshot, transition_count).unwrap(),
1776            RestoreDurability::DurabilityUnknown { .. }
1777        ));
1778        barrier.release.wait();
1779
1780        assert!(
1781            mutation.join().unwrap().is_err(),
1782            "a mutation admitted before restore must reconcile the journal after acquiring state"
1783        );
1784        assert!(!store.exists("must_not_write"));
1785        drop(store);
1786
1787        let reopened = StateStore::durable(&path).unwrap();
1788        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1789        assert!(!reopened.exists("must_not_write"));
1790    }
1791
1792    #[test]
1793    fn durable_scoped_restore_survives_reopen_without_clobbering_other_tenants() {
1794        let dir = tempfile::tempdir().unwrap();
1795        let path = dir.path().join("state.jsonl");
1796        {
1797            let store = StateStore::durable(&path).unwrap();
1798            store
1799                .scoped(Some("acme"))
1800                .set("existing", serde_json::json!("old"), "setup");
1801            store
1802                .scoped(Some("globex"))
1803                .set("survivor", serde_json::json!("before"), "setup");
1804            let snapshot = store.snapshot_scoped(Some("acme"));
1805            let transition_count = store.transition_count();
1806
1807            store
1808                .scoped(Some("acme"))
1809                .set("existing", serde_json::json!("new"), "candidate");
1810            store
1811                .scoped(Some("acme"))
1812                .set("created", serde_json::json!(true), "candidate");
1813            store.scoped(Some("globex")).set(
1814                "survivor",
1815                serde_json::json!("after"),
1816                "other-proposal",
1817            );
1818            store
1819                .restore_scoped(Some("acme"), snapshot, transition_count)
1820                .unwrap();
1821            store.sync().unwrap();
1822
1823            assert_eq!(
1824                store.scoped(Some("acme")).get("existing"),
1825                Some(serde_json::json!("old"))
1826            );
1827            assert!(!store.scoped(Some("acme")).exists("created"));
1828            assert_eq!(
1829                store.scoped(Some("globex")).get("survivor"),
1830                Some(serde_json::json!("after"))
1831            );
1832        }
1833
1834        let reopened = StateStore::durable(&path).unwrap();
1835        assert_eq!(
1836            reopened.scoped(Some("acme")).get("existing"),
1837            Some(serde_json::json!("old"))
1838        );
1839        assert!(!reopened.scoped(Some("acme")).exists("created"));
1840        assert_eq!(
1841            reopened.scoped(Some("globex")).get("survivor"),
1842            Some(serde_json::json!("after")),
1843            "scoped rollback must preserve unrelated tenant state after reopen"
1844        );
1845    }
1846
1847    #[test]
1848    fn ttl_reap_drops_expired_and_keeps_fresh() {
1849        let store = StateStore::new();
1850        store.set_with_ttl("short", serde_json::json!(1), "set", 0);
1851        store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
1852        store.set("forever", serde_json::json!(3), "set");
1853        // Now + 10s — short (ttl=0) is expired, long (ttl=3600) is fresh, forever has no TTL.
1854        let reaped = store
1855            .reap_expired(Utc::now() + Duration::seconds(10))
1856            .unwrap();
1857        assert_eq!(reaped, vec!["short".to_string()]);
1858        assert!(!store.exists("short"));
1859        assert_eq!(store.get("long"), Some(serde_json::json!(2)));
1860        assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
1861    }
1862
1863    #[test]
1864    fn scoped_reap_isolates_tenants() {
1865        // Each tenant has a TTL'd key that's expired. Reaping tenant "a"
1866        // must drop only a's key, leaving b's and the unscoped key intact —
1867        // one tenant's memory pressure can't evict another's (E3).
1868        let store = StateStore::new();
1869        store
1870            .scoped(Some("a"))
1871            .set_with_ttl("k", serde_json::json!(1), "set", 0);
1872        store
1873            .scoped(Some("b"))
1874            .set_with_ttl("k", serde_json::json!(2), "set", 0);
1875        store.set_with_ttl("global", serde_json::json!(3), "set", 0);
1876
1877        let future = Utc::now() + Duration::seconds(10);
1878        let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
1879        assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
1880        // Only a's key is gone.
1881        assert!(!store.scoped(Some("a")).exists("k"));
1882        assert!(store.scoped(Some("b")).exists("k"));
1883        assert!(store.exists("global"));
1884
1885        // Reaping the unscoped namespace drops only the unscoped key.
1886        let reaped = store.reap_expired_scoped(future, None).unwrap();
1887        assert_eq!(reaped, vec!["global".to_string()]);
1888        assert!(store.scoped(Some("b")).exists("k"));
1889    }
1890
1891    #[test]
1892    fn durable_ttl_compacts_journal() {
1893        let dir = tempfile::tempdir().unwrap();
1894        let path = dir.path().join("state.jsonl");
1895        {
1896            let store = StateStore::durable(&path).unwrap();
1897            for i in 0..50 {
1898                store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
1899            }
1900            store.set("survivor", serde_json::json!("kept"), "set");
1901            store.sync().unwrap();
1902            let pre = std::fs::metadata(&path).unwrap().len();
1903            // Force expiry by advancing the clock past the 0s TTL.
1904            let reaped = store
1905                .reap_expired(Utc::now() + Duration::seconds(1))
1906                .unwrap();
1907            assert_eq!(reaped.len(), 50);
1908            store.sync().unwrap();
1909            let post = std::fs::metadata(&path).unwrap().len();
1910            // Compaction should shrink the journal: 50 TTL'd writes + 1
1911            // survivor pre-compact is 51 lines; post-compact is 1 line.
1912            assert!(
1913                post < pre,
1914                "post={post} pre={pre} — compaction did not shrink"
1915            );
1916        }
1917        // Reopen — only the survivor remains.
1918        let store = StateStore::durable(&path).unwrap();
1919        assert!(!store.exists("k0"));
1920        assert!(!store.exists("k49"));
1921        assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
1922        // Version survives compaction (neo M2): survivor was written once,
1923        // so its version is 1 after a compaction-then-reopen, not reset in
1924        // a way that breaks staleness detection.
1925        assert_eq!(store.version("survivor"), Some(1));
1926    }
1927
1928    #[test]
1929    fn version_is_monotonic_and_survives_compaction() {
1930        let dir = tempfile::tempdir().unwrap();
1931        let path = dir.path().join("v.jsonl");
1932        {
1933            let store = StateStore::durable(&path).unwrap();
1934            for i in 0..3 {
1935                store.set("cfg", serde_json::json!(i), "set");
1936            }
1937            assert_eq!(store.version("cfg"), Some(3));
1938            // A TTL key that expires forces a compaction of the journal.
1939            store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
1940            store.sync().unwrap();
1941            store
1942                .reap_expired(Utc::now() + Duration::seconds(1))
1943                .unwrap();
1944            store.sync().unwrap();
1945        }
1946        // After compaction + restart, cfg's version must still be 3 — not
1947        // recounted to 1 from the collapsed single line.
1948        let store = StateStore::durable(&path).unwrap();
1949        assert_eq!(store.version("cfg"), Some(3));
1950    }
1951
1952    #[test]
1953    fn reap_bumps_version() {
1954        let store = StateStore::new();
1955        store.set("k", serde_json::json!("v"), "set");
1956        assert_eq!(store.version("k"), Some(1));
1957        store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
1958        assert_eq!(store.version("k"), Some(2));
1959        store
1960            .reap_expired(Utc::now() + Duration::seconds(1))
1961            .unwrap();
1962        // Expiry is an observable change → version advances (neo N1).
1963        assert_eq!(store.version("k"), Some(3));
1964    }
1965
1966    #[test]
1967    fn ttl_then_rewrite_without_ttl_does_not_reap() {
1968        let store = StateStore::new();
1969        store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
1970        store.set("k", serde_json::json!("b"), "second"); // no TTL
1971        let reaped = store
1972            .reap_expired(Utc::now() + Duration::seconds(10))
1973            .unwrap();
1974        assert!(reaped.is_empty());
1975        assert_eq!(store.get("k"), Some(serde_json::json!("b")));
1976    }
1977
1978    #[test]
1979    fn invalid_utf8_journal_line_is_skipped_never_applied_mangled() {
1980        let dir = tempfile::tempdir().unwrap();
1981        let path = dir.path().join("state.jsonl");
1982        // A record whose value bytes were corrupted on disk: everything
1983        // parses as JSON if the invalid byte is smoothed to U+FFFD, which
1984        // is exactly what a lossy reader would do — and then a corrupted
1985        // value would replay into state. The reader must reject the line
1986        // wholesale instead (matching the pre-batch `lines()` behavior).
1987        let mut journal = Vec::new();
1988        journal.extend_from_slice(
1989            b"{\"key\":\"good\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1990        );
1991        journal.extend_from_slice(
1992            b"{\"key\":\"corrupt\",\"old_value\":null,\"new_value\":\"va\xFFlue\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1993        );
1994        journal.extend_from_slice(
1995            b"{\"batch\":[{\"key\":\"corrupt_batch\",\"old_value\":null,\"new_value\":\"a\xFFb\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}]}\n",
1996        );
1997        std::fs::write(&path, &journal).unwrap();
1998
1999        let store = StateStore::durable(&path).unwrap();
2000        assert_eq!(store.get("good"), Some(json!(1)));
2001        assert!(
2002            !store.exists("corrupt"),
2003            "an invalid-UTF-8 record must be skipped, not applied with U+FFFD"
2004        );
2005        assert!(!store.exists("corrupt_batch"));
2006    }
2007
2008    #[test]
2009    fn malformed_journal_line_is_skipped_not_fatal() {
2010        let dir = tempfile::tempdir().unwrap();
2011        let path = dir.path().join("state.jsonl");
2012        // Plant a good line + a bad line + another good line.
2013        {
2014            std::fs::write(
2015                &path,
2016                "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
2017                 not-json\n\
2018                 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2019            )
2020            .unwrap();
2021        }
2022        let store = StateStore::durable(&path).unwrap();
2023        assert_eq!(store.get("a"), Some(serde_json::json!(1)));
2024        assert_eq!(store.get("b"), Some(serde_json::json!(2)));
2025    }
2026
2027    // ScopedStateView tests — Parslee-ai/car#187 phase 3 enforcement.
2028
2029    #[test]
2030    fn scoped_view_writes_isolate_between_tenants() {
2031        let store = StateStore::new();
2032        store.scoped(Some("acme")).set("config", json!("A"), "act");
2033        store
2034            .scoped(Some("globex"))
2035            .set("config", json!("G"), "act");
2036
2037        // Each tenant sees their own value.
2038        assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
2039        assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
2040    }
2041
2042    #[test]
2043    fn scoped_view_isolates_existence_check() {
2044        let store = StateStore::new();
2045        store.scoped(Some("acme")).set("k", json!(1), "act");
2046        assert!(store.scoped(Some("acme")).exists("k"));
2047        assert!(!store.scoped(Some("globex")).exists("k"));
2048    }
2049
2050    #[test]
2051    fn scoped_view_keys_filters_to_tenant() {
2052        let store = StateStore::new();
2053        store.scoped(Some("acme")).set("a", json!(1), "act");
2054        store.scoped(Some("acme")).set("b", json!(2), "act");
2055        store.scoped(Some("globex")).set("g", json!(9), "act");
2056        store.set("unscoped", json!(0), "act");
2057
2058        let mut acme_keys = store.scoped(Some("acme")).keys();
2059        acme_keys.sort();
2060        assert_eq!(acme_keys, vec!["a", "b"]);
2061
2062        let globex_keys = store.scoped(Some("globex")).keys();
2063        assert_eq!(globex_keys, vec!["g"]);
2064    }
2065
2066    #[test]
2067    fn unscoped_view_skips_tenant_prefixed_keys() {
2068        // Calling scoped(None) — the legacy-compat path — must NOT
2069        // accidentally expose other tenants' keys via `keys()`. This
2070        // is the inverse of the isolation contract: the unscoped
2071        // namespace shouldn't see scoped data even though it's all
2072        // in the same backing HashMap.
2073        let store = StateStore::new();
2074        store.set("legacy", json!("ok"), "act");
2075        store.scoped(Some("acme")).set("hidden", json!(42), "act");
2076
2077        let unscoped = store.scoped(None).keys();
2078        assert_eq!(unscoped, vec!["legacy"]);
2079        assert!(store.scoped(None).get("hidden").is_none());
2080    }
2081
2082    #[test]
2083    fn scoped_restore_does_not_clobber_other_tenants() {
2084        // The E2 fix: a tenant's rollback must restore only its own
2085        // namespace, leaving concurrent tenants' state intact.
2086        let store = StateStore::new();
2087        store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
2088        store
2089            .scoped(Some("globex"))
2090            .set("k", json!("globex-v1"), "a");
2091        store.set("global", json!("g-v1"), "a");
2092
2093        // Snapshot acme's namespace, then both tenants + global mutate.
2094        let acme_snap = store.scoped(Some("acme")).snapshot();
2095        store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
2096        store
2097            .scoped(Some("globex"))
2098            .set("k", json!("globex-v2"), "a");
2099        store.set("global", json!("g-v2"), "a");
2100
2101        // Roll acme back. Only acme reverts; globex + global keep v2.
2102        store.scoped(Some("acme")).restore(acme_snap, 0).unwrap();
2103        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
2104        assert_eq!(
2105            store.scoped(Some("globex")).get("k"),
2106            Some(json!("globex-v2"))
2107        );
2108        assert_eq!(store.get("global"), Some(json!("g-v2")));
2109    }
2110
2111    #[test]
2112    fn snapshot_scoped_captures_only_its_namespace() {
2113        let store = StateStore::new();
2114        store.set("global", json!(1), "a");
2115        store.scoped(Some("acme")).set("x", json!(2), "a");
2116        store.scoped(Some("globex")).set("y", json!(3), "a");
2117
2118        let acme = store.snapshot_scoped(Some("acme"));
2119        assert_eq!(acme.len(), 1);
2120        assert!(acme.contains_key("tenant:acme:x"));
2121
2122        let global = store.snapshot_scoped(None);
2123        assert_eq!(global.len(), 1);
2124        assert!(global.contains_key("global"));
2125    }
2126
2127    #[test]
2128    fn unscoped_restore_leaves_tenant_keys_intact() {
2129        // The global (None) namespace restore must not wipe tenant keys.
2130        let store = StateStore::new();
2131        store.set("g", json!("v1"), "a");
2132        store.scoped(Some("acme")).set("k", json!("acme"), "a");
2133
2134        let snap = store.snapshot_scoped(None);
2135        store.set("g", json!("v2"), "a");
2136        store.restore_scoped(None, snap, 0).unwrap();
2137
2138        assert_eq!(store.get("g"), Some(json!("v1")));
2139        // The tenant key survived the global rollback.
2140        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
2141    }
2142
2143    #[test]
2144    fn scoped_restore_preserves_other_tenants_transitions() {
2145        // C-5 regression: a tenant-scoped rollback must FILTER the shared
2146        // transition log, not truncate it — truncation dropped concurrent
2147        // tenants' post-snapshot transitions, falsifying history and
2148        // letting the reaper act on a stale "latest" transition.
2149        let store = StateStore::new();
2150        store.scoped(Some("acme")).set("k", json!("a1"), "act");
2151
2152        // acme snapshots here.
2153        let snap = store.snapshot_scoped(Some("acme"));
2154        let count = store.transition_count();
2155
2156        // Concurrent activity after the snapshot: acme mutates (to be
2157        // rolled back) and globex commits (must survive).
2158        store.scoped(Some("acme")).set("k", json!("a2"), "act");
2159        store.scoped(Some("globex")).set("g", json!("gv"), "act");
2160
2161        store.restore_scoped(Some("acme"), snap, count).unwrap();
2162
2163        // acme's post-snapshot transition is gone; globex's survived.
2164        let tail = store.transitions_since(count);
2165        assert_eq!(
2166            tail.len(),
2167            1,
2168            "exactly globex's transition survives: {tail:?}"
2169        );
2170        assert_eq!(tail[0].key, "tenant:globex:g");
2171        // Values match: acme rolled back, globex untouched.
2172        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
2173        assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
2174    }
2175
2176    #[test]
2177    fn scoped_view_delete_doesnt_touch_other_tenants() {
2178        let store = StateStore::new();
2179        store.scoped(Some("acme")).set("shared", json!(1), "act");
2180        store.scoped(Some("globex")).set("shared", json!(2), "act");
2181
2182        store.scoped(Some("acme")).delete("shared", "act");
2183        assert!(!store.scoped(Some("acme")).exists("shared"));
2184        assert!(store.scoped(Some("globex")).exists("shared"));
2185    }
2186
2187    #[test]
2188    fn empty_tenant_string_treated_as_unscoped() {
2189        // Some(""): defensive — RuntimeScope normalizes empty strings
2190        // to None at the dispatcher, but the view shouldn't trip if
2191        // a caller passes an empty tenant by mistake.
2192        let store = StateStore::new();
2193        store.scoped(Some("")).set("k", json!(1), "act");
2194        assert_eq!(store.get("k"), Some(json!(1)));
2195        assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
2196    }
2197
2198    // Atomic batch mutations — Parslee-ai/car#1140.
2199
2200    #[test]
2201    fn set_batch_applies_all_entries_with_versions_and_transitions() {
2202        let store = StateStore::new();
2203        store.set("a", json!("old"), "setup");
2204
2205        let batch = store.set_batch(
2206            vec![("a".to_string(), json!("new")), ("b".to_string(), json!(2))],
2207            "callback-action",
2208        );
2209
2210        assert_eq!(store.get("a"), Some(json!("new")));
2211        assert_eq!(store.get("b"), Some(json!(2)));
2212        assert_eq!(store.version("a"), Some(2));
2213        assert_eq!(store.version("b"), Some(1));
2214        // Returned transitions carry the pre-batch old values and the
2215        // shared attribution.
2216        assert_eq!(batch.len(), 2);
2217        assert_eq!(batch[0].old_value, Some(json!("old")));
2218        assert_eq!(batch[1].old_value, None);
2219        assert!(batch.iter().all(|t| t.action_id == "callback-action"));
2220        // The audit log gained one transition per key.
2221        assert_eq!(store.transition_count(), 3);
2222    }
2223
2224    #[test]
2225    fn empty_batch_is_a_noop() {
2226        let store = StateStore::new();
2227        assert!(store.set_batch(Vec::new(), "noop").is_empty());
2228        assert_eq!(store.transition_count(), 0);
2229    }
2230
2231    #[test]
2232    fn durable_two_and_three_key_batches_survive_reopen_with_versions() {
2233        let dir = tempfile::tempdir().unwrap();
2234        let path = dir.path().join("state.jsonl");
2235        {
2236            let store = StateStore::durable(&path).unwrap();
2237            store.set_batch(
2238                vec![("x".to_string(), json!(1)), ("y".to_string(), json!(2))],
2239                "two-key",
2240            );
2241            store.set_batch(
2242                vec![
2243                    ("x".to_string(), json!(10)),
2244                    ("y".to_string(), json!(20)),
2245                    ("z".to_string(), json!(30)),
2246                ],
2247                "three-key",
2248            );
2249            store.sync().unwrap();
2250        }
2251        let reopened = StateStore::durable(&path).unwrap();
2252        assert_eq!(reopened.get("x"), Some(json!(10)));
2253        assert_eq!(reopened.get("y"), Some(json!(20)));
2254        assert_eq!(reopened.get("z"), Some(json!(30)));
2255        assert_eq!(reopened.version("x"), Some(2));
2256        assert_eq!(reopened.version("y"), Some(2));
2257        assert_eq!(reopened.version("z"), Some(1));
2258    }
2259
2260    #[test]
2261    fn multi_key_batch_is_one_journal_line_and_single_key_keeps_legacy_shape() {
2262        let dir = tempfile::tempdir().unwrap();
2263        let path = dir.path().join("state.jsonl");
2264        let store = StateStore::durable(&path).unwrap();
2265
2266        store.set_batch(
2267            vec![
2268                ("k1".to_string(), json!(1)),
2269                ("k2".to_string(), json!(2)),
2270                ("k3".to_string(), json!(3)),
2271            ],
2272            "multi",
2273        );
2274        store.set_batch(vec![("solo".to_string(), json!(4))], "single");
2275        store.sync().unwrap();
2276
2277        let journal = std::fs::read_to_string(&path).unwrap();
2278        let lines: Vec<&str> = journal.lines().filter(|l| !l.trim().is_empty()).collect();
2279        assert_eq!(
2280            lines.len(),
2281            2,
2282            "a 3-key batch must be ONE line and a 1-key batch one line: {journal}"
2283        );
2284        let multi: Value = serde_json::from_str(lines[0]).unwrap();
2285        assert_eq!(
2286            multi["batch"].as_array().map(Vec::len),
2287            Some(3),
2288            "the multi-key line is a batch record"
2289        );
2290        // The single-entry batch keeps the legacy bare-transition shape so
2291        // earlier CAR versions can still read journals with no multi-key
2292        // batches in them.
2293        let single: StateTransition = serde_json::from_str(lines[1]).unwrap();
2294        assert_eq!(single.key, "solo");
2295    }
2296
2297    /// Shared torn-append scenario for the deterministic failure matrix:
2298    /// a base key, a complete batch, then an `n_keys` batch whose journal
2299    /// append was interrupted mid-line. Replay must surface the complete
2300    /// old state — never any prefix of the torn batch.
2301    fn torn_batch_replays_complete_old_state(n_keys: usize) {
2302        let dir = tempfile::tempdir().unwrap();
2303        let path = dir.path().join("state.jsonl");
2304        let torn_keys: Vec<String> = (1..=n_keys).map(|i| format!("t{i}")).collect();
2305        {
2306            let store = StateStore::durable(&path).unwrap();
2307            store.set("base", json!("kept"), "setup");
2308            store.set_batch(
2309                vec![("p".to_string(), json!(1)), ("q".to_string(), json!(2))],
2310                "complete-batch",
2311            );
2312            store.set_batch(
2313                torn_keys
2314                    .iter()
2315                    .map(|key| (key.clone(), json!(format!("{key}-value"))))
2316                    .collect(),
2317                "torn-batch",
2318            );
2319            store.sync().unwrap();
2320        }
2321        let full = std::fs::read_to_string(&path).unwrap();
2322        let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2323        let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2324        std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2325
2326        let store = StateStore::durable(&path).unwrap();
2327        assert_eq!(store.get("base"), Some(json!("kept")));
2328        assert_eq!(store.get("p"), Some(json!(1)));
2329        assert_eq!(store.get("q"), Some(json!(2)));
2330        for torn_key in &torn_keys {
2331            assert!(
2332                !store.exists(torn_key),
2333                "an interrupted {n_keys}-key batch append must replay as \
2334                 complete-old — no key of the torn batch may surface ({torn_key} did)"
2335            );
2336        }
2337    }
2338
2339    #[test]
2340    fn torn_two_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2341        torn_batch_replays_complete_old_state(2);
2342    }
2343
2344    #[test]
2345    fn torn_three_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2346        torn_batch_replays_complete_old_state(3);
2347    }
2348
2349    #[test]
2350    fn valid_unterminated_tail_is_terminated_so_the_next_append_survives_reopen() {
2351        let dir = tempfile::tempdir().unwrap();
2352        let path = dir.path().join("state.jsonl");
2353        {
2354            let store = StateStore::durable(&path).unwrap();
2355            store.set("base", json!("kept"), "setup");
2356            store.set_batch(
2357                vec![("b1".to_string(), json!(1)), ("b2".to_string(), json!(2))],
2358                "tail-batch",
2359            );
2360            store.sync().unwrap();
2361        }
2362        // Crash shape: the final record was fully written but its trailing
2363        // newline never reached disk.
2364        let full = std::fs::read_to_string(&path).unwrap();
2365        std::fs::write(&path, full.trim_end().as_bytes()).unwrap();
2366
2367        {
2368            let store = StateStore::durable(&path).unwrap();
2369            assert_eq!(store.get("b1"), Some(json!(1)), "the complete tail replays");
2370            store.set("after", json!("survives"), "later");
2371            store.sync().unwrap();
2372        }
2373        // Without tail repair the post-reopen append glues onto the
2374        // unterminated tail, and THIS reopen drops both records as one
2375        // malformed merged line.
2376        let reopened = StateStore::durable(&path).unwrap();
2377        assert_eq!(reopened.get("base"), Some(json!("kept")));
2378        assert_eq!(reopened.get("b1"), Some(json!(1)));
2379        assert_eq!(reopened.get("b2"), Some(json!(2)));
2380        assert_eq!(reopened.get("after"), Some(json!("survives")));
2381    }
2382
2383    #[test]
2384    fn torn_unterminated_tail_is_truncated_so_the_next_append_is_not_merged() {
2385        let dir = tempfile::tempdir().unwrap();
2386        let path = dir.path().join("state.jsonl");
2387        {
2388            let store = StateStore::durable(&path).unwrap();
2389            store.set("base", json!("kept"), "setup");
2390            store.set_batch(
2391                vec![
2392                    ("t1".to_string(), json!(1)),
2393                    ("t2".to_string(), json!(2)),
2394                    ("t3".to_string(), json!(3)),
2395                ],
2396                "torn-batch",
2397            );
2398            store.sync().unwrap();
2399        }
2400        // Crash shape: the batch append was interrupted mid-record.
2401        let full = std::fs::read_to_string(&path).unwrap();
2402        let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2403        let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2404        std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2405
2406        {
2407            let store = StateStore::durable(&path).unwrap();
2408            assert!(
2409                !store.exists("t1"),
2410                "the torn batch replays as complete-old"
2411            );
2412            store.set("after", json!("survives"), "later");
2413            store.sync().unwrap();
2414        }
2415        let reopened = StateStore::durable(&path).unwrap();
2416        assert_eq!(reopened.get("base"), Some(json!("kept")));
2417        assert_eq!(
2418            reopened.get("after"),
2419            Some(json!("survives")),
2420            "the record appended after a torn tail must not merge into it and be lost"
2421        );
2422        for torn_key in ["t1", "t2", "t3"] {
2423            assert!(!reopened.exists(torn_key));
2424        }
2425    }
2426
2427    /// Shared live-read scenario for the deterministic failure matrix. The
2428    /// `batch_mid_apply` seam pauses the batch after its FIRST key with the
2429    /// state lock still held; at that provably-partial instant the state
2430    /// lock must be unacquirable (`try_lock` fails — the deterministic
2431    /// core: no reader can enter mid-batch), and a reader admitted
2432    /// afterwards must observe the complete new state.
2433    fn reader_cannot_enter_mid_batch(n_keys: usize) {
2434        let keys: Vec<String> = (1..=n_keys).map(|i| format!("k{i}")).collect();
2435        let mut store = StateStore::new();
2436        for key in &keys {
2437            store.set(key, json!("old"), "setup");
2438        }
2439        let barrier = Arc::new(MutationRaceBarrier::new());
2440        store.batch_mid_apply = Some(barrier.clone());
2441        let store = Arc::new(store);
2442
2443        let batch_store = store.clone();
2444        let batch_keys = keys.clone();
2445        let batch = std::thread::spawn(move || {
2446            batch_store.set_batch(
2447                batch_keys
2448                    .into_iter()
2449                    .map(|key| (key, json!("new")))
2450                    .collect(),
2451                "batch",
2452            );
2453        });
2454
2455        // Deterministic partial-state instant: exactly one key applied,
2456        // n_keys - 1 still old, and the state lock held by the batch.
2457        barrier.reached.wait();
2458        assert!(
2459            store.state.try_lock().is_none(),
2460            "the state lock must be held for the whole batch — a reader \
2461             admitted here would observe a partial {n_keys}-key set"
2462        );
2463        let reader_store = store.clone();
2464        let reader_keys = keys.clone();
2465        let reader = std::thread::spawn(move || {
2466            reader_keys
2467                .iter()
2468                .rev()
2469                .map(|key| reader_store.get(key))
2470                .collect::<Vec<_>>()
2471        });
2472        barrier.release.wait();
2473        batch.join().unwrap();
2474
2475        let observed = reader.join().unwrap();
2476        assert!(
2477            observed.iter().all(|value| value == &Some(json!("new"))),
2478            "a reader admitted during a batch must see the complete new \
2479             state, got {observed:?}"
2480        );
2481    }
2482
2483    #[test]
2484    fn reader_cannot_enter_mid_two_key_batch_and_observes_complete_state() {
2485        reader_cannot_enter_mid_batch(2);
2486    }
2487
2488    #[test]
2489    fn reader_cannot_enter_mid_three_key_batch_and_observes_complete_state() {
2490        reader_cannot_enter_mid_batch(3);
2491    }
2492
2493    #[test]
2494    fn snapshot_stripped_is_single_lock_and_strips_tenant_prefixes() {
2495        // The reader map the state.snapshot RPC returns: built under one
2496        // state-lock acquisition (the same lock the mid-batch tests above
2497        // prove is held for a whole batch), with tenant prefixes stripped.
2498        let store = StateStore::new();
2499        store.set("global", json!(1), "act");
2500        store.scoped(Some("acme")).set("x", json!(2), "act");
2501        store.scoped(Some("globex")).set("y", json!(3), "act");
2502
2503        let acme = store.scoped(Some("acme")).snapshot_stripped();
2504        assert_eq!(acme, [("x".to_string(), json!(2))].into());
2505
2506        let unscoped = store.scoped(None).snapshot_stripped();
2507        assert_eq!(unscoped, [("global".to_string(), json!(1))].into());
2508    }
2509
2510    #[test]
2511    fn set_batch_refused_while_journal_durability_unknown() {
2512        let dir = tempfile::tempdir().unwrap();
2513        let path = dir.path().join("state.jsonl");
2514        let failures = RestoreFailureInjector::default();
2515        let store =
2516            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
2517                .unwrap();
2518        store.set("existing", json!("old"), "setup");
2519        let snapshot = store.snapshot();
2520        let transition_count = store.transition_count();
2521        store.set("existing", json!("new"), "candidate");
2522        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2523        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2524
2525        assert!(matches!(
2526            store.restore(snapshot, transition_count).unwrap(),
2527            RestoreDurability::DurabilityUnknown { .. }
2528        ));
2529        let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2530            store.set_batch(
2531                vec![
2532                    ("must_not_write".to_string(), json!(true)),
2533                    ("nor_this".to_string(), json!(true)),
2534                ],
2535                "later",
2536            );
2537        }));
2538        assert!(
2539            refused.is_err(),
2540            "set_batch must refuse mutation exactly like set while the journal is durability-unknown"
2541        );
2542        assert!(!store.exists("must_not_write"));
2543        assert!(!store.exists("nor_this"));
2544    }
2545
2546    #[test]
2547    fn scoped_set_batch_prefixes_all_keys_into_tenant_namespace() {
2548        let store = StateStore::new();
2549        store.scoped(Some("acme")).set_batch(
2550            vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2551            "act",
2552        );
2553        assert_eq!(store.scoped(Some("acme")).get("a"), Some(json!(1)));
2554        assert_eq!(store.scoped(Some("acme")).get("b"), Some(json!(2)));
2555        assert!(!store.scoped(Some("globex")).exists("a"));
2556        assert!(!store.scoped(None).exists("a"));
2557        assert_eq!(store.get("tenant:acme:b"), Some(json!(2)));
2558    }
2559
2560    #[test]
2561    fn batch_then_compaction_and_reopen_preserve_state_and_versions() {
2562        let dir = tempfile::tempdir().unwrap();
2563        let path = dir.path().join("state.jsonl");
2564        {
2565            let store = StateStore::durable(&path).unwrap();
2566            store.set_batch(
2567                vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2568                "batch",
2569            );
2570            // A TTL key that expires forces a journal compaction, which
2571            // rewrites the batch record as per-key snapshot lines.
2572            store.set_with_ttl("tmp", json!(true), "ttl", 0);
2573            store.sync().unwrap();
2574            store
2575                .reap_expired(Utc::now() + Duration::seconds(1))
2576                .unwrap();
2577            store.sync().unwrap();
2578        }
2579        let reopened = StateStore::durable(&path).unwrap();
2580        assert_eq!(reopened.get("a"), Some(json!(1)));
2581        assert_eq!(reopened.get("b"), Some(json!(2)));
2582        assert_eq!(reopened.version("a"), Some(1));
2583        assert_eq!(reopened.version("b"), Some(1));
2584        assert!(!reopened.exists("tmp"));
2585    }
2586}