Skip to main content

glass/browser/session/
knowledge_store.rs

1//! Crash-safe local persistence for bounded [`super::KnowledgeRecord`] values.
2//!
3//! The store is intentionally an explicit library component. Opening it does
4//! not make a browser session consult remembered data; callers must opt into
5//! each later knowledge-assisted operation.
6
7use super::{
8    KNOWLEDGE_SCHEMA_VERSION, KnowledgeAssessment, KnowledgeConfidence, KnowledgeLookupContext,
9    KnowledgeRecord, KnowledgeStoreSnapshot, KnowledgeValidationError, MAX_KNOWLEDGE_RECORDS,
10};
11use fs2::FileExt;
12use serde::Serialize;
13use std::cmp::Ordering;
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
18
19pub const DEFAULT_KNOWLEDGE_STORE_BYTES: usize = 4 * 1024 * 1024;
20const STORE_LOCK_SUFFIX: &str = ".lock";
21static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
22
23/// Default per-profile knowledge file used by local management surfaces.
24pub fn default_knowledge_store_path(profile: &str) -> PathBuf {
25    std::env::var_os("GLASS_CONFIG_HOME")
26        .map(PathBuf::from)
27        .or_else(dirs::config_dir)
28        .unwrap_or_else(|| PathBuf::from("."))
29        .join("glass")
30        .join("knowledge")
31        .join(format!("{profile}.json"))
32}
33
34/// Resource bounds for one local knowledge store.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct KnowledgeStoreLimits {
37    pub max_records: usize,
38    pub max_bytes: usize,
39}
40
41impl Default for KnowledgeStoreLimits {
42    fn default() -> Self {
43        Self {
44            max_records: MAX_KNOWLEDGE_RECORDS,
45            max_bytes: DEFAULT_KNOWLEDGE_STORE_BYTES,
46        }
47    }
48}
49
50impl KnowledgeStoreLimits {
51    fn validate(self) -> Result<(), KnowledgeStoreError> {
52        if self.max_records == 0 || self.max_records > MAX_KNOWLEDGE_RECORDS {
53            return Err(KnowledgeStoreError::InvalidConfiguration(format!(
54                "maxRecords must be between 1 and {MAX_KNOWLEDGE_RECORDS}"
55            )));
56        }
57        if self.max_bytes == 0 {
58            return Err(KnowledgeStoreError::InvalidConfiguration(
59                "maxBytes must be positive".into(),
60            ));
61        }
62        Ok(())
63    }
64}
65
66/// Result of an upsert or removal, including deterministic pruning evidence.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct KnowledgeStoreChange {
70    pub record_id: String,
71    pub retained: bool,
72    pub removed: bool,
73    pub pruned_record_ids: Vec<String>,
74}
75
76/// Bounded store statistics suitable for CLI/TUI display.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct KnowledgeStoreStats {
80    pub record_count: usize,
81    pub serialized_bytes: usize,
82    pub candidate_count: usize,
83    pub observed_count: usize,
84    pub verified_count: usize,
85    pub stale_count: usize,
86    pub contradicted_count: usize,
87    pub quarantined_count: usize,
88}
89
90/// A validated local knowledge store backed by one JSON snapshot file.
91#[derive(Debug)]
92pub struct KnowledgeStore {
93    path: PathBuf,
94    limits: KnowledgeStoreLimits,
95    snapshot: KnowledgeStoreSnapshot,
96    last_pruned: Option<Vec<String>>,
97}
98
99impl KnowledgeStore {
100    /// Open an existing store or an empty store at `path`.
101    pub fn open(path: impl Into<PathBuf>) -> Result<Self, KnowledgeStoreError> {
102        Self::open_with_limits(path, KnowledgeStoreLimits::default())
103    }
104
105    /// Open a store with explicit record and serialized-byte limits.
106    pub fn open_with_limits(
107        path: impl Into<PathBuf>,
108        limits: KnowledgeStoreLimits,
109    ) -> Result<Self, KnowledgeStoreError> {
110        limits.validate()?;
111        let path = path.into();
112        let snapshot = read_snapshot(&path)?;
113        validate_snapshot_size(&snapshot, limits.max_bytes)?;
114        Ok(Self {
115            path,
116            limits,
117            snapshot,
118            last_pruned: None,
119        })
120    }
121
122    /// Return the path used for the snapshot file.
123    pub fn path(&self) -> &Path {
124        &self.path
125    }
126
127    /// Return the active store limits.
128    pub fn limits(&self) -> KnowledgeStoreLimits {
129        self.limits
130    }
131
132    /// Return the last validated in-memory snapshot.
133    pub fn snapshot(&self) -> &KnowledgeStoreSnapshot {
134        &self.snapshot
135    }
136
137    /// Re-read the snapshot under the store lock.
138    pub fn refresh(&mut self) -> Result<(), KnowledgeStoreError> {
139        let _lock = StoreLock::acquire(&self.path)?;
140        let snapshot = read_snapshot(&self.path)?;
141        validate_snapshot_size(&snapshot, self.limits.max_bytes)?;
142        self.snapshot = snapshot;
143        self.last_pruned = None;
144        Ok(())
145    }
146
147    /// Find one record by its stable ID.
148    pub fn get(&self, record_id: &str) -> Option<&KnowledgeRecord> {
149        self.snapshot
150            .records
151            .iter()
152            .find(|record| record.record_id == record_id)
153    }
154
155    /// Assess all records against fresh session dimensions without changing
156    /// the store or returning any executable browser reference.
157    pub fn assess(&self, context: &KnowledgeLookupContext) -> Vec<KnowledgeAssessment> {
158        self.snapshot
159            .records
160            .iter()
161            .map(|record| record.assess(context))
162            .collect()
163    }
164
165    /// Return the validated records for inspection or export.
166    pub fn records(&self) -> &[KnowledgeRecord] {
167        &self.snapshot.records
168    }
169
170    /// Return bounded serialized-size and lifecycle counts.
171    pub fn stats(&self) -> Result<KnowledgeStoreStats, KnowledgeStoreError> {
172        let mut stats = KnowledgeStoreStats {
173            record_count: self.snapshot.records.len(),
174            serialized_bytes: serialized_size(&self.snapshot)?,
175            candidate_count: 0,
176            observed_count: 0,
177            verified_count: 0,
178            stale_count: 0,
179            contradicted_count: 0,
180            quarantined_count: 0,
181        };
182        for record in &self.snapshot.records {
183            match record.confidence {
184                KnowledgeConfidence::Candidate => stats.candidate_count += 1,
185                KnowledgeConfidence::Observed => stats.observed_count += 1,
186                KnowledgeConfidence::Verified => stats.verified_count += 1,
187                KnowledgeConfidence::Stale => stats.stale_count += 1,
188                KnowledgeConfidence::Contradicted => stats.contradicted_count += 1,
189                KnowledgeConfidence::Quarantined => stats.quarantined_count += 1,
190            }
191        }
192        Ok(stats)
193    }
194
195    /// Replace the complete store only after validating its schema and bounds.
196    pub fn replace_snapshot(
197        &mut self,
198        snapshot: KnowledgeStoreSnapshot,
199    ) -> Result<(), KnowledgeStoreError> {
200        snapshot
201            .validate()
202            .map_err(KnowledgeStoreError::InvalidContract)?;
203        if snapshot.records.len() > self.limits.max_records {
204            return Err(KnowledgeStoreError::Capacity(
205                "snapshot exceeds the configured record limit".into(),
206            ));
207        }
208        validate_snapshot_size(&snapshot, self.limits.max_bytes)?;
209        let _lock = StoreLock::acquire(&self.path)?;
210        write_snapshot(&self.path, &snapshot, self.limits.max_bytes)?;
211        self.snapshot = snapshot;
212        self.last_pruned = None;
213        Ok(())
214    }
215
216    /// Transition one record and retain the lifecycle evidence in its history.
217    pub fn transition(
218        &mut self,
219        record_id: &str,
220        next: KnowledgeConfidence,
221        reason: String,
222        observed_at: String,
223        fresh_verification: bool,
224    ) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
225        let record_id = record_id.to_owned();
226        self.mutate(|snapshot| {
227            let record = snapshot
228                .records
229                .iter_mut()
230                .find(|record| record.record_id == record_id)
231                .ok_or_else(|| KnowledgeStoreError::NotFound(record_id.clone()))?;
232            record.transition(next, reason, observed_at, fresh_verification)?;
233            Ok(())
234        })?;
235        Ok(KnowledgeStoreChange {
236            retained: true,
237            removed: false,
238            record_id,
239            pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
240        })
241    }
242
243    /// Remove every record scoped to one exact origin.
244    pub fn purge_origin(
245        &mut self,
246        origin: &str,
247    ) -> Result<KnowledgePurgeResult, KnowledgeStoreError> {
248        let mut removed_record_ids = Vec::new();
249        self.mutate(|snapshot| {
250            snapshot.records.retain(|record| {
251                if record.scope.origin == origin {
252                    removed_record_ids.push(record.record_id.clone());
253                    false
254                } else {
255                    true
256                }
257            });
258            Ok(())
259        })?;
260        Ok(KnowledgePurgeResult {
261            removed_record_ids,
262            pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
263        })
264    }
265
266    /// Insert or replace a record, then prune the least useful records first.
267    pub fn upsert(
268        &mut self,
269        record: KnowledgeRecord,
270    ) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
271        record.validate()?;
272        if record.to_canonical_json()?.len() > self.limits.max_bytes {
273            return Err(KnowledgeStoreError::Capacity(
274                "record exceeds the configured store byte limit".into(),
275            ));
276        }
277        let record_id = record.record_id.clone();
278        self.mutate(|snapshot| {
279            if let Some(existing) = snapshot
280                .records
281                .iter_mut()
282                .find(|existing| existing.record_id == record_id)
283            {
284                *existing = record;
285            } else {
286                snapshot.records.push(record);
287            }
288            Ok(())
289        })?;
290        let retained = self.get(&record_id).is_some();
291        if !retained {
292            return Err(KnowledgeStoreError::Capacity(
293                "record was pruned by the configured store limits".into(),
294            ));
295        }
296        Ok(KnowledgeStoreChange {
297            record_id,
298            retained,
299            removed: false,
300            pruned_record_ids: self.last_pruned.take().unwrap_or_default(),
301        })
302    }
303
304    /// Remove one record and persist the resulting snapshot.
305    pub fn remove(&mut self, record_id: &str) -> Result<KnowledgeStoreChange, KnowledgeStoreError> {
306        let record_id = record_id.to_owned();
307        let existed = self.mutate(|snapshot| {
308            let before = snapshot.records.len();
309            snapshot
310                .records
311                .retain(|record| record.record_id != record_id);
312            Ok(before != snapshot.records.len())
313        })?;
314        Ok(KnowledgeStoreChange {
315            record_id,
316            retained: false,
317            removed: existed,
318            pruned_record_ids: if existed {
319                self.last_pruned.take().unwrap_or_default()
320            } else {
321                Vec::new()
322            },
323        })
324    }
325}
326
327impl KnowledgeStore {
328    fn mutate<T, F>(&mut self, operation: F) -> Result<T, KnowledgeStoreError>
329    where
330        F: FnOnce(&mut KnowledgeStoreSnapshot) -> Result<T, KnowledgeStoreError>,
331    {
332        let _lock = StoreLock::acquire(&self.path)?;
333        let mut snapshot = read_snapshot(&self.path)?;
334        let result = operation(&mut snapshot)?;
335        let pruned = prune_snapshot(&mut snapshot, self.limits)?;
336        write_snapshot(&self.path, &snapshot, self.limits.max_bytes)?;
337        self.snapshot = snapshot;
338        self.last_pruned = Some(pruned);
339        Ok(result)
340    }
341}
342
343fn read_snapshot(path: &Path) -> Result<KnowledgeStoreSnapshot, KnowledgeStoreError> {
344    let bytes = match fs::read(path) {
345        Ok(bytes) => bytes,
346        Err(error) if error.kind() == io::ErrorKind::NotFound => {
347            return Ok(KnowledgeStoreSnapshot {
348                schema_version: KNOWLEDGE_SCHEMA_VERSION,
349                records: Vec::new(),
350            });
351        }
352        Err(error) => return Err(KnowledgeStoreError::Io(error)),
353    };
354    let snapshot = serde_json::from_slice::<KnowledgeStoreSnapshot>(&bytes)
355        .map_err(|error| KnowledgeStoreError::Corrupt(format!("invalid JSON snapshot: {error}")))?;
356    snapshot
357        .validate()
358        .map_err(KnowledgeStoreError::InvalidContract)?;
359    Ok(snapshot)
360}
361
362fn write_snapshot(
363    path: &Path,
364    snapshot: &KnowledgeStoreSnapshot,
365    max_bytes: usize,
366) -> Result<(), KnowledgeStoreError> {
367    let canonical = snapshot
368        .to_canonical_json()
369        .map_err(KnowledgeStoreError::InvalidContract)?;
370    if canonical.len() > max_bytes {
371        return Err(KnowledgeStoreError::Capacity(
372            "snapshot exceeds the configured store byte limit".into(),
373        ));
374    }
375    let parent = parent_dir(path);
376    fs::create_dir_all(parent).map_err(KnowledgeStoreError::Io)?;
377    let file_name = path
378        .file_name()
379        .and_then(|name| name.to_str())
380        .ok_or_else(|| {
381            KnowledgeStoreError::InvalidConfiguration("store path has no filename".into())
382        })?;
383    let sequence = TEMP_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed);
384    let temporary = parent.join(format!(
385        ".{file_name}.{}.{}.tmp",
386        std::process::id(),
387        sequence
388    ));
389    let result = (|| {
390        let mut file = OpenOptions::new()
391            .write(true)
392            .create_new(true)
393            .open(&temporary)
394            .map_err(KnowledgeStoreError::Io)?;
395        file.write_all(canonical.as_bytes())
396            .map_err(KnowledgeStoreError::Io)?;
397        file.sync_all().map_err(KnowledgeStoreError::Io)?;
398        fs::rename(&temporary, path).map_err(KnowledgeStoreError::Io)?;
399        #[cfg(unix)]
400        File::open(parent)
401            .and_then(|directory| directory.sync_all())
402            .map_err(KnowledgeStoreError::Io)?;
403        Ok(())
404    })();
405    if result.is_err() {
406        let _ = fs::remove_file(&temporary);
407    }
408    result
409}
410
411fn validate_snapshot_size(
412    snapshot: &KnowledgeStoreSnapshot,
413    max_bytes: usize,
414) -> Result<(), KnowledgeStoreError> {
415    let bytes = snapshot
416        .to_canonical_json()
417        .map_err(KnowledgeStoreError::InvalidContract)?
418        .len();
419    if bytes > max_bytes {
420        return Err(KnowledgeStoreError::Capacity(
421            "snapshot exceeds the configured store byte limit".into(),
422        ));
423    }
424    Ok(())
425}
426
427fn prune_snapshot(
428    snapshot: &mut KnowledgeStoreSnapshot,
429    limits: KnowledgeStoreLimits,
430) -> Result<Vec<String>, KnowledgeStoreError> {
431    let mut pruned = Vec::new();
432    while snapshot.records.len() > limits.max_records
433        || serialized_size(snapshot)? > limits.max_bytes
434    {
435        let Some(index) = snapshot
436            .records
437            .iter()
438            .enumerate()
439            .min_by(|(_, left), (_, right)| prune_order(left, right))
440            .map(|(index, _)| index)
441        else {
442            break;
443        };
444        pruned.push(snapshot.records.remove(index).record_id);
445    }
446    if serialized_size(snapshot)? > limits.max_bytes {
447        return Err(KnowledgeStoreError::Capacity(
448            "cannot fit the snapshot within the configured byte limit".into(),
449        ));
450    }
451    Ok(pruned)
452}
453
454fn serialized_size(snapshot: &KnowledgeStoreSnapshot) -> Result<usize, KnowledgeStoreError> {
455    snapshot
456        .to_canonical_json()
457        .map(|json| json.len())
458        .map_err(KnowledgeStoreError::InvalidContract)
459}
460
461fn prune_order(left: &KnowledgeRecord, right: &KnowledgeRecord) -> Ordering {
462    confidence_rank(left.confidence)
463        .cmp(&confidence_rank(right.confidence))
464        .then_with(|| {
465            left.source
466                .last_verified_at
467                .cmp(&right.source.last_verified_at)
468        })
469        .then_with(|| left.record_id.cmp(&right.record_id))
470}
471
472fn confidence_rank(confidence: KnowledgeConfidence) -> u8 {
473    match confidence {
474        KnowledgeConfidence::Quarantined => 0,
475        KnowledgeConfidence::Contradicted => 1,
476        KnowledgeConfidence::Stale => 2,
477        KnowledgeConfidence::Candidate => 3,
478        KnowledgeConfidence::Observed => 4,
479        KnowledgeConfidence::Verified => 5,
480    }
481}
482
483struct StoreLock {
484    file: File,
485}
486
487impl StoreLock {
488    fn acquire(path: &Path) -> Result<Self, KnowledgeStoreError> {
489        let lock_path = PathBuf::from(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
490        fs::create_dir_all(parent_dir(&lock_path)).map_err(KnowledgeStoreError::Io)?;
491        let file = OpenOptions::new()
492            .create(true)
493            .truncate(false)
494            .read(true)
495            .write(true)
496            .open(lock_path)
497            .map_err(KnowledgeStoreError::Io)?;
498        file.lock_exclusive().map_err(KnowledgeStoreError::Io)?;
499        Ok(Self { file })
500    }
501}
502
503fn parent_dir(path: &Path) -> &Path {
504    path.parent()
505        .filter(|parent| !parent.as_os_str().is_empty())
506        .unwrap_or_else(|| Path::new("."))
507}
508
509impl Drop for StoreLock {
510    fn drop(&mut self) {
511        let _ = FileExt::unlock(&self.file);
512    }
513}
514
515/// Errors returned by the local knowledge store.
516#[derive(Debug)]
517pub enum KnowledgeStoreError {
518    Io(io::Error),
519    Corrupt(String),
520    InvalidContract(KnowledgeValidationError),
521    InvalidConfiguration(String),
522    Capacity(String),
523    NotFound(String),
524}
525
526impl std::fmt::Display for KnowledgeStoreError {
527    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            Self::Io(error) => write!(formatter, "knowledge store I/O error: {error}"),
530            Self::Corrupt(reason) => write!(formatter, "knowledge store is corrupt: {reason}"),
531            Self::InvalidContract(error) => write!(formatter, "knowledge contract: {error}"),
532            Self::InvalidConfiguration(reason) => {
533                write!(formatter, "invalid knowledge store configuration: {reason}")
534            }
535            Self::Capacity(reason) => write!(formatter, "knowledge store capacity: {reason}"),
536            Self::NotFound(record_id) => {
537                write!(formatter, "knowledge record not found: {record_id}")
538            }
539        }
540    }
541}
542
543/// Result of purging records for one origin.
544#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
545#[serde(rename_all = "camelCase")]
546pub struct KnowledgePurgeResult {
547    pub removed_record_ids: Vec<String>,
548    pub pruned_record_ids: Vec<String>,
549}
550
551impl std::error::Error for KnowledgeStoreError {}
552
553impl From<KnowledgeValidationError> for KnowledgeStoreError {
554    fn from(error: KnowledgeValidationError) -> Self {
555        Self::InvalidContract(error)
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::browser::session::{
563        KnowledgeInvalidation, KnowledgeLookupContext, KnowledgeProfileScope, KnowledgeRecordKind,
564        KnowledgeScope, KnowledgeSource,
565    };
566    use serde_json::json;
567    use std::sync::atomic::{AtomicU64, Ordering};
568
569    static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
570
571    fn test_path() -> PathBuf {
572        std::env::temp_dir().join(format!(
573            "glass-knowledge-store-{}-{}",
574            std::process::id(),
575            TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed)
576        ))
577    }
578
579    fn record(id: &str, confidence: KnowledgeConfidence, verified_at: &str) -> KnowledgeRecord {
580        KnowledgeRecord {
581            schema_version: KNOWLEDGE_SCHEMA_VERSION,
582            record_id: id.into(),
583            kind: KnowledgeRecordKind::PageFamily,
584            scope: KnowledgeScope {
585                origin: "https://example.test".into(),
586                path_pattern: "/docs/*".into(),
587                profile_scope: KnowledgeProfileScope::Anonymous,
588                profile_key: None,
589                locale: None,
590                tenant_key: None,
591                browser_family: "chromium".into(),
592                browser_version_range: None,
593                glass_schema_version: 1,
594                policy_preset: "balanced".into(),
595            },
596            source: KnowledgeSource {
597                first_seen_at: format!("2026-07-27T00:00:{verified_at:0>2}Z"),
598                last_verified_at: format!("2026-07-27T00:00:{verified_at:0>2}Z"),
599                glass_version: "0.2.0".into(),
600                verification_count: 1,
601            },
602            confidence,
603            invalidation: KnowledgeInvalidation::default(),
604            data: json!({"pageKind": "documentation"}),
605            history: Vec::new(),
606        }
607    }
608
609    #[test]
610    fn upsert_reopens_from_an_atomic_snapshot() {
611        let path = test_path();
612        let mut store = KnowledgeStore::open(&path).unwrap();
613        store
614            .upsert(record("knowledge_1", KnowledgeConfidence::Verified, "2"))
615            .unwrap();
616        let reopened = KnowledgeStore::open(&path).unwrap();
617        assert!(reopened.get("knowledge_1").is_some());
618        assert_eq!(reopened.snapshot().records.len(), 1);
619        let _ = fs::remove_file(&path);
620        let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
621    }
622
623    #[test]
624    fn pruning_prefers_low_confidence_and_old_records() {
625        let path = test_path();
626        let limits = KnowledgeStoreLimits {
627            max_records: 2,
628            max_bytes: DEFAULT_KNOWLEDGE_STORE_BYTES,
629        };
630        let mut store = KnowledgeStore::open_with_limits(&path, limits).unwrap();
631        store
632            .upsert(record("verified", KnowledgeConfidence::Verified, "1"))
633            .unwrap();
634        store
635            .upsert(record("stale", KnowledgeConfidence::Stale, "3"))
636            .unwrap();
637        let change = store
638            .upsert(record("observed", KnowledgeConfidence::Observed, "2"))
639            .unwrap();
640        assert_eq!(change.pruned_record_ids, vec!["stale"]);
641        assert!(store.get("verified").is_some());
642        assert!(store.get("observed").is_some());
643        let _ = fs::remove_file(&path);
644        let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
645    }
646
647    #[test]
648    fn malformed_snapshot_is_not_silently_replaced() {
649        let path = test_path();
650        fs::write(&path, b"not-json").unwrap();
651        let error = KnowledgeStore::open(&path).unwrap_err();
652        assert!(matches!(error, KnowledgeStoreError::Corrupt(_)));
653        let _ = fs::remove_file(&path);
654    }
655
656    #[test]
657    fn assessment_is_read_only_and_returns_current_evidence() {
658        let path = test_path();
659        let mut store = KnowledgeStore::open(&path).unwrap();
660        store
661            .upsert(record("knowledge_1", KnowledgeConfidence::Verified, "01"))
662            .unwrap();
663        let context = KnowledgeLookupContext {
664            origin: "https://example.test".into(),
665            path: "/docs/start".into(),
666            profile_scope: KnowledgeProfileScope::Anonymous,
667            profile_key: None,
668            locale: None,
669            tenant_key: None,
670            browser_family: "chromium".into(),
671            browser_version: None,
672            glass_schema_version: 1,
673            policy_preset: "balanced".into(),
674            landmarks: vec!["documentation".into()],
675            now_epoch_seconds: chrono::DateTime::parse_from_rfc3339("2026-07-27T00:00:02Z")
676                .unwrap()
677                .timestamp(),
678        };
679        let assessments = store.assess(&context);
680        assert_eq!(assessments.len(), 1);
681        assert_eq!(assessments[0].record_id, "knowledge_1");
682        assert_eq!(store.snapshot().records.len(), 1);
683        let _ = fs::remove_file(&path);
684        let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
685    }
686
687    #[test]
688    fn lifecycle_management_and_stats_are_persisted() {
689        let path = test_path();
690        let mut store = KnowledgeStore::open(&path).unwrap();
691        store
692            .upsert(record("keep", KnowledgeConfidence::Observed, "01"))
693            .unwrap();
694        store
695            .upsert(record("purge", KnowledgeConfidence::Observed, "02"))
696            .unwrap();
697        store
698            .transition(
699                "keep",
700                KnowledgeConfidence::Verified,
701                "fresh landmark verification".into(),
702                "2026-07-27T00:00:03Z".into(),
703                true,
704            )
705            .unwrap();
706        let stats = store.stats().unwrap();
707        assert_eq!(stats.record_count, 2);
708        assert_eq!(stats.verified_count, 1);
709        let purge = store.purge_origin("https://example.test").unwrap();
710        assert_eq!(purge.removed_record_ids, vec!["keep", "purge"]);
711        assert!(store.records().is_empty());
712        let _ = fs::remove_file(&path);
713        let _ = fs::remove_file(format!("{}{}", path.display(), STORE_LOCK_SUFFIX));
714    }
715}