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    /// [`Self::versioned_snapshot`] restricted to the keys a proposal running
1008    /// under `tenant` may observe ([`key_visible_to_tenant`]): shared keys and
1009    /// that tenant's own, never another tenant's. Keys keep their store names.
1010    /// This is the snapshot pre-execution checks judge a tenant-scoped
1011    /// proposal against, so neither a refusal nor a conflict report can echo
1012    /// another tenant's key or value (Parslee-ai/car#3121).
1013    pub fn versioned_snapshot_visible_to(
1014        &self,
1015        tenant: Option<&str>,
1016    ) -> (HashMap<String, Value>, HashMap<String, u64>) {
1017        let state = self.state.lock();
1018        let versions = self.versions.lock();
1019        (
1020            state
1021                .iter()
1022                .filter(|(k, _)| key_visible_to_tenant(k, tenant))
1023                .map(|(k, v)| (k.clone(), v.clone()))
1024                .collect(),
1025            versions
1026                .iter()
1027                .filter(|(k, _)| key_visible_to_tenant(k, tenant))
1028                .map(|(k, v)| (k.clone(), *v))
1029                .collect(),
1030        )
1031    }
1032
1033    /// A read-only [`car_ir::precondition::StateView`] over the keys visible
1034    /// to `tenant`, under their store names — the view a check reads a
1035    /// `ToolCall`'s keys through in a tenant-scoped proposal, since a tool
1036    /// call runs on the unscoped path but must not observe another tenant.
1037    pub fn visible_to<'a>(&'a self, tenant: Option<&'a str>) -> TenantVisibleView<'a> {
1038        TenantVisibleView {
1039            store: self,
1040            tenant,
1041        }
1042    }
1043
1044    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1045        let mut state = self.require_reconciled_state_for_mutation();
1046        let old = state.remove(key)?;
1047        let version = self.bump_version(key);
1048
1049        let t = StateTransition {
1050            key: key.to_string(),
1051            old_value: Some(old),
1052            new_value: None,
1053            action_id: action_id.to_string(),
1054            timestamp: Utc::now(),
1055            ttl_secs: None,
1056            version: Some(version),
1057        };
1058
1059        self.transitions.lock().push(t.clone());
1060        self.append_journal(&t);
1061        Some(t)
1062    }
1063
1064    /// Deep clone of current state.
1065    pub fn snapshot(&self) -> HashMap<String, Value> {
1066        self.state.lock().clone()
1067    }
1068
1069    /// Restore state from a snapshot, truncating transitions. For durable
1070    /// stores the restored snapshot replaces the JSONL journal before the
1071    /// in-memory state is published; failures leave the current state intact.
1072    pub fn restore(
1073        &self,
1074        snapshot: HashMap<String, Value>,
1075        transition_count: usize,
1076    ) -> std::io::Result<RestoreDurability> {
1077        let restore_failures = self.restore_failures.as_ref();
1078        self.restore_with_parent_sync(snapshot, transition_count, |path| {
1079            if let Some(failures) = restore_failures {
1080                failures.check(RestoreFailurePoint::ParentDirectorySync)?;
1081            }
1082            sync_parent_directory(path)
1083        })
1084    }
1085
1086    fn restore_with_parent_sync<F>(
1087        &self,
1088        snapshot: HashMap<String, Value>,
1089        transition_count: usize,
1090        sync_parent: F,
1091    ) -> std::io::Result<RestoreDurability>
1092    where
1093        F: FnOnce(&Path) -> std::io::Result<()>,
1094    {
1095        let mut state = self.state.lock();
1096        let mut transitions = self.transitions.lock();
1097        let versions = self.versions.lock();
1098        let mut restored_transitions = transitions.clone();
1099        restored_transitions.truncate(transition_count);
1100        let durability = self.replace_journal_with_snapshot_and_sync(
1101            &snapshot,
1102            &restored_transitions,
1103            &versions,
1104            sync_parent,
1105        )?;
1106        *state = snapshot;
1107        *transitions = restored_transitions;
1108        Ok(durability)
1109    }
1110
1111    /// Snapshot only the keys belonging to one tenant's namespace
1112    /// (Parslee-ai/car#187 / EPIC E task E2). `tenant = Some(id)` captures
1113    /// `tenant:<id>:*`; `tenant = None` captures the unscoped (non-`tenant:`)
1114    /// namespace. Keys are returned in their full (prefixed) form so the
1115    /// result round-trips through [`Self::restore_scoped`]. This is the
1116    /// per-tenant counterpart to [`Self::snapshot`], which captures *all*
1117    /// tenants and so can't be used for a tenant-isolated rollback.
1118    pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
1119        let state = self.state.lock();
1120        state
1121            .iter()
1122            .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
1123            .map(|(k, v)| (k.clone(), v.clone()))
1124            .collect()
1125    }
1126
1127    /// Restore a single tenant's namespace from a scoped snapshot, leaving
1128    /// every other tenant's keys untouched (EPIC E / E2). Existing keys in
1129    /// the target namespace are dropped and replaced by `snapshot`; keys
1130    /// outside it are preserved. Fixes the cross-tenant clobber where a
1131    /// rollback via the unscoped [`Self::restore`] wiped concurrent
1132    /// tenants' state.
1133    ///
1134    /// The transition log is FILTERED, not truncated (linus review C-5):
1135    /// only this tenant's post-snapshot transitions are discarded.
1136    /// Truncating shared history dropped transitions concurrent tenants
1137    /// committed after `transition_count`, which both falsified the audit
1138    /// trail and let `reap_expired*` treat another tenant's stale TTL'd
1139    /// transition as latest — deleting a live key. `transition_count` is
1140    /// the log length captured when this tenant's snapshot was taken.
1141    pub fn restore_scoped(
1142        &self,
1143        tenant: Option<&str>,
1144        snapshot: HashMap<String, Value>,
1145        transition_count: usize,
1146    ) -> std::io::Result<RestoreDurability> {
1147        let mut state = self.state.lock();
1148        let mut transitions = self.transitions.lock();
1149        let versions = self.versions.lock();
1150        let mut restored_state = state.clone();
1151        restored_state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
1152        restored_state.extend(snapshot);
1153        let mut restored_transitions = transitions.clone();
1154        if transition_count < restored_transitions.len() {
1155            // Keep everything up to the snapshot point; after it, keep only
1156            // transitions that belong to OTHER namespaces.
1157            let tail: Vec<StateTransition> = restored_transitions
1158                .drain(transition_count..)
1159                .filter(|transition| !key_in_tenant_namespace(&transition.key, tenant))
1160                .collect();
1161            restored_transitions.extend(tail);
1162        }
1163        let durability =
1164            self.replace_journal_with_snapshot(&restored_state, &restored_transitions, &versions)?;
1165        *state = restored_state;
1166        *transitions = restored_transitions;
1167        Ok(durability)
1168    }
1169
1170    pub fn transition_count(&self) -> usize {
1171        self.transitions.lock().len()
1172    }
1173
1174    pub fn transitions(&self) -> Vec<StateTransition> {
1175        self.transitions.lock().clone()
1176    }
1177
1178    pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
1179        let transitions = self.transitions.lock();
1180        let start = index.min(transitions.len());
1181        transitions[start..].to_vec()
1182    }
1183
1184    pub fn keys(&self) -> Vec<String> {
1185        self.state.lock().keys().cloned().collect()
1186    }
1187
1188    /// Replace the entire state map without recording transitions.
1189    /// Used by checkpoint restore to avoid synthetic transition history.
1190    /// Also clears the transitions log so callers of `transitions_since()`
1191    /// don't see stale history from the discarded state.
1192    pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
1193        let mut state = self.require_reconciled_state_for_mutation();
1194        *state = snapshot;
1195        self.transitions.lock().clear();
1196    }
1197
1198    /// Build a tenant-scoped view over this store
1199    /// (Parslee-ai/car#187 phase 3 enforcement).
1200    ///
1201    /// All reads / writes go through `tenant:<tenant_id>:<key>` so
1202    /// distinct tenants can't see each other's keys. `tenant = None`
1203    /// returns a view that hits the unscoped (legacy) namespace —
1204    /// callers that don't yet have a `RuntimeScope` get pre-#187
1205    /// behaviour automatically.
1206    ///
1207    /// Cheap to construct; holds a `&self` borrow plus the tenant
1208    /// string. The view's methods take the parking-lot lock the same
1209    /// way the unscoped methods do.
1210    pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
1211        ScopedStateView {
1212            store: self,
1213            tenant,
1214        }
1215    }
1216}
1217
1218/// Apply one journaled transition during replay. Last-write-wins per key;
1219/// TTLs that already expired are dropped at replay time so stale data never
1220/// surfaces on first read. The version counter restores the persisted value
1221/// when present (survives compaction) and counts transitions for legacy
1222/// pre-versioning journals, taking the max so the counter stays monotonic
1223/// across a mix of compacted and appended lines.
1224fn replay_transition(
1225    state: &mut HashMap<String, Value>,
1226    transitions: &mut Vec<StateTransition>,
1227    versions: &mut HashMap<String, u64>,
1228    now: DateTime<Utc>,
1229    t: StateTransition,
1230) {
1231    if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
1232        if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
1233            state.remove(&t.key);
1234        } else {
1235            state.insert(t.key.clone(), value.clone());
1236        }
1237    } else if let Some(value) = &t.new_value {
1238        state.insert(t.key.clone(), value.clone());
1239    } else {
1240        state.remove(&t.key);
1241    }
1242    let entry = versions.entry(t.key.clone()).or_insert(0);
1243    let restored = t.version.unwrap_or(*entry + 1);
1244    *entry = (*entry).max(restored);
1245    transitions.push(t);
1246}
1247
1248/// `key` as the store holds it in `tenant`'s namespace: `tenant:<id>:<key>`
1249/// for a non-empty tenant, `key` itself otherwise. This is the key every
1250/// [`ScopedStateView`] read or write goes to; a check that judges a
1251/// tenant-scoped action against a whole-store snapshot resolves the action's
1252/// keys through it (Parslee-ai/car#3121).
1253pub fn tenant_key(tenant: Option<&str>, key: &str) -> String {
1254    match tenant {
1255        Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
1256        _ => key.to_string(),
1257    }
1258}
1259
1260/// Whether a proposal running under `tenant` may observe `key`: with no
1261/// tenant, every key; with one, the shared (non-`tenant:`) keys and that
1262/// tenant's own `tenant:<id>:` keys, never another tenant's.
1263pub fn key_visible_to_tenant(key: &str, tenant: Option<&str>) -> bool {
1264    match tenant {
1265        Some(t) if !t.is_empty() => {
1266            !key.starts_with("tenant:") || key_in_tenant_namespace(key, tenant)
1267        }
1268        _ => true,
1269    }
1270}
1271
1272/// See [`StateStore::visible_to`].
1273pub struct TenantVisibleView<'a> {
1274    store: &'a StateStore,
1275    tenant: Option<&'a str>,
1276}
1277
1278impl car_ir::precondition::StateView for TenantVisibleView<'_> {
1279    fn get_value(&self, key: &str) -> Option<Value> {
1280        key_visible_to_tenant(key, self.tenant)
1281            .then(|| self.store.get(key))
1282            .flatten()
1283    }
1284    fn key_exists(&self, key: &str) -> bool {
1285        key_visible_to_tenant(key, self.tenant) && self.store.exists(key)
1286    }
1287}
1288
1289/// Whether `key` belongs to the namespace identified by `tenant`.
1290///
1291/// `Some(id)` (non-empty) → keys prefixed `tenant:<id>:`. `None` or empty →
1292/// the unscoped namespace: every key that is NOT `tenant:`-prefixed (so an
1293/// unscoped snapshot/restore never touches any tenant's keys). This is the
1294/// predicate that makes [`StateStore::snapshot_scoped`] /
1295/// [`StateStore::restore_scoped`] tenant-isolated.
1296fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
1297    match tenant {
1298        Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
1299        _ => !key.starts_with("tenant:"),
1300    }
1301}
1302
1303/// Tenant-scoped view over a [`StateStore`]. All key arguments are
1304/// transparently prefixed with `tenant:<tenant_id>:` before hitting
1305/// the underlying store; on the way out, the prefix is stripped so
1306/// callers see their original keys.
1307///
1308/// Construct via [`StateStore::scoped`]. When `tenant` is `None`,
1309/// the prefix is empty and the view is functionally equivalent to
1310/// the unscoped methods on `StateStore` — useful for code paths
1311/// that always go through this view regardless of whether scope is
1312/// active.
1313///
1314/// # Isolation guarantee
1315///
1316/// Two views with distinct `tenant` strings cannot observe each
1317/// other's writes through `get` / `exists` / `keys`. The transitions
1318/// log still records the full (prefixed) key so audit / replay sees
1319/// the actual storage layout.
1320///
1321/// # What isolation does *not* cover (phase 3 follow-ups)
1322///
1323/// - `StateStore::snapshot` / `restore` are deliberately unscoped —
1324///   they're called at proposal boundaries for rollback and need to
1325///   see the whole map. Per-tenant partial rollback is a known
1326///   concurrency hole when multiple proposals run interleaved; the
1327///   pre-#187 baseline has the same issue, and fixing it cleanly
1328///   requires either serializing per-tenant or extending the
1329///   transactional model. Tracked as a follow-up.
1330/// - The journal file (when durability is on) records full
1331///   prefixed keys. Operators rotating tenants out can grep the
1332///   journal by prefix.
1333pub struct ScopedStateView<'a> {
1334    store: &'a StateStore,
1335    tenant: Option<&'a str>,
1336}
1337
1338impl<'a> ScopedStateView<'a> {
1339    fn full_key(&self, key: &str) -> String {
1340        tenant_key(self.tenant, key)
1341    }
1342
1343    fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
1344        match self.tenant {
1345            Some(t) if !t.is_empty() => {
1346                let prefix = format!("tenant:{t}:");
1347                full.strip_prefix(&prefix)
1348            }
1349            _ => Some(full),
1350        }
1351    }
1352
1353    pub fn get(&self, key: &str) -> Option<Value> {
1354        self.store.get(&self.full_key(key))
1355    }
1356
1357    pub fn get_or(&self, key: &str, default: Value) -> Value {
1358        self.store.get_or(&self.full_key(key), default)
1359    }
1360
1361    /// Snapshot only this tenant's namespace (EPIC E / E2) — the scoped
1362    /// counterpart to `StateStore::snapshot`, safe to pair with
1363    /// [`Self::restore`] for a tenant-isolated rollback.
1364    pub fn snapshot(&self) -> HashMap<String, Value> {
1365        self.store.snapshot_scoped(self.tenant)
1366    }
1367
1368    /// Single-lock snapshot of this tenant's namespace with the
1369    /// `tenant:<id>:` prefix stripped — the map a reader surface (e.g. the
1370    /// `state.snapshot` RPC) should return. Taken under ONE state-lock
1371    /// acquisition, so it can never interleave with a concurrent mutation
1372    /// batch the way a `keys()`-then-`get()` loop can — the result is the
1373    /// complete old state or the complete new state, never a mix
1374    /// (Parslee-ai/car#1140). Unlike [`Self::snapshot`], the stripped keys
1375    /// here do NOT round-trip through [`Self::restore`].
1376    pub fn snapshot_stripped(&self) -> HashMap<String, Value> {
1377        self.store
1378            .snapshot_scoped(self.tenant)
1379            .into_iter()
1380            .filter_map(|(key, value)| {
1381                self.strip_prefix(&key)
1382                    .map(|stripped| (stripped.to_string(), value))
1383            })
1384            .collect()
1385    }
1386
1387    /// Restore only this tenant's namespace from a scoped snapshot, leaving
1388    /// other tenants untouched (EPIC E / E2).
1389    pub fn restore(
1390        &self,
1391        snapshot: HashMap<String, Value>,
1392        transition_count: usize,
1393    ) -> std::io::Result<RestoreDurability> {
1394        self.store
1395            .restore_scoped(self.tenant, snapshot, transition_count)
1396    }
1397
1398    pub fn exists(&self, key: &str) -> bool {
1399        self.store.exists(&self.full_key(key))
1400    }
1401
1402    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
1403        self.store.set(&self.full_key(key), value, action_id)
1404    }
1405
1406    pub fn set_with_ttl(
1407        &self,
1408        key: &str,
1409        value: Value,
1410        action_id: &str,
1411        ttl_secs: u64,
1412    ) -> StateTransition {
1413        self.store
1414            .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
1415    }
1416
1417    /// Batch counterpart to [`Self::set`] — every entry applies inside one
1418    /// atomic mutation boundary, with keys transparently prefixed into this
1419    /// tenant's namespace (see [`StateStore::set_batch`]).
1420    pub fn set_batch(
1421        &self,
1422        entries: Vec<(String, Value)>,
1423        action_id: &str,
1424    ) -> Vec<StateTransition> {
1425        let prefixed = entries
1426            .into_iter()
1427            .map(|(key, value)| (self.full_key(&key), value))
1428            .collect();
1429        self.store.set_batch(prefixed, action_id)
1430    }
1431
1432    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
1433        self.store.delete(&self.full_key(key), action_id)
1434    }
1435
1436    /// Return keys belonging to this tenant only, with the
1437    /// `tenant:<id>:` prefix stripped so callers see their original
1438    /// key names. Unscoped views (no tenant) return only keys that
1439    /// don't start with `tenant:` — preventing accidental visibility
1440    /// of scoped state through a legacy code path.
1441    pub fn keys(&self) -> Vec<String> {
1442        self.store
1443            .keys()
1444            .into_iter()
1445            .filter_map(|k| {
1446                if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
1447                    self.strip_prefix(&k).map(str::to_string)
1448                } else if k.starts_with("tenant:") {
1449                    None
1450                } else {
1451                    Some(k)
1452                }
1453            })
1454            .collect()
1455    }
1456}
1457
1458impl Default for StateStore {
1459    fn default() -> Self {
1460        Self::new()
1461    }
1462}
1463
1464impl car_ir::precondition::StateView for StateStore {
1465    fn get_value(&self, key: &str) -> Option<Value> {
1466        self.get(key)
1467    }
1468    fn key_exists(&self, key: &str) -> bool {
1469        self.exists(key)
1470    }
1471}
1472
1473/// A scoped view answers preconditions and state dependencies from its own
1474/// namespace, the same keys its tenant's actions read and write.
1475impl car_ir::precondition::StateView for ScopedStateView<'_> {
1476    fn get_value(&self, key: &str) -> Option<Value> {
1477        self.get(key)
1478    }
1479    fn key_exists(&self, key: &str) -> bool {
1480        self.exists(key)
1481    }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486    use super::*;
1487    use serde_json::json;
1488
1489    #[test]
1490    fn set_and_get() {
1491        let store = StateStore::new();
1492        store.set("x", Value::from(42), "test");
1493        assert_eq!(store.get("x"), Some(Value::from(42)));
1494    }
1495
1496    #[test]
1497    fn exists() {
1498        let store = StateStore::new();
1499        assert!(!store.exists("x"));
1500        store.set("x", Value::from(1), "test");
1501        assert!(store.exists("x"));
1502    }
1503
1504    #[test]
1505    fn delete() {
1506        let store = StateStore::new();
1507        store.set("x", Value::from(1), "test");
1508        let t = store.delete("x", "test");
1509        assert!(t.is_some());
1510        assert!(!store.exists("x"));
1511    }
1512
1513    #[test]
1514    fn delete_nonexistent() {
1515        let store = StateStore::new();
1516        assert!(store.delete("x", "test").is_none());
1517    }
1518
1519    #[test]
1520    fn snapshot_and_restore() {
1521        let store = StateStore::new();
1522        store.set("x", Value::from(1), "a");
1523        let snap = store.snapshot();
1524        let tc = store.transition_count();
1525
1526        store.set("y", Value::from(2), "b");
1527        assert!(store.exists("y"));
1528
1529        store.restore(snap, tc).unwrap();
1530        assert!(store.exists("x"));
1531        assert!(!store.exists("y"));
1532        assert_eq!(store.transition_count(), 1);
1533    }
1534
1535    #[test]
1536    fn transitions_logged() {
1537        let store = StateStore::new();
1538        store.set("a", Value::from(1), "act1");
1539        store.set("b", Value::from(2), "act2");
1540
1541        let transitions = store.transitions();
1542        assert_eq!(transitions.len(), 2);
1543        assert_eq!(transitions[0].key, "a");
1544        assert_eq!(transitions[1].key, "b");
1545    }
1546
1547    #[test]
1548    fn transitions_since() {
1549        let store = StateStore::new();
1550        store.set("a", Value::from(1), "act1");
1551        let idx = store.transition_count();
1552        store.set("b", Value::from(2), "act2");
1553
1554        let since = store.transitions_since(idx);
1555        assert_eq!(since.len(), 1);
1556        assert_eq!(since[0].key, "b");
1557    }
1558
1559    #[test]
1560    fn transition_records_old_value() {
1561        let store = StateStore::new();
1562        store.set("x", Value::from(1), "first");
1563        store.set("x", Value::from(2), "second");
1564
1565        let transitions = store.transitions();
1566        assert_eq!(transitions[1].old_value, Some(Value::from(1)));
1567        assert_eq!(transitions[1].new_value, Some(Value::from(2)));
1568    }
1569
1570    #[test]
1571    fn keys() {
1572        let store = StateStore::new();
1573        store.set("a", Value::from(1), "t");
1574        store.set("b", Value::from(2), "t");
1575        let mut keys = store.keys();
1576        keys.sort();
1577        assert_eq!(keys, vec!["a", "b"]);
1578    }
1579
1580    #[test]
1581    fn transitions_since_after_restore_does_not_panic() {
1582        let store = StateStore::new();
1583        store.set("a", serde_json::json!(1), "test");
1584        store.set("b", serde_json::json!(2), "test");
1585        let count_before = store.transition_count(); // 2
1586
1587        // Restore to empty, truncating transitions to 0
1588        store.restore(HashMap::new(), 0).unwrap();
1589
1590        // Using the stale count_before (2) should not panic
1591        let result = store.transitions_since(count_before);
1592        assert!(result.is_empty());
1593    }
1594
1595    #[test]
1596    fn transitions_since_normal_usage() {
1597        let store = StateStore::new();
1598        store.set("a", serde_json::json!(1), "test");
1599        let mark = store.transition_count();
1600        store.set("b", serde_json::json!(2), "test");
1601        let since = store.transitions_since(mark);
1602        assert_eq!(since.len(), 1);
1603        assert_eq!(since[0].key, "b");
1604    }
1605
1606    #[test]
1607    fn replace_all_swaps_state_without_transitions() {
1608        let store = StateStore::new();
1609        store.set("old_key", serde_json::json!("old"), "setup");
1610
1611        let mut new_state = HashMap::new();
1612        new_state.insert("new_key".to_string(), serde_json::json!("new"));
1613        store.replace_all(new_state);
1614
1615        assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
1616        assert_eq!(store.get("old_key"), None);
1617        // After replace_all, transitions should be cleared (not preserved)
1618        assert_eq!(store.transition_count(), 0);
1619    }
1620
1621    #[test]
1622    fn durable_store_survives_reopen() {
1623        let dir = tempfile::tempdir().unwrap();
1624        let path = dir.path().join("state.jsonl");
1625        {
1626            let store = StateStore::durable(&path).unwrap();
1627            store.set("agent", serde_json::json!("planner"), "boot");
1628            store.set("turns", serde_json::json!(42), "tick");
1629            store.sync().unwrap();
1630        }
1631        let store = StateStore::durable(&path).unwrap();
1632        assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
1633        assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
1634    }
1635
1636    #[test]
1637    fn durable_store_replays_deletes() {
1638        let dir = tempfile::tempdir().unwrap();
1639        let path = dir.path().join("state.jsonl");
1640        {
1641            let store = StateStore::durable(&path).unwrap();
1642            store.set("transient", serde_json::json!("x"), "boot");
1643            store.delete("transient", "rm");
1644            store.sync().unwrap();
1645        }
1646        let store = StateStore::durable(&path).unwrap();
1647        assert!(!store.exists("transient"));
1648    }
1649
1650    #[test]
1651    fn durable_restore_survives_reopen() {
1652        let dir = tempfile::tempdir().unwrap();
1653        let path = dir.path().join("state.jsonl");
1654        {
1655            let store = StateStore::durable(&path).unwrap();
1656            store.set("existing", serde_json::json!("old"), "setup");
1657            let snapshot = store.snapshot();
1658            let transition_count = store.transition_count();
1659
1660            store.set("existing", serde_json::json!("new"), "candidate");
1661            store.set("created", serde_json::json!(true), "candidate");
1662            store.restore(snapshot, transition_count).unwrap();
1663            store.sync().unwrap();
1664
1665            assert_eq!(store.get("existing"), Some(serde_json::json!("old")));
1666            assert!(!store.exists("created"));
1667        }
1668
1669        let reopened = StateStore::durable(&path).unwrap();
1670        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1671        assert!(
1672            !reopened.exists("created"),
1673            "a full rollback must not resurrect candidate state after reopen"
1674        );
1675    }
1676
1677    #[test]
1678    fn abandoned_replacement_temps_do_not_block_restore_compaction_or_reopen() {
1679        let dir = tempfile::tempdir().unwrap();
1680        let path = dir.path().join("state.jsonl");
1681        {
1682            let store = StateStore::durable(&path).unwrap();
1683            store.set("existing", serde_json::json!("old"), "setup");
1684            store.sync().unwrap();
1685        }
1686
1687        let abandoned_transition = StateTransition {
1688            key: "intruder".to_string(),
1689            old_value: None,
1690            new_value: Some(serde_json::json!("must-not-replay")),
1691            action_id: "abandoned-temp".to_string(),
1692            timestamp: Utc::now(),
1693            ttl_secs: None,
1694            version: Some(99),
1695        };
1696        let abandoned_contents = format!(
1697            "{}\n",
1698            serde_json::to_string(&abandoned_transition).unwrap()
1699        );
1700        let abandoned_temps = [
1701            path.with_extension("jsonl.restore.tmp"),
1702            dir.path().join(".state.jsonl.restore.123.456.0.tmp"),
1703            dir.path().join(".state.jsonl.restore.789.012.1.tmp"),
1704        ];
1705        for temp in &abandoned_temps {
1706            std::fs::write(temp, &abandoned_contents).unwrap();
1707        }
1708
1709        let store = StateStore::durable(&path).unwrap();
1710        assert!(!store.exists("intruder"));
1711        let snapshot = store.snapshot();
1712        let transition_count = store.transition_count();
1713        store.set("existing", serde_json::json!("new"), "candidate");
1714        store.set("candidate_only", serde_json::json!(true), "candidate");
1715        assert_eq!(
1716            store.restore(snapshot, transition_count).unwrap(),
1717            RestoreDurability::Durable
1718        );
1719        store.set_with_ttl("expired", serde_json::json!(true), "ttl", 0);
1720        store.sync().unwrap();
1721        assert_eq!(
1722            store
1723                .reap_expired(Utc::now() + Duration::seconds(1))
1724                .unwrap(),
1725            vec!["expired".to_string()]
1726        );
1727        store.sync().unwrap();
1728        drop(store);
1729
1730        let reopened = StateStore::durable(&path).unwrap();
1731        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1732        assert!(!reopened.exists("candidate_only"));
1733        assert!(!reopened.exists("expired"));
1734        assert!(!reopened.exists("intruder"));
1735        for temp in &abandoned_temps {
1736            assert!(temp.exists(), "abandoned sibling temp should be ignored");
1737        }
1738    }
1739
1740    #[test]
1741    fn durable_restore_parent_sync_failure_keeps_live_and_reopened_state_coherent() {
1742        let dir = tempfile::tempdir().unwrap();
1743        let path = dir.path().join("state.jsonl");
1744        let store = StateStore::durable(&path).unwrap();
1745        store.set("existing", serde_json::json!("old"), "setup");
1746        let snapshot = store.snapshot();
1747        let transition_count = store.transition_count();
1748        store.set("existing", serde_json::json!("new"), "candidate");
1749
1750        #[cfg(unix)]
1751        let original_mode = {
1752            use std::os::unix::fs::PermissionsExt;
1753            std::fs::metadata(&path).unwrap().permissions().mode()
1754        };
1755        let durability = store
1756            .restore_with_parent_sync(snapshot, transition_count, |_| {
1757                Err(std::io::Error::other(
1758                    "injected parent directory sync failure",
1759                ))
1760            })
1761            .unwrap();
1762        assert!(matches!(
1763            durability,
1764            RestoreDurability::DurabilityUnknown { ref error }
1765                if error.contains("parent directory sync failure")
1766        ));
1767        assert_eq!(
1768            store.get("existing"),
1769            Some(serde_json::json!("old")),
1770            "once rename publishes the rollback journal, memory must adopt the same state"
1771        );
1772        assert!(
1773            !path.with_extension("jsonl.restore.tmp").exists(),
1774            "replacement temp must be cleaned after atomic publication"
1775        );
1776        #[cfg(unix)]
1777        {
1778            use std::os::unix::fs::PermissionsExt;
1779            assert_eq!(
1780                std::fs::metadata(&path).unwrap().permissions().mode(),
1781                original_mode,
1782                "replacement must preserve journal permissions"
1783            );
1784        }
1785        store.set("after_unknown", serde_json::json!("safe"), "later");
1786        store.sync().unwrap();
1787        drop(store);
1788
1789        let reopened = StateStore::durable(&path).unwrap();
1790        assert_eq!(
1791            reopened.get("existing"),
1792            Some(serde_json::json!("old")),
1793            "the visible journal and live rollback state must agree"
1794        );
1795        assert_eq!(
1796            reopened.get("after_unknown"),
1797            Some(serde_json::json!("safe")),
1798            "a later write must first reconcile the pending parent sync"
1799        );
1800    }
1801
1802    #[test]
1803    fn durability_unknown_store_refuses_write_until_parent_sync_reconciles() {
1804        let dir = tempfile::tempdir().unwrap();
1805        let path = dir.path().join("state.jsonl");
1806        let failures = RestoreFailureInjector::default();
1807        let store =
1808            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1809                .unwrap();
1810        store.set("existing", serde_json::json!("old"), "setup");
1811        let snapshot = store.snapshot();
1812        let transition_count = store.transition_count();
1813        store.set("existing", serde_json::json!("new"), "candidate");
1814        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1815        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1816
1817        assert!(matches!(
1818            store.restore(snapshot, transition_count).unwrap(),
1819            RestoreDurability::DurabilityUnknown { .. }
1820        ));
1821        let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1822            store.set("must_not_write", serde_json::json!(true), "later");
1823        }));
1824        assert!(refused.is_err());
1825        assert!(!store.exists("must_not_write"));
1826        drop(store);
1827
1828        let reopened = StateStore::durable(&path).unwrap();
1829        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1830        assert!(!reopened.exists("must_not_write"));
1831    }
1832
1833    #[test]
1834    fn mutation_waiting_for_state_reconciles_restore_durability_unknown() {
1835        let dir = tempfile::tempdir().unwrap();
1836        let path = dir.path().join("state.jsonl");
1837        let failures = RestoreFailureInjector::default();
1838        let mut store =
1839            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
1840                .unwrap();
1841        store.set("existing", serde_json::json!("old"), "setup");
1842        let snapshot = store.snapshot();
1843        let transition_count = store.transition_count();
1844        store.set("existing", serde_json::json!("candidate"), "candidate");
1845
1846        let barrier = Arc::new(MutationRaceBarrier::new());
1847        store.mutation_before_state_lock = Some(barrier.clone());
1848        let store = Arc::new(store);
1849        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1850        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
1851
1852        let mutation_store = store.clone();
1853        let mutation = std::thread::spawn(move || {
1854            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1855                mutation_store.set("must_not_write", serde_json::json!(true), "racing");
1856            }))
1857        });
1858
1859        barrier.reached.wait();
1860        assert!(matches!(
1861            store.restore(snapshot, transition_count).unwrap(),
1862            RestoreDurability::DurabilityUnknown { .. }
1863        ));
1864        barrier.release.wait();
1865
1866        assert!(
1867            mutation.join().unwrap().is_err(),
1868            "a mutation admitted before restore must reconcile the journal after acquiring state"
1869        );
1870        assert!(!store.exists("must_not_write"));
1871        drop(store);
1872
1873        let reopened = StateStore::durable(&path).unwrap();
1874        assert_eq!(reopened.get("existing"), Some(serde_json::json!("old")));
1875        assert!(!reopened.exists("must_not_write"));
1876    }
1877
1878    #[test]
1879    fn durable_scoped_restore_survives_reopen_without_clobbering_other_tenants() {
1880        let dir = tempfile::tempdir().unwrap();
1881        let path = dir.path().join("state.jsonl");
1882        {
1883            let store = StateStore::durable(&path).unwrap();
1884            store
1885                .scoped(Some("acme"))
1886                .set("existing", serde_json::json!("old"), "setup");
1887            store
1888                .scoped(Some("globex"))
1889                .set("survivor", serde_json::json!("before"), "setup");
1890            let snapshot = store.snapshot_scoped(Some("acme"));
1891            let transition_count = store.transition_count();
1892
1893            store
1894                .scoped(Some("acme"))
1895                .set("existing", serde_json::json!("new"), "candidate");
1896            store
1897                .scoped(Some("acme"))
1898                .set("created", serde_json::json!(true), "candidate");
1899            store.scoped(Some("globex")).set(
1900                "survivor",
1901                serde_json::json!("after"),
1902                "other-proposal",
1903            );
1904            store
1905                .restore_scoped(Some("acme"), snapshot, transition_count)
1906                .unwrap();
1907            store.sync().unwrap();
1908
1909            assert_eq!(
1910                store.scoped(Some("acme")).get("existing"),
1911                Some(serde_json::json!("old"))
1912            );
1913            assert!(!store.scoped(Some("acme")).exists("created"));
1914            assert_eq!(
1915                store.scoped(Some("globex")).get("survivor"),
1916                Some(serde_json::json!("after"))
1917            );
1918        }
1919
1920        let reopened = StateStore::durable(&path).unwrap();
1921        assert_eq!(
1922            reopened.scoped(Some("acme")).get("existing"),
1923            Some(serde_json::json!("old"))
1924        );
1925        assert!(!reopened.scoped(Some("acme")).exists("created"));
1926        assert_eq!(
1927            reopened.scoped(Some("globex")).get("survivor"),
1928            Some(serde_json::json!("after")),
1929            "scoped rollback must preserve unrelated tenant state after reopen"
1930        );
1931    }
1932
1933    #[test]
1934    fn ttl_reap_drops_expired_and_keeps_fresh() {
1935        let store = StateStore::new();
1936        store.set_with_ttl("short", serde_json::json!(1), "set", 0);
1937        store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
1938        store.set("forever", serde_json::json!(3), "set");
1939        // Now + 10s — short (ttl=0) is expired, long (ttl=3600) is fresh, forever has no TTL.
1940        let reaped = store
1941            .reap_expired(Utc::now() + Duration::seconds(10))
1942            .unwrap();
1943        assert_eq!(reaped, vec!["short".to_string()]);
1944        assert!(!store.exists("short"));
1945        assert_eq!(store.get("long"), Some(serde_json::json!(2)));
1946        assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
1947    }
1948
1949    #[test]
1950    fn scoped_reap_isolates_tenants() {
1951        // Each tenant has a TTL'd key that's expired. Reaping tenant "a"
1952        // must drop only a's key, leaving b's and the unscoped key intact —
1953        // one tenant's memory pressure can't evict another's (E3).
1954        let store = StateStore::new();
1955        store
1956            .scoped(Some("a"))
1957            .set_with_ttl("k", serde_json::json!(1), "set", 0);
1958        store
1959            .scoped(Some("b"))
1960            .set_with_ttl("k", serde_json::json!(2), "set", 0);
1961        store.set_with_ttl("global", serde_json::json!(3), "set", 0);
1962
1963        let future = Utc::now() + Duration::seconds(10);
1964        let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
1965        assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
1966        // Only a's key is gone.
1967        assert!(!store.scoped(Some("a")).exists("k"));
1968        assert!(store.scoped(Some("b")).exists("k"));
1969        assert!(store.exists("global"));
1970
1971        // Reaping the unscoped namespace drops only the unscoped key.
1972        let reaped = store.reap_expired_scoped(future, None).unwrap();
1973        assert_eq!(reaped, vec!["global".to_string()]);
1974        assert!(store.scoped(Some("b")).exists("k"));
1975    }
1976
1977    #[test]
1978    fn durable_ttl_compacts_journal() {
1979        let dir = tempfile::tempdir().unwrap();
1980        let path = dir.path().join("state.jsonl");
1981        {
1982            let store = StateStore::durable(&path).unwrap();
1983            for i in 0..50 {
1984                store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
1985            }
1986            store.set("survivor", serde_json::json!("kept"), "set");
1987            store.sync().unwrap();
1988            let pre = std::fs::metadata(&path).unwrap().len();
1989            // Force expiry by advancing the clock past the 0s TTL.
1990            let reaped = store
1991                .reap_expired(Utc::now() + Duration::seconds(1))
1992                .unwrap();
1993            assert_eq!(reaped.len(), 50);
1994            store.sync().unwrap();
1995            let post = std::fs::metadata(&path).unwrap().len();
1996            // Compaction should shrink the journal: 50 TTL'd writes + 1
1997            // survivor pre-compact is 51 lines; post-compact is 1 line.
1998            assert!(
1999                post < pre,
2000                "post={post} pre={pre} — compaction did not shrink"
2001            );
2002        }
2003        // Reopen — only the survivor remains.
2004        let store = StateStore::durable(&path).unwrap();
2005        assert!(!store.exists("k0"));
2006        assert!(!store.exists("k49"));
2007        assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
2008        // Version survives compaction (neo M2): survivor was written once,
2009        // so its version is 1 after a compaction-then-reopen, not reset in
2010        // a way that breaks staleness detection.
2011        assert_eq!(store.version("survivor"), Some(1));
2012    }
2013
2014    #[test]
2015    fn version_is_monotonic_and_survives_compaction() {
2016        let dir = tempfile::tempdir().unwrap();
2017        let path = dir.path().join("v.jsonl");
2018        {
2019            let store = StateStore::durable(&path).unwrap();
2020            for i in 0..3 {
2021                store.set("cfg", serde_json::json!(i), "set");
2022            }
2023            assert_eq!(store.version("cfg"), Some(3));
2024            // A TTL key that expires forces a compaction of the journal.
2025            store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
2026            store.sync().unwrap();
2027            store
2028                .reap_expired(Utc::now() + Duration::seconds(1))
2029                .unwrap();
2030            store.sync().unwrap();
2031        }
2032        // After compaction + restart, cfg's version must still be 3 — not
2033        // recounted to 1 from the collapsed single line.
2034        let store = StateStore::durable(&path).unwrap();
2035        assert_eq!(store.version("cfg"), Some(3));
2036    }
2037
2038    #[test]
2039    fn reap_bumps_version() {
2040        let store = StateStore::new();
2041        store.set("k", serde_json::json!("v"), "set");
2042        assert_eq!(store.version("k"), Some(1));
2043        store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
2044        assert_eq!(store.version("k"), Some(2));
2045        store
2046            .reap_expired(Utc::now() + Duration::seconds(1))
2047            .unwrap();
2048        // Expiry is an observable change → version advances (neo N1).
2049        assert_eq!(store.version("k"), Some(3));
2050    }
2051
2052    #[test]
2053    fn ttl_then_rewrite_without_ttl_does_not_reap() {
2054        let store = StateStore::new();
2055        store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
2056        store.set("k", serde_json::json!("b"), "second"); // no TTL
2057        let reaped = store
2058            .reap_expired(Utc::now() + Duration::seconds(10))
2059            .unwrap();
2060        assert!(reaped.is_empty());
2061        assert_eq!(store.get("k"), Some(serde_json::json!("b")));
2062    }
2063
2064    #[test]
2065    fn invalid_utf8_journal_line_is_skipped_never_applied_mangled() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let path = dir.path().join("state.jsonl");
2068        // A record whose value bytes were corrupted on disk: everything
2069        // parses as JSON if the invalid byte is smoothed to U+FFFD, which
2070        // is exactly what a lossy reader would do — and then a corrupted
2071        // value would replay into state. The reader must reject the line
2072        // wholesale instead (matching the pre-batch `lines()` behavior).
2073        let mut journal = Vec::new();
2074        journal.extend_from_slice(
2075            b"{\"key\":\"good\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2076        );
2077        journal.extend_from_slice(
2078            b"{\"key\":\"corrupt\",\"old_value\":null,\"new_value\":\"va\xFFlue\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2079        );
2080        journal.extend_from_slice(
2081            b"{\"batch\":[{\"key\":\"corrupt_batch\",\"old_value\":null,\"new_value\":\"a\xFFb\",\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}]}\n",
2082        );
2083        std::fs::write(&path, &journal).unwrap();
2084
2085        let store = StateStore::durable(&path).unwrap();
2086        assert_eq!(store.get("good"), Some(json!(1)));
2087        assert!(
2088            !store.exists("corrupt"),
2089            "an invalid-UTF-8 record must be skipped, not applied with U+FFFD"
2090        );
2091        assert!(!store.exists("corrupt_batch"));
2092    }
2093
2094    #[test]
2095    fn malformed_journal_line_is_skipped_not_fatal() {
2096        let dir = tempfile::tempdir().unwrap();
2097        let path = dir.path().join("state.jsonl");
2098        // Plant a good line + a bad line + another good line.
2099        {
2100            std::fs::write(
2101                &path,
2102                "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
2103                 not-json\n\
2104                 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
2105            )
2106            .unwrap();
2107        }
2108        let store = StateStore::durable(&path).unwrap();
2109        assert_eq!(store.get("a"), Some(serde_json::json!(1)));
2110        assert_eq!(store.get("b"), Some(serde_json::json!(2)));
2111    }
2112
2113    // ScopedStateView tests — Parslee-ai/car#187 phase 3 enforcement.
2114
2115    #[test]
2116    fn scoped_view_writes_isolate_between_tenants() {
2117        let store = StateStore::new();
2118        store.scoped(Some("acme")).set("config", json!("A"), "act");
2119        store
2120            .scoped(Some("globex"))
2121            .set("config", json!("G"), "act");
2122
2123        // Each tenant sees their own value.
2124        assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
2125        assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
2126    }
2127
2128    #[test]
2129    fn scoped_view_isolates_existence_check() {
2130        let store = StateStore::new();
2131        store.scoped(Some("acme")).set("k", json!(1), "act");
2132        assert!(store.scoped(Some("acme")).exists("k"));
2133        assert!(!store.scoped(Some("globex")).exists("k"));
2134    }
2135
2136    #[test]
2137    fn scoped_view_keys_filters_to_tenant() {
2138        let store = StateStore::new();
2139        store.scoped(Some("acme")).set("a", json!(1), "act");
2140        store.scoped(Some("acme")).set("b", json!(2), "act");
2141        store.scoped(Some("globex")).set("g", json!(9), "act");
2142        store.set("unscoped", json!(0), "act");
2143
2144        let mut acme_keys = store.scoped(Some("acme")).keys();
2145        acme_keys.sort();
2146        assert_eq!(acme_keys, vec!["a", "b"]);
2147
2148        let globex_keys = store.scoped(Some("globex")).keys();
2149        assert_eq!(globex_keys, vec!["g"]);
2150    }
2151
2152    #[test]
2153    fn unscoped_view_skips_tenant_prefixed_keys() {
2154        // Calling scoped(None) — the legacy-compat path — must NOT
2155        // accidentally expose other tenants' keys via `keys()`. This
2156        // is the inverse of the isolation contract: the unscoped
2157        // namespace shouldn't see scoped data even though it's all
2158        // in the same backing HashMap.
2159        let store = StateStore::new();
2160        store.set("legacy", json!("ok"), "act");
2161        store.scoped(Some("acme")).set("hidden", json!(42), "act");
2162
2163        let unscoped = store.scoped(None).keys();
2164        assert_eq!(unscoped, vec!["legacy"]);
2165        assert!(store.scoped(None).get("hidden").is_none());
2166    }
2167
2168    #[test]
2169    fn scoped_restore_does_not_clobber_other_tenants() {
2170        // The E2 fix: a tenant's rollback must restore only its own
2171        // namespace, leaving concurrent tenants' state intact.
2172        let store = StateStore::new();
2173        store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
2174        store
2175            .scoped(Some("globex"))
2176            .set("k", json!("globex-v1"), "a");
2177        store.set("global", json!("g-v1"), "a");
2178
2179        // Snapshot acme's namespace, then both tenants + global mutate.
2180        let acme_snap = store.scoped(Some("acme")).snapshot();
2181        store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
2182        store
2183            .scoped(Some("globex"))
2184            .set("k", json!("globex-v2"), "a");
2185        store.set("global", json!("g-v2"), "a");
2186
2187        // Roll acme back. Only acme reverts; globex + global keep v2.
2188        store.scoped(Some("acme")).restore(acme_snap, 0).unwrap();
2189        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
2190        assert_eq!(
2191            store.scoped(Some("globex")).get("k"),
2192            Some(json!("globex-v2"))
2193        );
2194        assert_eq!(store.get("global"), Some(json!("g-v2")));
2195    }
2196
2197    #[test]
2198    fn snapshot_scoped_captures_only_its_namespace() {
2199        let store = StateStore::new();
2200        store.set("global", json!(1), "a");
2201        store.scoped(Some("acme")).set("x", json!(2), "a");
2202        store.scoped(Some("globex")).set("y", json!(3), "a");
2203
2204        let acme = store.snapshot_scoped(Some("acme"));
2205        assert_eq!(acme.len(), 1);
2206        assert!(acme.contains_key("tenant:acme:x"));
2207
2208        let global = store.snapshot_scoped(None);
2209        assert_eq!(global.len(), 1);
2210        assert!(global.contains_key("global"));
2211    }
2212
2213    #[test]
2214    fn unscoped_restore_leaves_tenant_keys_intact() {
2215        // The global (None) namespace restore must not wipe tenant keys.
2216        let store = StateStore::new();
2217        store.set("g", json!("v1"), "a");
2218        store.scoped(Some("acme")).set("k", json!("acme"), "a");
2219
2220        let snap = store.snapshot_scoped(None);
2221        store.set("g", json!("v2"), "a");
2222        store.restore_scoped(None, snap, 0).unwrap();
2223
2224        assert_eq!(store.get("g"), Some(json!("v1")));
2225        // The tenant key survived the global rollback.
2226        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
2227    }
2228
2229    #[test]
2230    fn scoped_restore_preserves_other_tenants_transitions() {
2231        // C-5 regression: a tenant-scoped rollback must FILTER the shared
2232        // transition log, not truncate it — truncation dropped concurrent
2233        // tenants' post-snapshot transitions, falsifying history and
2234        // letting the reaper act on a stale "latest" transition.
2235        let store = StateStore::new();
2236        store.scoped(Some("acme")).set("k", json!("a1"), "act");
2237
2238        // acme snapshots here.
2239        let snap = store.snapshot_scoped(Some("acme"));
2240        let count = store.transition_count();
2241
2242        // Concurrent activity after the snapshot: acme mutates (to be
2243        // rolled back) and globex commits (must survive).
2244        store.scoped(Some("acme")).set("k", json!("a2"), "act");
2245        store.scoped(Some("globex")).set("g", json!("gv"), "act");
2246
2247        store.restore_scoped(Some("acme"), snap, count).unwrap();
2248
2249        // acme's post-snapshot transition is gone; globex's survived.
2250        let tail = store.transitions_since(count);
2251        assert_eq!(
2252            tail.len(),
2253            1,
2254            "exactly globex's transition survives: {tail:?}"
2255        );
2256        assert_eq!(tail[0].key, "tenant:globex:g");
2257        // Values match: acme rolled back, globex untouched.
2258        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
2259        assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
2260    }
2261
2262    #[test]
2263    fn scoped_view_delete_doesnt_touch_other_tenants() {
2264        let store = StateStore::new();
2265        store.scoped(Some("acme")).set("shared", json!(1), "act");
2266        store.scoped(Some("globex")).set("shared", json!(2), "act");
2267
2268        store.scoped(Some("acme")).delete("shared", "act");
2269        assert!(!store.scoped(Some("acme")).exists("shared"));
2270        assert!(store.scoped(Some("globex")).exists("shared"));
2271    }
2272
2273    #[test]
2274    fn empty_tenant_string_treated_as_unscoped() {
2275        // Some(""): defensive — RuntimeScope normalizes empty strings
2276        // to None at the dispatcher, but the view shouldn't trip if
2277        // a caller passes an empty tenant by mistake.
2278        let store = StateStore::new();
2279        store.scoped(Some("")).set("k", json!(1), "act");
2280        assert_eq!(store.get("k"), Some(json!(1)));
2281        assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
2282    }
2283
2284    // Atomic batch mutations — Parslee-ai/car#1140.
2285
2286    #[test]
2287    fn set_batch_applies_all_entries_with_versions_and_transitions() {
2288        let store = StateStore::new();
2289        store.set("a", json!("old"), "setup");
2290
2291        let batch = store.set_batch(
2292            vec![("a".to_string(), json!("new")), ("b".to_string(), json!(2))],
2293            "callback-action",
2294        );
2295
2296        assert_eq!(store.get("a"), Some(json!("new")));
2297        assert_eq!(store.get("b"), Some(json!(2)));
2298        assert_eq!(store.version("a"), Some(2));
2299        assert_eq!(store.version("b"), Some(1));
2300        // Returned transitions carry the pre-batch old values and the
2301        // shared attribution.
2302        assert_eq!(batch.len(), 2);
2303        assert_eq!(batch[0].old_value, Some(json!("old")));
2304        assert_eq!(batch[1].old_value, None);
2305        assert!(batch.iter().all(|t| t.action_id == "callback-action"));
2306        // The audit log gained one transition per key.
2307        assert_eq!(store.transition_count(), 3);
2308    }
2309
2310    #[test]
2311    fn empty_batch_is_a_noop() {
2312        let store = StateStore::new();
2313        assert!(store.set_batch(Vec::new(), "noop").is_empty());
2314        assert_eq!(store.transition_count(), 0);
2315    }
2316
2317    #[test]
2318    fn durable_two_and_three_key_batches_survive_reopen_with_versions() {
2319        let dir = tempfile::tempdir().unwrap();
2320        let path = dir.path().join("state.jsonl");
2321        {
2322            let store = StateStore::durable(&path).unwrap();
2323            store.set_batch(
2324                vec![("x".to_string(), json!(1)), ("y".to_string(), json!(2))],
2325                "two-key",
2326            );
2327            store.set_batch(
2328                vec![
2329                    ("x".to_string(), json!(10)),
2330                    ("y".to_string(), json!(20)),
2331                    ("z".to_string(), json!(30)),
2332                ],
2333                "three-key",
2334            );
2335            store.sync().unwrap();
2336        }
2337        let reopened = StateStore::durable(&path).unwrap();
2338        assert_eq!(reopened.get("x"), Some(json!(10)));
2339        assert_eq!(reopened.get("y"), Some(json!(20)));
2340        assert_eq!(reopened.get("z"), Some(json!(30)));
2341        assert_eq!(reopened.version("x"), Some(2));
2342        assert_eq!(reopened.version("y"), Some(2));
2343        assert_eq!(reopened.version("z"), Some(1));
2344    }
2345
2346    #[test]
2347    fn multi_key_batch_is_one_journal_line_and_single_key_keeps_legacy_shape() {
2348        let dir = tempfile::tempdir().unwrap();
2349        let path = dir.path().join("state.jsonl");
2350        let store = StateStore::durable(&path).unwrap();
2351
2352        store.set_batch(
2353            vec![
2354                ("k1".to_string(), json!(1)),
2355                ("k2".to_string(), json!(2)),
2356                ("k3".to_string(), json!(3)),
2357            ],
2358            "multi",
2359        );
2360        store.set_batch(vec![("solo".to_string(), json!(4))], "single");
2361        store.sync().unwrap();
2362
2363        let journal = std::fs::read_to_string(&path).unwrap();
2364        let lines: Vec<&str> = journal.lines().filter(|l| !l.trim().is_empty()).collect();
2365        assert_eq!(
2366            lines.len(),
2367            2,
2368            "a 3-key batch must be ONE line and a 1-key batch one line: {journal}"
2369        );
2370        let multi: Value = serde_json::from_str(lines[0]).unwrap();
2371        assert_eq!(
2372            multi["batch"].as_array().map(Vec::len),
2373            Some(3),
2374            "the multi-key line is a batch record"
2375        );
2376        // The single-entry batch keeps the legacy bare-transition shape so
2377        // earlier CAR versions can still read journals with no multi-key
2378        // batches in them.
2379        let single: StateTransition = serde_json::from_str(lines[1]).unwrap();
2380        assert_eq!(single.key, "solo");
2381    }
2382
2383    /// Shared torn-append scenario for the deterministic failure matrix:
2384    /// a base key, a complete batch, then an `n_keys` batch whose journal
2385    /// append was interrupted mid-line. Replay must surface the complete
2386    /// old state — never any prefix of the torn batch.
2387    fn torn_batch_replays_complete_old_state(n_keys: usize) {
2388        let dir = tempfile::tempdir().unwrap();
2389        let path = dir.path().join("state.jsonl");
2390        let torn_keys: Vec<String> = (1..=n_keys).map(|i| format!("t{i}")).collect();
2391        {
2392            let store = StateStore::durable(&path).unwrap();
2393            store.set("base", json!("kept"), "setup");
2394            store.set_batch(
2395                vec![("p".to_string(), json!(1)), ("q".to_string(), json!(2))],
2396                "complete-batch",
2397            );
2398            store.set_batch(
2399                torn_keys
2400                    .iter()
2401                    .map(|key| (key.clone(), json!(format!("{key}-value"))))
2402                    .collect(),
2403                "torn-batch",
2404            );
2405            store.sync().unwrap();
2406        }
2407        let full = std::fs::read_to_string(&path).unwrap();
2408        let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2409        let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2410        std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2411
2412        let store = StateStore::durable(&path).unwrap();
2413        assert_eq!(store.get("base"), Some(json!("kept")));
2414        assert_eq!(store.get("p"), Some(json!(1)));
2415        assert_eq!(store.get("q"), Some(json!(2)));
2416        for torn_key in &torn_keys {
2417            assert!(
2418                !store.exists(torn_key),
2419                "an interrupted {n_keys}-key batch append must replay as \
2420                 complete-old — no key of the torn batch may surface ({torn_key} did)"
2421            );
2422        }
2423    }
2424
2425    #[test]
2426    fn torn_two_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2427        torn_batch_replays_complete_old_state(2);
2428    }
2429
2430    #[test]
2431    fn torn_three_key_batch_journal_line_replays_complete_old_state_never_a_prefix() {
2432        torn_batch_replays_complete_old_state(3);
2433    }
2434
2435    #[test]
2436    fn valid_unterminated_tail_is_terminated_so_the_next_append_survives_reopen() {
2437        let dir = tempfile::tempdir().unwrap();
2438        let path = dir.path().join("state.jsonl");
2439        {
2440            let store = StateStore::durable(&path).unwrap();
2441            store.set("base", json!("kept"), "setup");
2442            store.set_batch(
2443                vec![("b1".to_string(), json!(1)), ("b2".to_string(), json!(2))],
2444                "tail-batch",
2445            );
2446            store.sync().unwrap();
2447        }
2448        // Crash shape: the final record was fully written but its trailing
2449        // newline never reached disk.
2450        let full = std::fs::read_to_string(&path).unwrap();
2451        std::fs::write(&path, full.trim_end().as_bytes()).unwrap();
2452
2453        {
2454            let store = StateStore::durable(&path).unwrap();
2455            assert_eq!(store.get("b1"), Some(json!(1)), "the complete tail replays");
2456            store.set("after", json!("survives"), "later");
2457            store.sync().unwrap();
2458        }
2459        // Without tail repair the post-reopen append glues onto the
2460        // unterminated tail, and THIS reopen drops both records as one
2461        // malformed merged line.
2462        let reopened = StateStore::durable(&path).unwrap();
2463        assert_eq!(reopened.get("base"), Some(json!("kept")));
2464        assert_eq!(reopened.get("b1"), Some(json!(1)));
2465        assert_eq!(reopened.get("b2"), Some(json!(2)));
2466        assert_eq!(reopened.get("after"), Some(json!("survives")));
2467    }
2468
2469    #[test]
2470    fn torn_unterminated_tail_is_truncated_so_the_next_append_is_not_merged() {
2471        let dir = tempfile::tempdir().unwrap();
2472        let path = dir.path().join("state.jsonl");
2473        {
2474            let store = StateStore::durable(&path).unwrap();
2475            store.set("base", json!("kept"), "setup");
2476            store.set_batch(
2477                vec![
2478                    ("t1".to_string(), json!(1)),
2479                    ("t2".to_string(), json!(2)),
2480                    ("t3".to_string(), json!(3)),
2481                ],
2482                "torn-batch",
2483            );
2484            store.sync().unwrap();
2485        }
2486        // Crash shape: the batch append was interrupted mid-record.
2487        let full = std::fs::read_to_string(&path).unwrap();
2488        let last_line_start = full.trim_end().rfind('\n').unwrap() + 1;
2489        let torn_cut = last_line_start + (full.trim_end().len() - last_line_start) / 2;
2490        std::fs::write(&path, &full.as_bytes()[..torn_cut]).unwrap();
2491
2492        {
2493            let store = StateStore::durable(&path).unwrap();
2494            assert!(
2495                !store.exists("t1"),
2496                "the torn batch replays as complete-old"
2497            );
2498            store.set("after", json!("survives"), "later");
2499            store.sync().unwrap();
2500        }
2501        let reopened = StateStore::durable(&path).unwrap();
2502        assert_eq!(reopened.get("base"), Some(json!("kept")));
2503        assert_eq!(
2504            reopened.get("after"),
2505            Some(json!("survives")),
2506            "the record appended after a torn tail must not merge into it and be lost"
2507        );
2508        for torn_key in ["t1", "t2", "t3"] {
2509            assert!(!reopened.exists(torn_key));
2510        }
2511    }
2512
2513    /// Shared live-read scenario for the deterministic failure matrix. The
2514    /// `batch_mid_apply` seam pauses the batch after its FIRST key with the
2515    /// state lock still held; at that provably-partial instant the state
2516    /// lock must be unacquirable (`try_lock` fails — the deterministic
2517    /// core: no reader can enter mid-batch), and a reader admitted
2518    /// afterwards must observe the complete new state.
2519    fn reader_cannot_enter_mid_batch(n_keys: usize) {
2520        let keys: Vec<String> = (1..=n_keys).map(|i| format!("k{i}")).collect();
2521        let mut store = StateStore::new();
2522        for key in &keys {
2523            store.set(key, json!("old"), "setup");
2524        }
2525        let barrier = Arc::new(MutationRaceBarrier::new());
2526        store.batch_mid_apply = Some(barrier.clone());
2527        let store = Arc::new(store);
2528
2529        let batch_store = store.clone();
2530        let batch_keys = keys.clone();
2531        let batch = std::thread::spawn(move || {
2532            batch_store.set_batch(
2533                batch_keys
2534                    .into_iter()
2535                    .map(|key| (key, json!("new")))
2536                    .collect(),
2537                "batch",
2538            );
2539        });
2540
2541        // Deterministic partial-state instant: exactly one key applied,
2542        // n_keys - 1 still old, and the state lock held by the batch.
2543        barrier.reached.wait();
2544        assert!(
2545            store.state.try_lock().is_none(),
2546            "the state lock must be held for the whole batch — a reader \
2547             admitted here would observe a partial {n_keys}-key set"
2548        );
2549        let reader_store = store.clone();
2550        let reader_keys = keys.clone();
2551        let reader = std::thread::spawn(move || {
2552            reader_keys
2553                .iter()
2554                .rev()
2555                .map(|key| reader_store.get(key))
2556                .collect::<Vec<_>>()
2557        });
2558        barrier.release.wait();
2559        batch.join().unwrap();
2560
2561        let observed = reader.join().unwrap();
2562        assert!(
2563            observed.iter().all(|value| value == &Some(json!("new"))),
2564            "a reader admitted during a batch must see the complete new \
2565             state, got {observed:?}"
2566        );
2567    }
2568
2569    #[test]
2570    fn reader_cannot_enter_mid_two_key_batch_and_observes_complete_state() {
2571        reader_cannot_enter_mid_batch(2);
2572    }
2573
2574    #[test]
2575    fn reader_cannot_enter_mid_three_key_batch_and_observes_complete_state() {
2576        reader_cannot_enter_mid_batch(3);
2577    }
2578
2579    #[test]
2580    fn snapshot_stripped_is_single_lock_and_strips_tenant_prefixes() {
2581        // The reader map the state.snapshot RPC returns: built under one
2582        // state-lock acquisition (the same lock the mid-batch tests above
2583        // prove is held for a whole batch), with tenant prefixes stripped.
2584        let store = StateStore::new();
2585        store.set("global", json!(1), "act");
2586        store.scoped(Some("acme")).set("x", json!(2), "act");
2587        store.scoped(Some("globex")).set("y", json!(3), "act");
2588
2589        let acme = store.scoped(Some("acme")).snapshot_stripped();
2590        assert_eq!(acme, [("x".to_string(), json!(2))].into());
2591
2592        let unscoped = store.scoped(None).snapshot_stripped();
2593        assert_eq!(unscoped, [("global".to_string(), json!(1))].into());
2594    }
2595
2596    #[test]
2597    fn a_tenant_sees_shared_keys_and_its_own_never_another_tenants() {
2598        use car_ir::precondition::StateView;
2599        let store = StateStore::new();
2600        store.set("global", json!(1), "act");
2601        store.scoped(Some("acme")).set("x", json!(2), "act");
2602        store.scoped(Some("globex")).set("y", json!(3), "act");
2603
2604        let (values, versions) = store.versioned_snapshot_visible_to(Some("acme"));
2605        let expected: HashMap<String, Value> = [
2606            ("global".to_string(), json!(1)),
2607            ("tenant:acme:x".to_string(), json!(2)),
2608        ]
2609        .into();
2610        assert_eq!(values, expected);
2611        assert_eq!(
2612            versions.keys().collect::<std::collections::HashSet<_>>(),
2613            expected.keys().collect()
2614        );
2615
2616        let view = store.visible_to(Some("acme"));
2617        assert!(view.key_exists("global"));
2618        assert!(view.key_exists("tenant:acme:x"));
2619        assert!(!view.key_exists("tenant:globex:y"));
2620        assert_eq!(view.get_value("tenant:globex:y"), None);
2621
2622        // No tenant: the whole store, as before.
2623        assert_eq!(
2624            store.versioned_snapshot_visible_to(None),
2625            store.versioned_snapshot()
2626        );
2627        assert!(store.visible_to(None).key_exists("tenant:globex:y"));
2628    }
2629
2630    #[test]
2631    fn set_batch_refused_while_journal_durability_unknown() {
2632        let dir = tempfile::tempdir().unwrap();
2633        let path = dir.path().join("state.jsonl");
2634        let failures = RestoreFailureInjector::default();
2635        let store =
2636            StateStore::durable_with_restore_failure_injector(&path, Some(failures.clone()))
2637                .unwrap();
2638        store.set("existing", json!("old"), "setup");
2639        let snapshot = store.snapshot();
2640        let transition_count = store.transition_count();
2641        store.set("existing", json!("new"), "candidate");
2642        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2643        failures.fail_next(RestoreFailurePoint::ParentDirectorySync);
2644
2645        assert!(matches!(
2646            store.restore(snapshot, transition_count).unwrap(),
2647            RestoreDurability::DurabilityUnknown { .. }
2648        ));
2649        let refused = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2650            store.set_batch(
2651                vec![
2652                    ("must_not_write".to_string(), json!(true)),
2653                    ("nor_this".to_string(), json!(true)),
2654                ],
2655                "later",
2656            );
2657        }));
2658        assert!(
2659            refused.is_err(),
2660            "set_batch must refuse mutation exactly like set while the journal is durability-unknown"
2661        );
2662        assert!(!store.exists("must_not_write"));
2663        assert!(!store.exists("nor_this"));
2664    }
2665
2666    #[test]
2667    fn scoped_set_batch_prefixes_all_keys_into_tenant_namespace() {
2668        let store = StateStore::new();
2669        store.scoped(Some("acme")).set_batch(
2670            vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2671            "act",
2672        );
2673        assert_eq!(store.scoped(Some("acme")).get("a"), Some(json!(1)));
2674        assert_eq!(store.scoped(Some("acme")).get("b"), Some(json!(2)));
2675        assert!(!store.scoped(Some("globex")).exists("a"));
2676        assert!(!store.scoped(None).exists("a"));
2677        assert_eq!(store.get("tenant:acme:b"), Some(json!(2)));
2678    }
2679
2680    #[test]
2681    fn batch_then_compaction_and_reopen_preserve_state_and_versions() {
2682        let dir = tempfile::tempdir().unwrap();
2683        let path = dir.path().join("state.jsonl");
2684        {
2685            let store = StateStore::durable(&path).unwrap();
2686            store.set_batch(
2687                vec![("a".to_string(), json!(1)), ("b".to_string(), json!(2))],
2688                "batch",
2689            );
2690            // A TTL key that expires forces a journal compaction, which
2691            // rewrites the batch record as per-key snapshot lines.
2692            store.set_with_ttl("tmp", json!(true), "ttl", 0);
2693            store.sync().unwrap();
2694            store
2695                .reap_expired(Utc::now() + Duration::seconds(1))
2696                .unwrap();
2697            store.sync().unwrap();
2698        }
2699        let reopened = StateStore::durable(&path).unwrap();
2700        assert_eq!(reopened.get("a"), Some(json!(1)));
2701        assert_eq!(reopened.get("b"), Some(json!(2)));
2702        assert_eq!(reopened.version("a"), Some(1));
2703        assert_eq!(reopened.version("b"), Some(1));
2704        assert!(!reopened.exists("tmp"));
2705    }
2706}