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;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::fs::{File, OpenOptions};
29use std::io::{BufRead, BufReader, BufWriter, Write};
30use std::path::{Path, PathBuf};
31
32/// An explicit record of a state change.
33///
34/// `ttl_secs` is optional — when present, the key expires `ttl_secs`
35/// seconds after `timestamp`. Reads return the value while it's
36/// live; `reap_expired` drops it after the deadline. The default
37/// (None) means "keep until explicitly deleted."
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct StateTransition {
40    pub key: String,
41    pub old_value: Option<Value>,
42    pub new_value: Option<Value>,
43    pub action_id: String,
44    pub timestamp: DateTime<Utc>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub ttl_secs: Option<u64>,
47    /// The key's monotonic version *after* this transition. Persisted so
48    /// the version counter survives journal compaction and restart — a
49    /// compacted journal collapses a key's history to one line, so without
50    /// this field replay would recount from 1 and break the staleness
51    /// guarantee (neo review M2). Optional for backward-compatible reads of
52    /// pre-versioning journals.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub version: Option<u64>,
55}
56
57/// Thread-safe state store with transition logging.
58///
59/// All reads and writes go through this store. Every write produces a
60/// StateTransition record for audit and replay. Optionally backed by
61/// a JSONL journal file for durability across process restarts (see
62/// [`StateStore::durable`]).
63pub struct StateStore {
64    state: Mutex<HashMap<String, Value>>,
65    transitions: Mutex<Vec<StateTransition>>,
66    /// Monotonic per-key version counter, bumped on every write/delete.
67    /// The basis for transactional staleness detection (survey §5.2.4):
68    /// an action that read `k` at version `v` can be flagged when `k` has
69    /// since advanced past `v`, catching belief divergence that a value
70    /// comparison alone would miss (e.g. set back to the same value).
71    versions: Mutex<HashMap<String, u64>>,
72    /// Optional JSONL-backed durability layer. When set, every
73    /// `StateTransition` appended to the in-memory log is also
74    /// appended to this file's open writer; `reap_expired` rewrites
75    /// the file to compact away dropped keys.
76    journal: Mutex<Option<Journal>>,
77}
78
79struct Journal {
80    path: PathBuf,
81    writer: BufWriter<File>,
82}
83
84impl StateStore {
85    pub fn new() -> Self {
86        Self {
87            state: Mutex::new(HashMap::new()),
88            transitions: Mutex::new(Vec::new()),
89            versions: Mutex::new(HashMap::new()),
90            journal: Mutex::new(None),
91        }
92    }
93
94    /// Open a durable, JSONL-backed StateStore. If the file exists,
95    /// its transitions are replayed (last-write-wins per key, with
96    /// TTLs honored) to rebuild current state. Subsequent writes
97    /// append to the same file.
98    ///
99    /// Returns an error only on filesystem-level failures (parent
100    /// directory missing, permission denied, etc.). Malformed lines
101    /// inside the journal are skipped with a warning rather than
102    /// failing the open — agent persistence shouldn't refuse to
103    /// start over a single bad line.
104    pub fn durable(path: impl Into<PathBuf>) -> std::io::Result<Self> {
105        let path = path.into();
106        if let Some(parent) = path.parent() {
107            if !parent.as_os_str().is_empty() {
108                std::fs::create_dir_all(parent)?;
109            }
110        }
111        let store = Self::new();
112        store.replay_journal(&path)?;
113        let file = OpenOptions::new().create(true).append(true).open(&path)?;
114        *store.journal.lock() = Some(Journal {
115            path,
116            writer: BufWriter::new(file),
117        });
118        Ok(store)
119    }
120
121    fn replay_journal(&self, path: &Path) -> std::io::Result<()> {
122        if !path.exists() {
123            return Ok(());
124        }
125        let file = File::open(path)?;
126        let reader = BufReader::new(file);
127        let now = Utc::now();
128        let mut state = self.state.lock();
129        let mut transitions = self.transitions.lock();
130        let mut versions = self.versions.lock();
131        for line in reader.lines() {
132            let line = match line {
133                Ok(l) if l.trim().is_empty() => continue,
134                Ok(l) => l,
135                Err(_) => continue,
136            };
137            let Ok(t) = serde_json::from_str::<StateTransition>(&line) else {
138                // Malformed line. Don't refuse to boot over it.
139                tracing::warn!(
140                    journal = %path.display(),
141                    "skipping malformed StateStore journal line"
142                );
143                continue;
144            };
145            // Replay last-write-wins. TTLs that already expired are
146            // dropped at replay time so we don't surface stale data
147            // on first read.
148            if let (Some(ttl), Some(value)) = (t.ttl_secs, &t.new_value) {
149                if now.signed_duration_since(t.timestamp) > Duration::seconds(ttl as i64) {
150                    state.remove(&t.key);
151                } else {
152                    state.insert(t.key.clone(), value.clone());
153                }
154            } else if let Some(value) = &t.new_value {
155                state.insert(t.key.clone(), value.clone());
156            } else {
157                state.remove(&t.key);
158            }
159            // Restore the persisted version when present (survives
160            // compaction); otherwise count transitions for legacy
161            // pre-versioning journals. Take the max so the counter stays
162            // monotonic across a mix of compacted and appended lines.
163            let entry = versions.entry(t.key.clone()).or_insert(0);
164            let restored = t.version.unwrap_or(*entry + 1);
165            *entry = (*entry).max(restored);
166            transitions.push(t);
167        }
168        Ok(())
169    }
170
171    fn append_journal(&self, transition: &StateTransition) {
172        let mut journal = self.journal.lock();
173        let Some(journal) = journal.as_mut() else {
174            return;
175        };
176        // Best-effort: a failed disk write tracing::warn!s but the
177        // in-memory write already succeeded. Callers who need
178        // guaranteed durability should call `sync` after batches.
179        let Ok(json) = serde_json::to_string(transition) else {
180            return;
181        };
182        if let Err(e) = writeln!(journal.writer, "{json}") {
183            tracing::warn!(
184                journal = %journal.path.display(),
185                error = %e,
186                "StateStore journal append failed"
187            );
188            return;
189        }
190        let _ = journal.writer.flush();
191    }
192
193    /// Fsync the journal writer. Call after a batch of writes when
194    /// you need durability guarantees beyond best-effort flush.
195    pub fn sync(&self) -> std::io::Result<()> {
196        let mut journal = self.journal.lock();
197        let Some(journal) = journal.as_mut() else {
198            return Ok(());
199        };
200        journal.writer.flush()?;
201        journal.writer.get_ref().sync_all()
202    }
203
204    /// Drop expired keys (per `ttl_secs` on their last write) and
205    /// rewrite the journal as a compacted snapshot of the surviving
206    /// state. Returns the keys that were reaped.
207    ///
208    /// **TTL semantics**: a `ttl_secs` of 0 means "expired
209    /// immediately" — the key is reapable on the next call. There
210    /// is no "0 = forever" sentinel; use `set` (no TTL) for keys
211    /// that should never auto-expire.
212    ///
213    /// Latest-write-wins: a key rewritten WITHOUT a TTL after a
214    /// TTL'd write is NOT reaped — the more recent write
215    /// effectively cancels the TTL.
216    ///
217    /// Single-pass over the transitions log via a key→latest
218    /// index, so cost is O(n) in journal length (not O(n²)).
219    pub fn reap_expired(&self, now: DateTime<Utc>) -> std::io::Result<Vec<String>> {
220        self.reap_expired_where(now, |_| true)
221    }
222
223    /// Reap only the expired keys in one tenant's namespace (EPIC E / E3).
224    /// `tenant = Some(id)` reaps `tenant:<id>:*`; `tenant = None` reaps only
225    /// the unscoped namespace. This is the per-tenant counterpart to
226    /// [`Self::reap_expired`] (which reaps across all tenants): it lets a
227    /// per-tenant reaping budget expire one tenant's TTL'd keys without
228    /// touching another tenant's — so one tenant's memory pressure can't
229    /// evict another's state.
230    pub fn reap_expired_scoped(
231        &self,
232        now: DateTime<Utc>,
233        tenant: Option<&str>,
234    ) -> std::io::Result<Vec<String>> {
235        self.reap_expired_where(now, |k| key_in_tenant_namespace(k, tenant))
236    }
237
238    /// Shared reaping core: reap every expired key for which `keep` returns
239    /// true. Walks the transition log once to find the latest state per key.
240    fn reap_expired_where(
241        &self,
242        now: DateTime<Utc>,
243        keep: impl Fn(&str) -> bool,
244    ) -> std::io::Result<Vec<String>> {
245        let mut state = self.state.lock();
246        let mut transitions = self.transitions.lock();
247        // Build a single-pass index of the latest transition per
248        // key. Walking the whole log once is unavoidable; doing it
249        // ONCE keeps reap O(n) in journal length.
250        let mut latest_by_key: HashMap<&str, &StateTransition> = HashMap::new();
251        for t in transitions.iter() {
252            latest_by_key.insert(t.key.as_str(), t);
253        }
254        let expired: Vec<String> = latest_by_key
255            .values()
256            .filter_map(|t| {
257                if !keep(&t.key) {
258                    return None;
259                }
260                let ttl = t.ttl_secs?;
261                t.new_value.as_ref()?;
262                let age = now.signed_duration_since(t.timestamp);
263                (age > Duration::seconds(ttl as i64)).then(|| t.key.clone())
264            })
265            .collect();
266        let mut reaped = Vec::new();
267        for key in expired {
268            if state.remove(&key).is_some() {
269                // Bump the version so an expiry is observable as a change
270                // (neo review N1: keeps in-memory versions in step with
271                // what replay would reconstruct).
272                let version = self.bump_version(&key);
273                reaped.push(key.clone());
274                transitions.push(StateTransition {
275                    key,
276                    old_value: None,
277                    new_value: None,
278                    action_id: "reap".to_string(),
279                    timestamp: now,
280                    ttl_secs: None,
281                    version: Some(version),
282                });
283            }
284        }
285        drop(state);
286        drop(transitions);
287        if !reaped.is_empty() {
288            self.compact_journal()?;
289        }
290        Ok(reaped)
291    }
292
293    /// Rewrite the journal as a flat snapshot of the current state —
294    /// one transition per surviving key, no replay history. Reduces
295    /// journal size without changing observable behavior.
296    ///
297    /// **Concurrency requirement**: callers MUST hold the
298    /// observation that no other thread is mid-`set`/`delete` on
299    /// this store; the snapshot is taken under the state lock, but
300    /// in-flight journal appends to the *old* file handle that
301    /// land between snapshot and rename are lost. Today's only
302    /// caller is `reap_expired`, which holds both locks across the
303    /// call; external callers should serialize themselves.
304    pub(crate) fn compact_journal(&self) -> std::io::Result<()> {
305        let mut journal = self.journal.lock();
306        let Some(j) = journal.as_mut() else {
307            return Ok(());
308        };
309        let state = self.state.lock().clone();
310        let versions = self.versions.lock().clone();
311        let tmp_path = j.path.with_extension("jsonl.tmp");
312        {
313            let tmp_file = File::create(&tmp_path)?;
314            let mut writer = BufWriter::new(tmp_file);
315            for (key, value) in &state {
316                let t = StateTransition {
317                    key: key.clone(),
318                    old_value: None,
319                    new_value: Some(value.clone()),
320                    action_id: "compact".to_string(),
321                    timestamp: Utc::now(),
322                    ttl_secs: None,
323                    // Persist the true version so replay restores it rather
324                    // than recounting the collapsed history from 1.
325                    version: versions.get(key).copied(),
326                };
327                let line = serde_json::to_string(&t)?;
328                writeln!(writer, "{line}")?;
329            }
330            writer.flush()?;
331            writer.get_ref().sync_all()?;
332        }
333        std::fs::rename(&tmp_path, &j.path)?;
334        let file = OpenOptions::new().create(true).append(true).open(&j.path)?;
335        j.writer = BufWriter::new(file);
336        Ok(())
337    }
338
339    pub fn get(&self, key: &str) -> Option<Value> {
340        self.state.lock().get(key).cloned()
341    }
342
343    pub fn get_or(&self, key: &str, default: Value) -> Value {
344        self.state.lock().get(key).cloned().unwrap_or(default)
345    }
346
347    pub fn exists(&self, key: &str) -> bool {
348        self.state.lock().contains_key(key)
349    }
350
351    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
352        self.set_inner(key, value, action_id, None)
353    }
354
355    /// Set a key with a TTL (seconds from now). `reap_expired`
356    /// drops the key once the deadline passes; re-setting the key
357    /// without a TTL (`set`) cancels the TTL.
358    ///
359    /// `ttl_secs == 0` means "expire immediately" (reapable on the
360    /// next `reap_expired` call). It is NOT a "no expiry" sentinel
361    /// — use the plain `set(...)` method for keys that should
362    /// never auto-expire. This differs from the Unix/Redis
363    /// convention; the distinction matters because a TTL passed
364    /// from untrusted input could otherwise silently mean
365    /// "forever" when the caller intended "never store."
366    pub fn set_with_ttl(
367        &self,
368        key: &str,
369        value: Value,
370        action_id: &str,
371        ttl_secs: u64,
372    ) -> StateTransition {
373        self.set_inner(key, value, action_id, Some(ttl_secs))
374    }
375
376    fn set_inner(
377        &self,
378        key: &str,
379        value: Value,
380        action_id: &str,
381        ttl_secs: Option<u64>,
382    ) -> StateTransition {
383        let mut state = self.state.lock();
384        let old = state.get(key).cloned();
385        state.insert(key.to_string(), value.clone());
386        let version = self.bump_version(key);
387
388        let t = StateTransition {
389            key: key.to_string(),
390            old_value: old,
391            new_value: Some(value),
392            action_id: action_id.to_string(),
393            timestamp: Utc::now(),
394            ttl_secs,
395            version: Some(version),
396        };
397
398        self.transitions.lock().push(t.clone());
399        self.append_journal(&t);
400        t
401    }
402
403    /// Increment the monotonic version counter for `key`, returning the new
404    /// version.
405    fn bump_version(&self, key: &str) -> u64 {
406        let mut versions = self.versions.lock();
407        let v = versions.entry(key.to_string()).or_insert(0);
408        *v += 1;
409        *v
410    }
411
412    /// Current version of `key` (number of writes/deletes applied to it),
413    /// or `None` if it was never written. Used by the transactional
414    /// conflict checker to detect stale reads (survey §5.2.4).
415    pub fn version(&self, key: &str) -> Option<u64> {
416        self.versions.lock().get(key).copied()
417    }
418
419    /// Snapshot of all key versions — the version map an action's
420    /// assumptions are checked against.
421    pub fn versions(&self) -> HashMap<String, u64> {
422        self.versions.lock().clone()
423    }
424
425    /// Atomic snapshot of both the current values and the current versions,
426    /// taken under a single consistent lock acquisition (state then
427    /// versions, matching the write path) so the two maps can't tear — a
428    /// caller never sees a value from version N+1 paired with version N
429    /// (neo review N2). This is the pair the transactional conflict checker
430    /// (`car_verify::check_transaction`) should consume.
431    pub fn versioned_snapshot(&self) -> (HashMap<String, Value>, HashMap<String, u64>) {
432        let state = self.state.lock();
433        let versions = self.versions.lock();
434        (state.clone(), versions.clone())
435    }
436
437    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
438        let mut state = self.state.lock();
439        let old = state.remove(key)?;
440        let version = self.bump_version(key);
441
442        let t = StateTransition {
443            key: key.to_string(),
444            old_value: Some(old),
445            new_value: None,
446            action_id: action_id.to_string(),
447            timestamp: Utc::now(),
448            ttl_secs: None,
449            version: Some(version),
450        };
451
452        self.transitions.lock().push(t.clone());
453        self.append_journal(&t);
454        Some(t)
455    }
456
457    /// Deep clone of current state.
458    pub fn snapshot(&self) -> HashMap<String, Value> {
459        self.state.lock().clone()
460    }
461
462    /// Restore state from a snapshot, truncating transitions.
463    pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
464        *self.state.lock() = snapshot;
465        self.transitions.lock().truncate(transition_count);
466    }
467
468    /// Snapshot only the keys belonging to one tenant's namespace
469    /// (Parslee-ai/car#187 / EPIC E task E2). `tenant = Some(id)` captures
470    /// `tenant:<id>:*`; `tenant = None` captures the unscoped (non-`tenant:`)
471    /// namespace. Keys are returned in their full (prefixed) form so the
472    /// result round-trips through [`Self::restore_scoped`]. This is the
473    /// per-tenant counterpart to [`Self::snapshot`], which captures *all*
474    /// tenants and so can't be used for a tenant-isolated rollback.
475    pub fn snapshot_scoped(&self, tenant: Option<&str>) -> HashMap<String, Value> {
476        let state = self.state.lock();
477        state
478            .iter()
479            .filter(|(k, _)| key_in_tenant_namespace(k, tenant))
480            .map(|(k, v)| (k.clone(), v.clone()))
481            .collect()
482    }
483
484    /// Restore a single tenant's namespace from a scoped snapshot, leaving
485    /// every other tenant's keys untouched (EPIC E / E2). Existing keys in
486    /// the target namespace are dropped and replaced by `snapshot`; keys
487    /// outside it are preserved. Fixes the cross-tenant clobber where a
488    /// rollback via the unscoped [`Self::restore`] wiped concurrent
489    /// tenants' state.
490    ///
491    /// The transition log is FILTERED, not truncated (linus review C-5):
492    /// only this tenant's post-snapshot transitions are discarded.
493    /// Truncating shared history dropped transitions concurrent tenants
494    /// committed after `transition_count`, which both falsified the audit
495    /// trail and let `reap_expired*` treat another tenant's stale TTL'd
496    /// transition as latest — deleting a live key. `transition_count` is
497    /// the log length captured when this tenant's snapshot was taken.
498    pub fn restore_scoped(
499        &self,
500        tenant: Option<&str>,
501        snapshot: HashMap<String, Value>,
502        transition_count: usize,
503    ) {
504        {
505            let mut state = self.state.lock();
506            state.retain(|k, _| !key_in_tenant_namespace(k, tenant));
507            state.extend(snapshot);
508        }
509        let mut transitions = self.transitions.lock();
510        if transition_count >= transitions.len() {
511            return;
512        }
513        // Keep everything up to the snapshot point; after it, keep only
514        // transitions that belong to OTHER namespaces.
515        let tail: Vec<StateTransition> = transitions
516            .drain(transition_count..)
517            .filter(|t| !key_in_tenant_namespace(&t.key, tenant))
518            .collect();
519        transitions.extend(tail);
520    }
521
522    pub fn transition_count(&self) -> usize {
523        self.transitions.lock().len()
524    }
525
526    pub fn transitions(&self) -> Vec<StateTransition> {
527        self.transitions.lock().clone()
528    }
529
530    pub fn transitions_since(&self, index: usize) -> Vec<StateTransition> {
531        let transitions = self.transitions.lock();
532        let start = index.min(transitions.len());
533        transitions[start..].to_vec()
534    }
535
536    pub fn keys(&self) -> Vec<String> {
537        self.state.lock().keys().cloned().collect()
538    }
539
540    /// Replace the entire state map without recording transitions.
541    /// Used by checkpoint restore to avoid synthetic transition history.
542    /// Also clears the transitions log so callers of `transitions_since()`
543    /// don't see stale history from the discarded state.
544    pub fn replace_all(&self, snapshot: HashMap<String, Value>) {
545        *self.state.lock() = snapshot;
546        self.transitions.lock().clear();
547    }
548
549    /// Build a tenant-scoped view over this store
550    /// (Parslee-ai/car#187 phase 3 enforcement).
551    ///
552    /// All reads / writes go through `tenant:<tenant_id>:<key>` so
553    /// distinct tenants can't see each other's keys. `tenant = None`
554    /// returns a view that hits the unscoped (legacy) namespace —
555    /// callers that don't yet have a `RuntimeScope` get pre-#187
556    /// behaviour automatically.
557    ///
558    /// Cheap to construct; holds a `&self` borrow plus the tenant
559    /// string. The view's methods take the parking-lot lock the same
560    /// way the unscoped methods do.
561    pub fn scoped<'a>(&'a self, tenant: Option<&'a str>) -> ScopedStateView<'a> {
562        ScopedStateView {
563            store: self,
564            tenant,
565        }
566    }
567}
568
569/// Whether `key` belongs to the namespace identified by `tenant`.
570///
571/// `Some(id)` (non-empty) → keys prefixed `tenant:<id>:`. `None` or empty →
572/// the unscoped namespace: every key that is NOT `tenant:`-prefixed (so an
573/// unscoped snapshot/restore never touches any tenant's keys). This is the
574/// predicate that makes [`StateStore::snapshot_scoped`] /
575/// [`StateStore::restore_scoped`] tenant-isolated.
576fn key_in_tenant_namespace(key: &str, tenant: Option<&str>) -> bool {
577    match tenant {
578        Some(t) if !t.is_empty() => key.starts_with(&format!("tenant:{t}:")),
579        _ => !key.starts_with("tenant:"),
580    }
581}
582
583/// Tenant-scoped view over a [`StateStore`]. All key arguments are
584/// transparently prefixed with `tenant:<tenant_id>:` before hitting
585/// the underlying store; on the way out, the prefix is stripped so
586/// callers see their original keys.
587///
588/// Construct via [`StateStore::scoped`]. When `tenant` is `None`,
589/// the prefix is empty and the view is functionally equivalent to
590/// the unscoped methods on `StateStore` — useful for code paths
591/// that always go through this view regardless of whether scope is
592/// active.
593///
594/// # Isolation guarantee
595///
596/// Two views with distinct `tenant` strings cannot observe each
597/// other's writes through `get` / `exists` / `keys`. The transitions
598/// log still records the full (prefixed) key so audit / replay sees
599/// the actual storage layout.
600///
601/// # What isolation does *not* cover (phase 3 follow-ups)
602///
603/// - `StateStore::snapshot` / `restore` are deliberately unscoped —
604///   they're called at proposal boundaries for rollback and need to
605///   see the whole map. Per-tenant partial rollback is a known
606///   concurrency hole when multiple proposals run interleaved; the
607///   pre-#187 baseline has the same issue, and fixing it cleanly
608///   requires either serializing per-tenant or extending the
609///   transactional model. Tracked as a follow-up.
610/// - The journal file (when durability is on) records full
611///   prefixed keys. Operators rotating tenants out can grep the
612///   journal by prefix.
613pub struct ScopedStateView<'a> {
614    store: &'a StateStore,
615    tenant: Option<&'a str>,
616}
617
618impl<'a> ScopedStateView<'a> {
619    fn full_key(&self, key: &str) -> String {
620        match self.tenant {
621            Some(t) if !t.is_empty() => format!("tenant:{t}:{key}"),
622            _ => key.to_string(),
623        }
624    }
625
626    fn strip_prefix<'k>(&self, full: &'k str) -> Option<&'k str> {
627        match self.tenant {
628            Some(t) if !t.is_empty() => {
629                let prefix = format!("tenant:{t}:");
630                full.strip_prefix(&prefix)
631            }
632            _ => Some(full),
633        }
634    }
635
636    pub fn get(&self, key: &str) -> Option<Value> {
637        self.store.get(&self.full_key(key))
638    }
639
640    pub fn get_or(&self, key: &str, default: Value) -> Value {
641        self.store.get_or(&self.full_key(key), default)
642    }
643
644    /// Snapshot only this tenant's namespace (EPIC E / E2) — the scoped
645    /// counterpart to `StateStore::snapshot`, safe to pair with
646    /// [`Self::restore`] for a tenant-isolated rollback.
647    pub fn snapshot(&self) -> HashMap<String, Value> {
648        self.store.snapshot_scoped(self.tenant)
649    }
650
651    /// Restore only this tenant's namespace from a scoped snapshot, leaving
652    /// other tenants untouched (EPIC E / E2).
653    pub fn restore(&self, snapshot: HashMap<String, Value>, transition_count: usize) {
654        self.store
655            .restore_scoped(self.tenant, snapshot, transition_count)
656    }
657
658    pub fn exists(&self, key: &str) -> bool {
659        self.store.exists(&self.full_key(key))
660    }
661
662    pub fn set(&self, key: &str, value: Value, action_id: &str) -> StateTransition {
663        self.store.set(&self.full_key(key), value, action_id)
664    }
665
666    pub fn set_with_ttl(
667        &self,
668        key: &str,
669        value: Value,
670        action_id: &str,
671        ttl_secs: u64,
672    ) -> StateTransition {
673        self.store
674            .set_with_ttl(&self.full_key(key), value, action_id, ttl_secs)
675    }
676
677    pub fn delete(&self, key: &str, action_id: &str) -> Option<StateTransition> {
678        self.store.delete(&self.full_key(key), action_id)
679    }
680
681    /// Return keys belonging to this tenant only, with the
682    /// `tenant:<id>:` prefix stripped so callers see their original
683    /// key names. Unscoped views (no tenant) return only keys that
684    /// don't start with `tenant:` — preventing accidental visibility
685    /// of scoped state through a legacy code path.
686    pub fn keys(&self) -> Vec<String> {
687        self.store
688            .keys()
689            .into_iter()
690            .filter_map(|k| {
691                if self.tenant.map(|t| !t.is_empty()).unwrap_or(false) {
692                    self.strip_prefix(&k).map(str::to_string)
693                } else if k.starts_with("tenant:") {
694                    None
695                } else {
696                    Some(k)
697                }
698            })
699            .collect()
700    }
701}
702
703impl Default for StateStore {
704    fn default() -> Self {
705        Self::new()
706    }
707}
708
709impl car_ir::precondition::StateView for StateStore {
710    fn get_value(&self, key: &str) -> Option<Value> {
711        self.get(key)
712    }
713    fn key_exists(&self, key: &str) -> bool {
714        self.exists(key)
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use serde_json::json;
722
723    #[test]
724    fn set_and_get() {
725        let store = StateStore::new();
726        store.set("x", Value::from(42), "test");
727        assert_eq!(store.get("x"), Some(Value::from(42)));
728    }
729
730    #[test]
731    fn exists() {
732        let store = StateStore::new();
733        assert!(!store.exists("x"));
734        store.set("x", Value::from(1), "test");
735        assert!(store.exists("x"));
736    }
737
738    #[test]
739    fn delete() {
740        let store = StateStore::new();
741        store.set("x", Value::from(1), "test");
742        let t = store.delete("x", "test");
743        assert!(t.is_some());
744        assert!(!store.exists("x"));
745    }
746
747    #[test]
748    fn delete_nonexistent() {
749        let store = StateStore::new();
750        assert!(store.delete("x", "test").is_none());
751    }
752
753    #[test]
754    fn snapshot_and_restore() {
755        let store = StateStore::new();
756        store.set("x", Value::from(1), "a");
757        let snap = store.snapshot();
758        let tc = store.transition_count();
759
760        store.set("y", Value::from(2), "b");
761        assert!(store.exists("y"));
762
763        store.restore(snap, tc);
764        assert!(store.exists("x"));
765        assert!(!store.exists("y"));
766        assert_eq!(store.transition_count(), 1);
767    }
768
769    #[test]
770    fn transitions_logged() {
771        let store = StateStore::new();
772        store.set("a", Value::from(1), "act1");
773        store.set("b", Value::from(2), "act2");
774
775        let transitions = store.transitions();
776        assert_eq!(transitions.len(), 2);
777        assert_eq!(transitions[0].key, "a");
778        assert_eq!(transitions[1].key, "b");
779    }
780
781    #[test]
782    fn transitions_since() {
783        let store = StateStore::new();
784        store.set("a", Value::from(1), "act1");
785        let idx = store.transition_count();
786        store.set("b", Value::from(2), "act2");
787
788        let since = store.transitions_since(idx);
789        assert_eq!(since.len(), 1);
790        assert_eq!(since[0].key, "b");
791    }
792
793    #[test]
794    fn transition_records_old_value() {
795        let store = StateStore::new();
796        store.set("x", Value::from(1), "first");
797        store.set("x", Value::from(2), "second");
798
799        let transitions = store.transitions();
800        assert_eq!(transitions[1].old_value, Some(Value::from(1)));
801        assert_eq!(transitions[1].new_value, Some(Value::from(2)));
802    }
803
804    #[test]
805    fn keys() {
806        let store = StateStore::new();
807        store.set("a", Value::from(1), "t");
808        store.set("b", Value::from(2), "t");
809        let mut keys = store.keys();
810        keys.sort();
811        assert_eq!(keys, vec!["a", "b"]);
812    }
813
814    #[test]
815    fn transitions_since_after_restore_does_not_panic() {
816        let store = StateStore::new();
817        store.set("a", serde_json::json!(1), "test");
818        store.set("b", serde_json::json!(2), "test");
819        let count_before = store.transition_count(); // 2
820
821        // Restore to empty, truncating transitions to 0
822        store.restore(HashMap::new(), 0);
823
824        // Using the stale count_before (2) should not panic
825        let result = store.transitions_since(count_before);
826        assert!(result.is_empty());
827    }
828
829    #[test]
830    fn transitions_since_normal_usage() {
831        let store = StateStore::new();
832        store.set("a", serde_json::json!(1), "test");
833        let mark = store.transition_count();
834        store.set("b", serde_json::json!(2), "test");
835        let since = store.transitions_since(mark);
836        assert_eq!(since.len(), 1);
837        assert_eq!(since[0].key, "b");
838    }
839
840    #[test]
841    fn replace_all_swaps_state_without_transitions() {
842        let store = StateStore::new();
843        store.set("old_key", serde_json::json!("old"), "setup");
844
845        let mut new_state = HashMap::new();
846        new_state.insert("new_key".to_string(), serde_json::json!("new"));
847        store.replace_all(new_state);
848
849        assert_eq!(store.get("new_key"), Some(serde_json::json!("new")));
850        assert_eq!(store.get("old_key"), None);
851        // After replace_all, transitions should be cleared (not preserved)
852        assert_eq!(store.transition_count(), 0);
853    }
854
855    #[test]
856    fn durable_store_survives_reopen() {
857        let dir = tempfile::tempdir().unwrap();
858        let path = dir.path().join("state.jsonl");
859        {
860            let store = StateStore::durable(&path).unwrap();
861            store.set("agent", serde_json::json!("planner"), "boot");
862            store.set("turns", serde_json::json!(42), "tick");
863            store.sync().unwrap();
864        }
865        let store = StateStore::durable(&path).unwrap();
866        assert_eq!(store.get("agent"), Some(serde_json::json!("planner")));
867        assert_eq!(store.get("turns"), Some(serde_json::json!(42)));
868    }
869
870    #[test]
871    fn durable_store_replays_deletes() {
872        let dir = tempfile::tempdir().unwrap();
873        let path = dir.path().join("state.jsonl");
874        {
875            let store = StateStore::durable(&path).unwrap();
876            store.set("transient", serde_json::json!("x"), "boot");
877            store.delete("transient", "rm");
878            store.sync().unwrap();
879        }
880        let store = StateStore::durable(&path).unwrap();
881        assert!(!store.exists("transient"));
882    }
883
884    #[test]
885    fn ttl_reap_drops_expired_and_keeps_fresh() {
886        let store = StateStore::new();
887        store.set_with_ttl("short", serde_json::json!(1), "set", 0);
888        store.set_with_ttl("long", serde_json::json!(2), "set", 3600);
889        store.set("forever", serde_json::json!(3), "set");
890        // Now + 10s — short (ttl=0) is expired, long (ttl=3600) is fresh, forever has no TTL.
891        let reaped = store
892            .reap_expired(Utc::now() + Duration::seconds(10))
893            .unwrap();
894        assert_eq!(reaped, vec!["short".to_string()]);
895        assert!(!store.exists("short"));
896        assert_eq!(store.get("long"), Some(serde_json::json!(2)));
897        assert_eq!(store.get("forever"), Some(serde_json::json!(3)));
898    }
899
900    #[test]
901    fn scoped_reap_isolates_tenants() {
902        // Each tenant has a TTL'd key that's expired. Reaping tenant "a"
903        // must drop only a's key, leaving b's and the unscoped key intact —
904        // one tenant's memory pressure can't evict another's (E3).
905        let store = StateStore::new();
906        store
907            .scoped(Some("a"))
908            .set_with_ttl("k", serde_json::json!(1), "set", 0);
909        store
910            .scoped(Some("b"))
911            .set_with_ttl("k", serde_json::json!(2), "set", 0);
912        store.set_with_ttl("global", serde_json::json!(3), "set", 0);
913
914        let future = Utc::now() + Duration::seconds(10);
915        let reaped = store.reap_expired_scoped(future, Some("a")).unwrap();
916        assert_eq!(reaped, vec!["tenant:a:k".to_string()]);
917        // Only a's key is gone.
918        assert!(!store.scoped(Some("a")).exists("k"));
919        assert!(store.scoped(Some("b")).exists("k"));
920        assert!(store.exists("global"));
921
922        // Reaping the unscoped namespace drops only the unscoped key.
923        let reaped = store.reap_expired_scoped(future, None).unwrap();
924        assert_eq!(reaped, vec!["global".to_string()]);
925        assert!(store.scoped(Some("b")).exists("k"));
926    }
927
928    #[test]
929    fn durable_ttl_compacts_journal() {
930        let dir = tempfile::tempdir().unwrap();
931        let path = dir.path().join("state.jsonl");
932        {
933            let store = StateStore::durable(&path).unwrap();
934            for i in 0..50 {
935                store.set_with_ttl(&format!("k{i}"), serde_json::json!(i), "set", 0);
936            }
937            store.set("survivor", serde_json::json!("kept"), "set");
938            store.sync().unwrap();
939            let pre = std::fs::metadata(&path).unwrap().len();
940            // Force expiry by advancing the clock past the 0s TTL.
941            let reaped = store
942                .reap_expired(Utc::now() + Duration::seconds(1))
943                .unwrap();
944            assert_eq!(reaped.len(), 50);
945            store.sync().unwrap();
946            let post = std::fs::metadata(&path).unwrap().len();
947            // Compaction should shrink the journal: 50 TTL'd writes + 1
948            // survivor pre-compact is 51 lines; post-compact is 1 line.
949            assert!(
950                post < pre,
951                "post={post} pre={pre} — compaction did not shrink"
952            );
953        }
954        // Reopen — only the survivor remains.
955        let store = StateStore::durable(&path).unwrap();
956        assert!(!store.exists("k0"));
957        assert!(!store.exists("k49"));
958        assert_eq!(store.get("survivor"), Some(serde_json::json!("kept")));
959        // Version survives compaction (neo M2): survivor was written once,
960        // so its version is 1 after a compaction-then-reopen, not reset in
961        // a way that breaks staleness detection.
962        assert_eq!(store.version("survivor"), Some(1));
963    }
964
965    #[test]
966    fn version_is_monotonic_and_survives_compaction() {
967        let dir = tempfile::tempdir().unwrap();
968        let path = dir.path().join("v.jsonl");
969        {
970            let store = StateStore::durable(&path).unwrap();
971            for i in 0..3 {
972                store.set("cfg", serde_json::json!(i), "set");
973            }
974            assert_eq!(store.version("cfg"), Some(3));
975            // A TTL key that expires forces a compaction of the journal.
976            store.set_with_ttl("tmp", serde_json::json!(1), "set", 0);
977            store.sync().unwrap();
978            store
979                .reap_expired(Utc::now() + Duration::seconds(1))
980                .unwrap();
981            store.sync().unwrap();
982        }
983        // After compaction + restart, cfg's version must still be 3 — not
984        // recounted to 1 from the collapsed single line.
985        let store = StateStore::durable(&path).unwrap();
986        assert_eq!(store.version("cfg"), Some(3));
987    }
988
989    #[test]
990    fn reap_bumps_version() {
991        let store = StateStore::new();
992        store.set("k", serde_json::json!("v"), "set");
993        assert_eq!(store.version("k"), Some(1));
994        store.set_with_ttl("k", serde_json::json!("v2"), "set", 0);
995        assert_eq!(store.version("k"), Some(2));
996        store
997            .reap_expired(Utc::now() + Duration::seconds(1))
998            .unwrap();
999        // Expiry is an observable change → version advances (neo N1).
1000        assert_eq!(store.version("k"), Some(3));
1001    }
1002
1003    #[test]
1004    fn ttl_then_rewrite_without_ttl_does_not_reap() {
1005        let store = StateStore::new();
1006        store.set_with_ttl("k", serde_json::json!("a"), "first", 0);
1007        store.set("k", serde_json::json!("b"), "second"); // no TTL
1008        let reaped = store
1009            .reap_expired(Utc::now() + Duration::seconds(10))
1010            .unwrap();
1011        assert!(reaped.is_empty());
1012        assert_eq!(store.get("k"), Some(serde_json::json!("b")));
1013    }
1014
1015    #[test]
1016    fn malformed_journal_line_is_skipped_not_fatal() {
1017        let dir = tempfile::tempdir().unwrap();
1018        let path = dir.path().join("state.jsonl");
1019        // Plant a good line + a bad line + another good line.
1020        {
1021            std::fs::write(
1022                &path,
1023                "{\"key\":\"a\",\"old_value\":null,\"new_value\":1,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n\
1024                 not-json\n\
1025                 {\"key\":\"b\",\"old_value\":null,\"new_value\":2,\"action_id\":\"x\",\"timestamp\":\"2026-05-11T00:00:00Z\"}\n",
1026            )
1027            .unwrap();
1028        }
1029        let store = StateStore::durable(&path).unwrap();
1030        assert_eq!(store.get("a"), Some(serde_json::json!(1)));
1031        assert_eq!(store.get("b"), Some(serde_json::json!(2)));
1032    }
1033
1034    // ScopedStateView tests — Parslee-ai/car#187 phase 3 enforcement.
1035
1036    #[test]
1037    fn scoped_view_writes_isolate_between_tenants() {
1038        let store = StateStore::new();
1039        store.scoped(Some("acme")).set("config", json!("A"), "act");
1040        store
1041            .scoped(Some("globex"))
1042            .set("config", json!("G"), "act");
1043
1044        // Each tenant sees their own value.
1045        assert_eq!(store.scoped(Some("acme")).get("config"), Some(json!("A")));
1046        assert_eq!(store.scoped(Some("globex")).get("config"), Some(json!("G")));
1047    }
1048
1049    #[test]
1050    fn scoped_view_isolates_existence_check() {
1051        let store = StateStore::new();
1052        store.scoped(Some("acme")).set("k", json!(1), "act");
1053        assert!(store.scoped(Some("acme")).exists("k"));
1054        assert!(!store.scoped(Some("globex")).exists("k"));
1055    }
1056
1057    #[test]
1058    fn scoped_view_keys_filters_to_tenant() {
1059        let store = StateStore::new();
1060        store.scoped(Some("acme")).set("a", json!(1), "act");
1061        store.scoped(Some("acme")).set("b", json!(2), "act");
1062        store.scoped(Some("globex")).set("g", json!(9), "act");
1063        store.set("unscoped", json!(0), "act");
1064
1065        let mut acme_keys = store.scoped(Some("acme")).keys();
1066        acme_keys.sort();
1067        assert_eq!(acme_keys, vec!["a", "b"]);
1068
1069        let globex_keys = store.scoped(Some("globex")).keys();
1070        assert_eq!(globex_keys, vec!["g"]);
1071    }
1072
1073    #[test]
1074    fn unscoped_view_skips_tenant_prefixed_keys() {
1075        // Calling scoped(None) — the legacy-compat path — must NOT
1076        // accidentally expose other tenants' keys via `keys()`. This
1077        // is the inverse of the isolation contract: the unscoped
1078        // namespace shouldn't see scoped data even though it's all
1079        // in the same backing HashMap.
1080        let store = StateStore::new();
1081        store.set("legacy", json!("ok"), "act");
1082        store.scoped(Some("acme")).set("hidden", json!(42), "act");
1083
1084        let unscoped = store.scoped(None).keys();
1085        assert_eq!(unscoped, vec!["legacy"]);
1086        assert!(store.scoped(None).get("hidden").is_none());
1087    }
1088
1089    #[test]
1090    fn scoped_restore_does_not_clobber_other_tenants() {
1091        // The E2 fix: a tenant's rollback must restore only its own
1092        // namespace, leaving concurrent tenants' state intact.
1093        let store = StateStore::new();
1094        store.scoped(Some("acme")).set("k", json!("acme-v1"), "a");
1095        store
1096            .scoped(Some("globex"))
1097            .set("k", json!("globex-v1"), "a");
1098        store.set("global", json!("g-v1"), "a");
1099
1100        // Snapshot acme's namespace, then both tenants + global mutate.
1101        let acme_snap = store.scoped(Some("acme")).snapshot();
1102        store.scoped(Some("acme")).set("k", json!("acme-v2"), "a");
1103        store
1104            .scoped(Some("globex"))
1105            .set("k", json!("globex-v2"), "a");
1106        store.set("global", json!("g-v2"), "a");
1107
1108        // Roll acme back. Only acme reverts; globex + global keep v2.
1109        store.scoped(Some("acme")).restore(acme_snap, 0);
1110        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme-v1")));
1111        assert_eq!(
1112            store.scoped(Some("globex")).get("k"),
1113            Some(json!("globex-v2"))
1114        );
1115        assert_eq!(store.get("global"), Some(json!("g-v2")));
1116    }
1117
1118    #[test]
1119    fn snapshot_scoped_captures_only_its_namespace() {
1120        let store = StateStore::new();
1121        store.set("global", json!(1), "a");
1122        store.scoped(Some("acme")).set("x", json!(2), "a");
1123        store.scoped(Some("globex")).set("y", json!(3), "a");
1124
1125        let acme = store.snapshot_scoped(Some("acme"));
1126        assert_eq!(acme.len(), 1);
1127        assert!(acme.contains_key("tenant:acme:x"));
1128
1129        let global = store.snapshot_scoped(None);
1130        assert_eq!(global.len(), 1);
1131        assert!(global.contains_key("global"));
1132    }
1133
1134    #[test]
1135    fn unscoped_restore_leaves_tenant_keys_intact() {
1136        // The global (None) namespace restore must not wipe tenant keys.
1137        let store = StateStore::new();
1138        store.set("g", json!("v1"), "a");
1139        store.scoped(Some("acme")).set("k", json!("acme"), "a");
1140
1141        let snap = store.snapshot_scoped(None);
1142        store.set("g", json!("v2"), "a");
1143        store.restore_scoped(None, snap, 0);
1144
1145        assert_eq!(store.get("g"), Some(json!("v1")));
1146        // The tenant key survived the global rollback.
1147        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("acme")));
1148    }
1149
1150    #[test]
1151    fn scoped_restore_preserves_other_tenants_transitions() {
1152        // C-5 regression: a tenant-scoped rollback must FILTER the shared
1153        // transition log, not truncate it — truncation dropped concurrent
1154        // tenants' post-snapshot transitions, falsifying history and
1155        // letting the reaper act on a stale "latest" transition.
1156        let store = StateStore::new();
1157        store.scoped(Some("acme")).set("k", json!("a1"), "act");
1158
1159        // acme snapshots here.
1160        let snap = store.snapshot_scoped(Some("acme"));
1161        let count = store.transition_count();
1162
1163        // Concurrent activity after the snapshot: acme mutates (to be
1164        // rolled back) and globex commits (must survive).
1165        store.scoped(Some("acme")).set("k", json!("a2"), "act");
1166        store.scoped(Some("globex")).set("g", json!("gv"), "act");
1167
1168        store.restore_scoped(Some("acme"), snap, count);
1169
1170        // acme's post-snapshot transition is gone; globex's survived.
1171        let tail = store.transitions_since(count);
1172        assert_eq!(
1173            tail.len(),
1174            1,
1175            "exactly globex's transition survives: {tail:?}"
1176        );
1177        assert_eq!(tail[0].key, "tenant:globex:g");
1178        // Values match: acme rolled back, globex untouched.
1179        assert_eq!(store.scoped(Some("acme")).get("k"), Some(json!("a1")));
1180        assert_eq!(store.scoped(Some("globex")).get("g"), Some(json!("gv")));
1181    }
1182
1183    #[test]
1184    fn scoped_view_delete_doesnt_touch_other_tenants() {
1185        let store = StateStore::new();
1186        store.scoped(Some("acme")).set("shared", json!(1), "act");
1187        store.scoped(Some("globex")).set("shared", json!(2), "act");
1188
1189        store.scoped(Some("acme")).delete("shared", "act");
1190        assert!(!store.scoped(Some("acme")).exists("shared"));
1191        assert!(store.scoped(Some("globex")).exists("shared"));
1192    }
1193
1194    #[test]
1195    fn empty_tenant_string_treated_as_unscoped() {
1196        // Some(""): defensive — RuntimeScope normalizes empty strings
1197        // to None at the dispatcher, but the view shouldn't trip if
1198        // a caller passes an empty tenant by mistake.
1199        let store = StateStore::new();
1200        store.scoped(Some("")).set("k", json!(1), "act");
1201        assert_eq!(store.get("k"), Some(json!(1)));
1202        assert_eq!(store.scoped(None).get("k"), Some(json!(1)));
1203    }
1204}