Skip to main content

lean_ctx/core/knowledge/
persist.rs

1use chrono::Utc;
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use super::ranking::hash_project_root;
7use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
8use crate::core::memory_policy::MemoryPolicy;
9
10fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
11    Ok(crate::core::data_dir::lean_ctx_data_dir()?
12        .join("knowledge")
13        .join(project_hash))
14}
15
16/// The directory of `project_root`'s store (`knowledge.json`, its journal).
17pub fn knowledge_dir_of(project_root: &str) -> Result<PathBuf, String> {
18    knowledge_dir(&hash_project_root(project_root))
19}
20
21/// How long a knowledge write waits for the project's in-process and file
22/// locks. Bounded, so a stuck holder cannot hang a tool call; long enough that
23/// parallel `remember` calls on a loaded machine queue instead of failing,
24/// which would lose the fact #326 is about (2 s did not hold on a busy CI
25/// runner with seven writers).
26const KNOWLEDGE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
27
28/// Per-project-hash mutex registry. Serializes the read-modify-write cycle of
29/// `mutate_locked` so concurrent `remember` calls within a single process (e.g.
30/// parallel MCP tool calls) cannot clobber each other (issue #326). The outer
31/// map lock is held only briefly to clone the inner `Arc`; the inner lock is
32/// held across the load → mutate → save cycle.
33fn knowledge_lock(project_hash: &str) -> Arc<Mutex<()>> {
34    static KNOWLEDGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
35    let map = KNOWLEDGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
36    let mut guard = map
37        .lock()
38        .unwrap_or_else(std::sync::PoisonError::into_inner);
39    guard
40        .entry(project_hash.to_string())
41        .or_insert_with(|| Arc::new(Mutex::new(())))
42        .clone()
43}
44
45/// Acquires an exclusive, cross-process advisory lock for a project's
46/// knowledge store. The returned file handle holds the lock until it is
47/// dropped; the OS releases it automatically if the process exits (even on
48/// crash), so there are no stale locks. This serializes the read-modify-write
49/// cycle across *separate processes* (parallel CLI invocations, CLI + daemon +
50/// MCP server), complementing the in-process mutex (issue #326).
51#[cfg(feature = "embeddings")]
52fn acquire_file_lock(dir: &Path) -> Option<std::fs::File> {
53    use fs2::FileExt;
54    let lock_path = dir.join(".knowledge.lock");
55    let file = std::fs::OpenOptions::new()
56        .create(true)
57        .truncate(false)
58        .write(true)
59        .open(&lock_path)
60        .ok()?;
61    #[cfg(unix)]
62    {
63        use std::os::unix::fs::PermissionsExt;
64        let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
65    }
66    // Blocks until every other process holding the lock releases it. A failure
67    // here (unsupported FS, etc.) degrades to the in-process lock only.
68    file.lock_exclusive().ok()?;
69    Some(file)
70}
71
72/// Atomically writes `json` to `path` by writing to a unique temp file in the
73/// same directory and renaming it into place. `rename` is atomic on every
74/// supported platform (and replaces the target on Windows), so readers and
75/// concurrent writers never observe a half-written file — preventing the
76/// trailing-garbage JSON corruption reported in issue #326. The temp file and
77/// the directory are synced, so a published store also survives a crash.
78fn write_json_atomic(path: &Path, json: &str) -> Result<(), String> {
79    #[cfg(unix)]
80    let owner_only: Option<std::fs::Permissions> =
81        Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
82    #[cfg(not(unix))]
83    let owner_only: Option<std::fs::Permissions> = None;
84    crate::core::atomic_fs::try_atomic_write(path, json.as_bytes(), owner_only.as_ref())
85        .map_err(|_| "knowledge publication failed".to_string())
86}
87
88/// Parsed stores, reused while their file is unchanged. Every tool call loads
89/// the store and parsing a large one is the whole cost (knowledge grows
90/// without limit, owner decision 24.10.). The key is length, modification
91/// time and inode: an atomic write replaces the file, so the inode alone
92/// already tells a rewrite apart. Each caller gets its own copy; views,
93/// admission and the project check still run on every load.
94fn parse_store_cached(path: &Path) -> Result<ProjectKnowledge, &'static str> {
95    type Key = (u64, Option<std::time::SystemTime>, u64);
96    static PARSED: OnceLock<Mutex<HashMap<PathBuf, (Key, ProjectKnowledge)>>> = OnceLock::new();
97    const KEPT: usize = 8;
98
99    let metadata =
100        std::fs::symlink_metadata(path).map_err(|_| "knowledge input metadata unavailable")?;
101    if !metadata.is_file() {
102        return Err("knowledge input is unavailable or uninspectable");
103    }
104    #[cfg(unix)]
105    let inode = std::os::unix::fs::MetadataExt::ino(&metadata);
106    #[cfg(not(unix))]
107    let inode = 0;
108    let key: Key = (metadata.len(), metadata.modified().ok(), inode);
109    let cache = PARSED.get_or_init(Mutex::default);
110    if let Some((cached_key, knowledge)) = cache
111        .lock()
112        .unwrap_or_else(std::sync::PoisonError::into_inner)
113        .get(path)
114        && *cached_key == key
115    {
116        return Ok(knowledge.clone());
117    }
118    let text = crate::core::policy::diagnostics::Target::read_store_file(path)
119        .ok_or("knowledge input is unavailable or uninspectable")?;
120    let knowledge: ProjectKnowledge =
121        serde_json::from_str(&text).map_err(|_| "knowledge input is invalid")?;
122    let mut parsed = cache
123        .lock()
124        .unwrap_or_else(std::sync::PoisonError::into_inner);
125    if parsed.len() >= KEPT && !parsed.contains_key(path) {
126        parsed.clear();
127    }
128    parsed.insert(path.to_path_buf(), (key, knowledge.clone()));
129    Ok(knowledge)
130}
131
132/// Whether every record of a serialized store passes the policy unchanged.
133/// Records are inspected one at a time: the inspector refuses any value over
134/// the protected-content cap, and the store as a whole outgrows it (owner
135/// decision 24.10.: no knowledge limits).
136fn store_is_policy_safe(
137    store: &serde_json::Value,
138    policy: &crate::core::policy::runtime::ActivePolicy,
139) -> bool {
140    let unchanged = |value: &serde_json::Value| {
141        crate::core::policy::diagnostics::inspect(value, Some(policy)).as_ref() == Some(value)
142    };
143    match store {
144        serde_json::Value::Object(fields) => fields.values().all(|field| match field {
145            serde_json::Value::Array(records) => records.iter().all(unchanged),
146            other => unchanged(other),
147        }),
148        other => unchanged(other),
149    }
150}
151
152/// Marker that identifies a fact as machine-derived rather than curated.
153///
154/// Both automatic producers already carry it: `extract_session_facts` tags the
155/// *category* (`auto:decision`, `auto:pattern`, `auto:blocker`) and
156/// `auto_capture` derives an `auto:`-prefixed *key*. Nothing a person writes
157/// through `ctx_knowledge(action="remember")` does.
158pub(crate) const MACHINE_DERIVED_PREFIX: &str = "auto:";
159
160/// Whether this fact was derived by lean-ctx rather than written by a person.
161pub fn is_machine_derived(category: &str, key: &str) -> bool {
162    category.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
163        || key.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
164}
165
166/// Whether a machine-derived fact may enter the durable store right now.
167///
168/// #1802: `auto_capture = false` was honoured by exactly one producer
169/// (`auto_capture::capture_finding`) and ignored by the other
170/// (`extract_session_facts`, reached from both the consolidation engine and
171/// the session *save* path) — so a single MCP call re-materialised every
172/// deleted `auto:*` fact, complete with its original `created_at`, and a
173/// curated store could not be kept clean.
174///
175/// The gate therefore lives here, at the store's own ingestion points, and not
176/// at the producers: a check placed at a call site only covers the call sites
177/// that exist when it is written, which is exactly how this defect arose.
178/// Session state itself is left untouched — it is ephemeral, capacity-capped,
179/// and backs handoff, session recap and metrics, none of which this key
180/// disables.
181pub(crate) fn machine_derived_writes_allowed() -> bool {
182    crate::core::auto_capture::is_enabled()
183}
184
185impl ProjectKnowledge {
186    /// Return the most recent active decision-like facts for session handoff.
187    /// Adds a pre-built fact, coalescing repeated observations into confirmations.
188    pub fn add_fact(&mut self, mut fact: KnowledgeFact) -> bool {
189        let trimmed_cat = fact.category.trim().to_string();
190        fact.category = trimmed_cat;
191        let trimmed_val = fact.value.trim().to_string();
192        fact.value = trimmed_val;
193        if fact.category.is_empty() || fact.value.is_empty() {
194            return false;
195        }
196        // #1802: refuse machine-derived facts while auto-capture is off. Placed
197        // before the coalescing branch below so a disabled run cannot even
198        // refresh `last_confirmed` on facts a previous enabled run left behind.
199        if is_machine_derived(&fact.category, &fact.key) && !machine_derived_writes_allowed() {
200            return false;
201        }
202        // Same store boundary as `remember_with_origin` (G5, E3).
203        let Some([category, key, value]) = super::core::admit_fact_fields(
204            &self.project_root,
205            [&fact.category, &fact.key, &fact.value],
206        ) else {
207            return false;
208        };
209        (fact.category, fact.key, fact.value) = (category, key, value);
210
211        if let Some(existing) = self.facts.iter_mut().find(|existing| {
212            existing.category == fact.category
213                && existing.value == fact.value
214                && existing.origin == fact.origin
215        }) {
216            if fact.last_confirmed > existing.last_confirmed {
217                existing.last_confirmed = fact.last_confirmed;
218            }
219            existing.confidence = existing.confidence.max(fact.confidence);
220            existing.confirmation_count = existing.confirmation_count.saturating_add(1);
221            existing.update_fidelity();
222        } else {
223            fact.update_fidelity();
224            self.facts.push(fact);
225            self.rebuild_index();
226        }
227
228        self.updated_at = Utc::now();
229        true
230    }
231
232    pub fn recent_decisions(&self, limit: usize) -> Vec<KnowledgeFact> {
233        let mut decisions: Vec<KnowledgeFact> = self
234            .facts
235            .iter()
236            .filter(|fact| {
237                let category = fact.category.to_ascii_lowercase();
238                fact.is_current()
239                    && (category.contains("decision")
240                        || category.contains("architecture")
241                        || category.contains("solution"))
242            })
243            .cloned()
244            .collect();
245        decisions.sort_by(|a, b| {
246            b.last_confirmed
247                .cmp(&a.last_confirmed)
248                .then_with(|| b.created_at.cmp(&a.created_at))
249        });
250        decisions.truncate(limit);
251        decisions
252    }
253    /// Keyword-based knowledge search across all facts.
254    /// Serves as the API surface for semantic retrieval; currently falls back
255    /// to substring matching. When the embedding model is loaded, this will
256    /// use HNSW vector search instead.
257    pub fn search_semantic(&self, query: &str, limit: usize) -> Vec<KnowledgeFact> {
258        let query_lower = query.to_lowercase();
259        let terms: Vec<&str> = query_lower.split_whitespace().collect();
260        if terms.is_empty() {
261            return Vec::new();
262        }
263        let mut scored: Vec<(usize, &KnowledgeFact)> = self
264            .facts
265            .iter()
266            .filter(|f| f.valid_until.is_none_or(|t| t > chrono::Utc::now()))
267            .map(|fact| {
268                let haystack = format!(
269                    "{} {} {}",
270                    fact.category.to_lowercase(),
271                    fact.value.to_lowercase(),
272                    fact.key.to_lowercase(),
273                );
274                let score = terms.iter().filter(|t| haystack.contains(*t)).count();
275                (score, fact)
276            })
277            .filter(|(score, _)| *score > 0)
278            .collect();
279        scored.sort_by_key(|a| std::cmp::Reverse(a.0));
280        scored
281            .into_iter()
282            .take(limit)
283            .map(|(_, f)| f.clone())
284            .collect()
285    }
286
287    pub fn list_project_roots() -> Result<Vec<String>, String> {
288        let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
289        if !base.exists() {
290            return Ok(Vec::new());
291        }
292
293        let mut roots = Vec::new();
294        let mut seen = HashSet::new();
295        let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
296        for entry in entries.flatten() {
297            let path = entry.path().join("knowledge.json");
298            if !path.is_file() {
299                continue;
300            }
301            let Ok(content) = std::fs::read_to_string(&path) else {
302                continue;
303            };
304            let Ok(knowledge) = serde_json::from_str::<Self>(&content) else {
305                continue;
306            };
307            if seen.insert(knowledge.project_root.clone()) {
308                roots.push(knowledge.project_root);
309            }
310        }
311
312        roots.sort();
313        Ok(roots)
314    }
315
316    /// Load one admitted, non-migrating read view for an export operation.
317    /// Hidden rows are omitted from the view and never rewrite the durable store.
318    pub fn load_for_admission(
319        project_root: &str,
320        admission: &super::protection::KnowledgeAdmission,
321    ) -> Result<Option<Self>, String> {
322        let hash = hash_project_root(project_root);
323        let path = knowledge_dir(&hash)?.join("knowledge.json");
324        match std::fs::symlink_metadata(&path) {
325            Ok(metadata) if metadata.is_file() => {}
326            Ok(_) => return Err("knowledge store is not a regular file".into()),
327            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
328                // Still reject a pending legacy migration instead of silently
329                // exporting an empty view that hides the existing store.
330                let _ = Self::load_for_checked_capture(project_root)?;
331                admission.authorize_gateway()?;
332                return Ok(None);
333            }
334            Err(_) => return Err("knowledge store metadata unavailable".into()),
335        }
336        let raw = Self::load_for_checked_capture(project_root)?;
337        let view = admission.view(&raw)?.admit_sources();
338        admission.authorize_gateway()?;
339        Ok(Some(view))
340    }
341
342    /// Optional capture must not trigger legacy migration writes during a read,
343    /// or replace an unreadable existing store with a newly created empty one.
344    pub fn load_for_checked_capture(project_root: &str) -> Result<Self, String> {
345        let hash = hash_project_root(project_root);
346        let path = knowledge_dir(&hash)?.join("knowledge.json");
347        match std::fs::symlink_metadata(&path) {
348            Ok(_) => {
349                let mut knowledge = parse_store_cached(&path)?;
350                let canonical = |root: &str| {
351                    crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000)
352                };
353                // The recorded root is the authority. The hash is derived: a
354                // store copied in from an earlier hash of the same project
355                // (path normalization, an identity change) still names it.
356                if canonical(&knowledge.project_root) != canonical(project_root) {
357                    return Err("knowledge input belongs to a different project".into());
358                }
359                knowledge.project_hash = hash;
360                knowledge.rebuild_index();
361                return Ok(knowledge);
362            }
363            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
364            Err(_) => return Err("knowledge input metadata unavailable".into()),
365        }
366        let mut legacy = crate::core::project_hash::legacy_unnormalized_hashes(project_root);
367        legacy.push(crate::core::project_hash::hash_path_only(project_root));
368        for old_hash in legacy.into_iter().filter(|old| old != &hash) {
369            match std::fs::symlink_metadata(knowledge_dir(&old_hash)?.join("knowledge.json")) {
370                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
371                _ => return Err("knowledge requires an explicit legacy migration".into()),
372            }
373        }
374        Ok(Self::new(project_root))
375    }
376
377    pub(crate) fn save_policy_checked(
378        &self,
379        source_tool: &str,
380        original_content: &str,
381    ) -> Result<(), String> {
382        if let Some(policy) =
383            crate::core::policy::runtime::for_project(Path::new(&self.project_root))?
384        {
385            if !policy.tool_allowed("ctx_knowledge")
386                || !policy.tool_allowed(source_tool)
387                || crate::core::policy::content::evaluate_text(original_content, &policy).blocked
388            {
389                return Err("knowledge persistence withheld by policy".into());
390            }
391            let original =
392                serde_json::to_value(self).map_err(|_| "knowledge is not inspectable")?;
393            if !store_is_policy_safe(&original, &policy) {
394                // Preserve structural identity and old customer data. Writers
395                // must supply already-safe facts; never silently rewrite keys
396                // or republish an unsafe legacy store into a temporary file.
397                return Err("knowledge persistence requires policy-safe fields".into());
398            }
399        }
400        // The capture caller already holds this project's checked write lock.
401        self.save_unlocked()
402    }
403
404    pub fn save(&self) -> Result<(), String> {
405        let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
406        if Path::new(&self.project_root).is_dir() {
407            crate::core::policy::runtime::with_explicit_project_source_view(
408                Path::new(&self.project_root),
409                |authority| {
410                    Self::with_project_lock_checked(&self.project_root, || {
411                        admission.authorize_publication(authority)?;
412                        self.save_with_admission(&admission)
413                    })?
414                },
415            )?
416        } else {
417            if crate::core::policy::runtime::inherited_source_view().is_some() {
418                return Err("knowledge project root unavailable".into());
419            }
420            Self::with_project_lock_checked(&self.project_root, || {
421                self.save_with_admission(&admission)
422            })?
423        }
424    }
425
426    fn save_unlocked(&self) -> Result<(), String> {
427        let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
428        self.save_with_admission(&admission)
429    }
430
431    fn save_with_admission(
432        &self,
433        admission: &super::protection::KnowledgeAdmission,
434    ) -> Result<(), String> {
435        let candidate = if self.withheld.is_empty() {
436            self.clone()
437        } else {
438            let existing = Self::load_for_checked_capture(&self.project_root)?;
439            self.complete_for_storage(&existing)?
440        };
441        candidate.save_complete_with_admission(admission)
442    }
443
444    fn save_complete_with_admission(
445        &self,
446        admission: &super::protection::KnowledgeAdmission,
447    ) -> Result<(), String> {
448        if self.project_hash != hash_project_root(&self.project_root) {
449            return Err("knowledge storage identity mismatch".into());
450        }
451        let existing = Self::load_for_checked_capture(&self.project_root)?;
452        admission.require_safe(&existing)?;
453        admission.require_safe(self)?;
454
455        let dir = knowledge_dir(&self.project_hash)?;
456        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
457        let _ = crate::core::secure_fs::restrict_to_owner(&dir);
458
459        let path = dir.join("knowledge.json");
460        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
461        admission.authorize_gateway()?;
462        write_json_atomic(&path, &json)?;
463        // Every save comes through here under the project lock, so the
464        // journal sees each write path in save order (`journal`).
465        super::journal::append(
466            &dir,
467            &super::journal::diff(&existing.facts, &self.facts),
468            &Utc::now().to_rfc3339(),
469        );
470        Ok(())
471    }
472
473    /// Runs `f` while holding this project's locks — the in-process per-hash
474    /// mutex *and* the cross-process advisory file lock — without loading or
475    /// saving the knowledge JSON itself. [`mutate_locked`](Self::mutate_locked)
476    /// is built on this, and side-car stores that must stay consistent with the
477    /// facts (today: the embedding index) call it directly so their
478    /// read-modify-write is serialized against parallel
479    /// `remember`/`remove`/`reindex`. That side-car write used to run lock-free,
480    /// so concurrent callers clobbered each other's embeddings and pruned
481    /// just-stored vectors, degrading semantic recall (issue #412, a #326
482    /// follow-up).
483    #[cfg(feature = "embeddings")]
484    pub fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
485        let hash = hash_project_root(project_root);
486        let lock = knowledge_lock(&hash);
487        let _guard = lock
488            .lock()
489            .unwrap_or_else(std::sync::PoisonError::into_inner);
490
491        // Cross-process lock: create the dir up front so the lock file has a
492        // home, then block until any other process releases it. Held for the
493        // whole critical section via `_file_lock`'s lifetime.
494        let _file_lock = match knowledge_dir(&hash) {
495            Ok(dir) => {
496                let _ = std::fs::create_dir_all(&dir);
497                acquire_file_lock(&dir)
498            }
499            Err(_) => None,
500        };
501
502        f()
503    }
504
505    /// Runs a read-modify-write cycle under `with_project_lock`, then saves
506    /// atomically. The knowledge is (re)loaded *inside* the lock so
507    /// the closure always operates on the latest on-disk state; this is what
508    /// prevents lost updates when several `remember` calls run in parallel —
509    /// whether as threads in one process (parallel MCP calls) or as separate
510    /// processes (parallel CLI invocations, CLI + daemon + MCP server) — see
511    /// issue #326. Returns the persisted knowledge plus the closure's return
512    /// value so the caller can build a response from the committed state.
513    pub(crate) fn with_project_lock_checked<T>(
514        project_root: &str,
515        f: impl FnOnce() -> T,
516    ) -> Result<T, String> {
517        use fs2::FileExt;
518        let hash = hash_project_root(project_root);
519        let dir = knowledge_dir(&hash)?;
520        // Parallel writers of this process queue in arrival order and wait
521        // only while the queue stands still; the polled locks below then
522        // bound just the embedding path and other processes. Polled alone,
523        // every writer ahead counted against a waiter's deadline (#2214).
524        let _turn =
525            crate::core::file_lock::take_turn(&dir.join(".knowledge.lock"), KNOWLEDGE_LOCK_WAIT)
526                .ok_or("knowledge lock unavailable")?;
527        let lock = knowledge_lock(&hash);
528        let deadline = std::time::Instant::now() + KNOWLEDGE_LOCK_WAIT;
529        let _guard = loop {
530            match lock.try_lock() {
531                Ok(guard) => break guard,
532                Err(std::sync::TryLockError::WouldBlock)
533                    if std::time::Instant::now() < deadline =>
534                {
535                    std::thread::sleep(std::time::Duration::from_millis(5));
536                }
537                Err(_) => return Err("knowledge lock unavailable".into()),
538            }
539        };
540        std::fs::create_dir_all(&dir).map_err(|_| "knowledge directory unavailable")?;
541        let file = crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
542            .map_err(|_| "knowledge file lock unavailable")?;
543        loop {
544            match file.try_lock_exclusive() {
545                Ok(()) => break,
546                Err(e)
547                    if crate::core::file_lock::is_contended(&e)
548                        && std::time::Instant::now() < deadline =>
549                {
550                    std::thread::sleep(std::time::Duration::from_millis(5));
551                }
552                Err(_) => return Err("knowledge file lock unavailable".into()),
553            }
554        }
555        let output = f();
556        let _ = FileExt::unlock(&file);
557        Ok(output)
558    }
559
560    pub fn mutate_locked<T>(
561        project_root: &str,
562        f: impl FnOnce(&mut Self) -> T,
563    ) -> Result<(Self, T), String> {
564        let admission = super::protection::KnowledgeAdmission::capture(project_root)?;
565        Self::mutate_locked_with_admission(project_root, &admission, f)
566    }
567
568    pub fn mutate_locked_with_admission<T>(
569        project_root: &str,
570        admission: &super::protection::KnowledgeAdmission,
571        f: impl FnOnce(&mut Self) -> T,
572    ) -> Result<(Self, T), String> {
573        crate::core::providers::provenance::with_reuse_deadline(|| {
574            Self::mutate_locked_in_scope(project_root, admission, f)
575        })
576    }
577
578    fn mutate_locked_in_scope<T>(
579        project_root: &str,
580        admission: &super::protection::KnowledgeAdmission,
581        f: impl FnOnce(&mut Self) -> T,
582    ) -> Result<(Self, T), String> {
583        if Path::new(project_root).is_dir() {
584            crate::core::policy::runtime::with_explicit_project_source_view(
585                Path::new(project_root),
586                |authority| Self::mutate_locked_scoped(project_root, admission, Some(authority), f),
587            )?
588        } else {
589            if crate::core::policy::runtime::inherited_source_view().is_some() {
590                return Err("knowledge project root unavailable".into());
591            }
592            if let Some(request) = crate::core::policy::diagnostics::request_project()
593                && crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
594                    != crate::core::pathutil::safe_canonicalize_bounded(
595                        Path::new(project_root),
596                        2000,
597                    )
598            {
599                return Err("knowledge project does not match protected request".into());
600            }
601            Self::mutate_locked_scoped(project_root, admission, None, f)
602        }
603    }
604
605    fn mutate_locked_scoped<T>(
606        project_root: &str,
607        admission: &super::protection::KnowledgeAdmission,
608        authority: Option<&crate::core::policy::runtime::PublicationAuthority>,
609        f: impl FnOnce(&mut Self) -> T,
610    ) -> Result<(Self, T), String> {
611        Self::with_project_lock_checked(project_root, || {
612            // An identity change moved this project's hash: its store under
613            // the earlier hash (proven by the recorded root) is copied over
614            // first, so a write never forks the project's knowledge.
615            let current = knowledge_dir(&hash_project_root(project_root))?.join("knowledge.json");
616            if !current.exists()
617                && let Some(old) =
618                    crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
619            {
620                crate::core::legacy_adoption::copy_knowledge_partition(&old, project_root)?;
621            }
622            let raw = Self::load_for_checked_capture(project_root)?;
623            admission.require_safe(&raw)?;
624            let mut knowledge = raw.admit_sources();
625            let out = f(&mut knowledge);
626            admission.require_safe(&knowledge)?;
627            admission.authorize_gateway()?;
628            if let Some(authority) = authority {
629                authority.verify()?;
630            }
631            knowledge.save_with_admission(admission)?;
632            Ok((knowledge.admit_sources(), out))
633        })?
634    }
635
636    pub fn load(project_root: &str) -> Option<Self> {
637        let admission = super::protection::KnowledgeAdmission::capture(project_root).ok()?;
638        let legacy_source_safe = |legacy_hash: &str| {
639            let Ok(path) = knowledge_dir(legacy_hash) else {
640                return false;
641            };
642            let path = path.join("knowledge.json");
643            match std::fs::symlink_metadata(&path) {
644                Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
645                Err(_) => false,
646                Ok(_) => {
647                    let Some(content) =
648                        crate::core::policy::diagnostics::Target::read_store_file(&path)
649                    else {
650                        return false;
651                    };
652                    let Ok(legacy) = serde_json::from_str::<Self>(&content) else {
653                        return false;
654                    };
655                    crate::core::pathutil::safe_canonicalize_bounded(
656                        Path::new(&legacy.project_root),
657                        2000,
658                    ) == crate::core::pathutil::safe_canonicalize_bounded(
659                        Path::new(project_root),
660                        2000,
661                    ) && admission.require_safe(&legacy).is_ok()
662                }
663            }
664        };
665        match super::protection::current(project_root) {
666            Ok(Some(_)) => {
667                let path = knowledge_dir(&hash_project_root(project_root))
668                    .ok()?
669                    .join("knowledge.json");
670                std::fs::symlink_metadata(path).ok()?;
671                let raw = Self::load_for_checked_capture(project_root).ok()?;
672                return admission.view(&raw).ok().map(Self::admit_sources);
673            }
674            Err(_) => return None,
675            Ok(None) => {}
676        }
677        let hash = hash_project_root(project_root);
678        let dir = knowledge_dir(&hash).ok()?;
679        let path = dir.join("knowledge.json");
680
681        if let Ok(mut k) = parse_store_cached(&path) {
682            // The recorded root is the authority; the hash is derived
683            // and is re-keyed for a store copied from an earlier hash.
684            if crate::core::pathutil::safe_canonicalize_bounded(Path::new(&k.project_root), 2000)
685                != crate::core::pathutil::safe_canonicalize_bounded(Path::new(project_root), 2000)
686            {
687                return None;
688            }
689            k.project_hash = hash;
690            k.rebuild_index();
691            return admission.view(&k).ok().map(Self::admit_sources);
692        }
693
694        let old_hash = crate::core::project_hash::hash_path_only(project_root);
695        if old_hash != hash {
696            if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
697                return None;
698            }
699            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
700            if let Ok(content) = std::fs::read_to_string(&path)
701                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
702            {
703                k.project_hash = hash;
704                k.rebuild_index();
705                if admission.require_safe(&k).is_err()
706                    || admission.authorize_gateway().is_err()
707                    || k.save_with_admission(&admission).is_err()
708                {
709                    return None;
710                }
711                return admission.view(&k).ok().map(Self::admit_sources);
712            }
713        }
714
715        // Migrate stores created before path normalization (issue #325): on
716        // Windows the CLI keyed its store by a backslash path, splitting it from
717        // the forward-slash MCP store. Pull any such legacy store into the
718        // canonical (normalized) location so facts converge.
719        for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
720            if legacy_hash == hash {
721                continue;
722            }
723            if !legacy_source_safe(&legacy_hash) || admission.authorize_gateway().is_err() {
724                return None;
725            }
726            crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
727            if let Ok(content) = std::fs::read_to_string(&path)
728                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
729            {
730                k.project_hash = hash;
731                k.rebuild_index();
732                if admission.require_safe(&k).is_err()
733                    || admission.authorize_gateway().is_err()
734                    || k.save_with_admission(&admission).is_err()
735                {
736                    return None;
737                }
738                return admission.view(&k).ok().map(Self::admit_sources);
739            }
740        }
741
742        // The project's identity (git remote, package name, `.lean-ctx-id`)
743        // changed, so its hash moved: a store whose recorded root is this
744        // path is the project's own. Its files are copied (never moved) into
745        // the current partition and pass the same guards as above.
746        if let Some(old_hash) =
747            crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
748        {
749            if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
750                return None;
751            }
752            crate::core::legacy_adoption::copy_knowledge_partition(&old_hash, project_root).ok()?;
753            if let Ok(content) = std::fs::read_to_string(&path)
754                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
755                && crate::core::pathutil::safe_canonicalize_bounded(
756                    Path::new(&k.project_root),
757                    2000,
758                ) == crate::core::pathutil::safe_canonicalize_bounded(
759                    Path::new(project_root),
760                    2000,
761                )
762            {
763                k.project_hash = hash;
764                k.rebuild_index();
765                if admission.require_safe(&k).is_err()
766                    || admission.authorize_gateway().is_err()
767                    || k.save_with_admission(&admission).is_err()
768                {
769                    return None;
770                }
771                return admission.view(&k).ok().map(Self::admit_sources);
772            }
773        }
774
775        None
776    }
777
778    pub fn load_or_create(project_root: &str) -> Self {
779        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
780    }
781
782    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
783    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
784    pub fn migrate_legacy_empty_root(
785        target_root: &str,
786        policy: &MemoryPolicy,
787    ) -> Result<bool, String> {
788        if target_root.trim().is_empty() {
789            return Ok(false);
790        }
791
792        if super::protection::current(target_root)?.is_some() {
793            return Err("unscoped legacy knowledge requires explicit protected migration".into());
794        }
795        // This explicit compatibility migration may read the old empty-root
796        // store, but general load/save must never treat an empty root as authority.
797        let mut legacy_store = None;
798        for hash in [
799            hash_project_root(""),
800            crate::core::project_hash::hash_path_only(""),
801        ] {
802            let path = knowledge_dir(&hash)?.join("knowledge.json");
803            match std::fs::symlink_metadata(&path) {
804                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
805                Err(_) => return Err("legacy knowledge input unavailable".into()),
806                Ok(_) => {}
807            }
808            let text = crate::core::policy::diagnostics::Target::read_store_file(&path)
809                .ok_or("legacy knowledge input is uninspectable")?;
810            let legacy: Self =
811                serde_json::from_str(&text).map_err(|_| "invalid legacy knowledge")?;
812            legacy_store = Some((legacy, path));
813            break;
814        }
815        let Some((legacy, legacy_path)) = legacy_store else {
816            return Ok(false);
817        };
818
819        if !legacy.project_root.trim().is_empty() {
820            return Ok(false);
821        }
822        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
823            return Ok(false);
824        }
825
826        Self::with_project_lock_checked(target_root, || {
827            if super::protection::current(target_root)?.is_some() {
828                return Err("legacy migration withheld by changed project policy".into());
829            }
830            let mut target = Self::load_or_create(target_root);
831
832            fn fact_key(f: &KnowledgeFact) -> String {
833                format!(
834                    "{}|{}|{}|{}|{}",
835                    f.category, f.key, f.value, f.source_session, f.created_at
836                )
837            }
838            fn pattern_key(p: &ProjectPattern) -> String {
839                format!(
840                    "{}|{}|{}|{}",
841                    p.pattern_type, p.description, p.source_session, p.created_at
842                )
843            }
844            fn history_key(h: &ConsolidatedInsight) -> String {
845                format!(
846                    "{}|{}|{}",
847                    h.summary,
848                    h.from_sessions.join(","),
849                    h.timestamp
850                )
851            }
852
853            let mut seen_facts: std::collections::HashSet<String> =
854                target.facts.iter().map(fact_key).collect();
855            for f in legacy.facts {
856                if seen_facts.insert(fact_key(&f)) {
857                    target.facts.push(f);
858                }
859            }
860
861            let mut seen_patterns: std::collections::HashSet<String> =
862                target.patterns.iter().map(pattern_key).collect();
863            for p in legacy.patterns {
864                if seen_patterns.insert(pattern_key(&p)) {
865                    target.patterns.push(p);
866                }
867            }
868
869            let mut seen_history: std::collections::HashSet<String> =
870                target.history.iter().map(history_key).collect();
871            for h in legacy.history {
872                if seen_history.insert(history_key(&h)) {
873                    target.history.push(h);
874                }
875            }
876
877            target.facts.sort_by(|a, b| {
878                b.created_at
879                    .cmp(&a.created_at)
880                    .then_with(|| b.confidence.total_cmp(&a.confidence))
881            });
882            target
883                .patterns
884                .sort_by_key(|x| std::cmp::Reverse(x.created_at));
885            if target.patterns.len() > policy.knowledge.max_patterns {
886                target.patterns.truncate(policy.knowledge.max_patterns);
887            }
888            target
889                .history
890                .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
891            if target.history.len() > policy.knowledge.max_history {
892                target.history.truncate(policy.knowledge.max_history);
893            }
894
895            target.updated_at = Utc::now();
896            if super::protection::current(target_root)?.is_some() {
897                return Err("legacy migration withheld by changed project policy".into());
898            }
899            target.save_unlocked()?;
900
901            let legacy_dir = legacy_path.parent().ok_or("legacy directory unavailable")?;
902            if legacy_path.exists() {
903                let ts = Utc::now().format("%Y%m%d-%H%M%S");
904                let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
905                std::fs::hard_link(&legacy_path, &backup)
906                    .map_err(|_| "legacy backup could not be preserved")?;
907                std::fs::remove_file(&legacy_path)
908                    .map_err(|_| "legacy backup retained but source cleanup failed")?;
909            }
910
911            Ok(true)
912        })?
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    #[cfg(feature = "embeddings")]
920    use fs2::FileExt;
921
922    // --- #1802: `auto_capture = false` must hold at the store, not the callers ---
923
924    fn machine_fact(category: &str, key: &str) -> KnowledgeFact {
925        let now = chrono::Utc::now();
926        KnowledgeFact {
927            anchors: Vec::new(),
928            review: None,
929            capture: Default::default(),
930            origin: crate::core::knowledge::FactOrigin::Local,
931            category: category.to_string(),
932            key: key.to_string(),
933            value: "Finding: run-guards.ts: Read run-guards.ts (92L)".to_string(),
934            source_session: "session-1".to_string(),
935            confidence: 0.8,
936            created_at: now,
937            last_confirmed: now,
938            retrieval_count: 0,
939            last_retrieved: None,
940            valid_from: None,
941            valid_until: None,
942            supersedes: None,
943            confirmation_count: 1,
944            feedback_up: 0,
945            feedback_down: 0,
946            last_feedback: None,
947            privacy: Default::default(),
948            sensitivity: Default::default(),
949            imported_from: None,
950            archetype: Default::default(),
951            fidelity: None,
952            revision_count: 0,
953            status: None,
954            validity: None,
955        }
956    }
957
958    #[test]
959    fn machine_derived_is_recognised_in_either_ingestion_shape() {
960        // `extract_session_facts` marks the category; `auto_capture` marks the
961        // key. Both producers must be recognisable by one predicate.
962        assert!(is_machine_derived("auto:blocker", "run-guards.ts"));
963        assert!(is_machine_derived("blocker", "auto:config.rs"));
964        assert!(!is_machine_derived("konventionen", "naming"));
965        assert!(!is_machine_derived("ops", "deploy"));
966    }
967
968    #[test]
969    fn a_curated_fact_is_never_affected_by_the_gate() {
970        // The key disables *automatic* capture. A fact a person wrote must be
971        // storable whatever the flag says, or the feature would be a footgun.
972        // Isolated: the store boundary reads the data dir and the active
973        // policy, which concurrent tests change.
974        let _data = crate::core::data_dir::isolated_data_dir();
975        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
976        let knowledge_root = tempfile::tempdir().unwrap();
977        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
978        assert!(
979            knowledge.add_fact(machine_fact("konventionen", "naming")),
980            "curated facts must always be accepted"
981        );
982    }
983
984    #[test]
985    fn disabled_capture_blocks_every_machine_derived_shape() {
986        // The regression: a single MCP call re-materialised deleted `auto:*`
987        // facts because only one of two producers consulted the flag. Asserting
988        // at the store covers both, and any producer added later.
989        let _env = crate::core::data_dir::test_env_lock();
990        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
991
992        let knowledge_root = tempfile::tempdir().unwrap();
993
994        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
995        let category_marked = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
996        let key_marked = knowledge.add_fact(machine_fact("blocker", "auto:config.rs"));
997
998        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
999
1000        assert!(!category_marked, "auto:* category must be refused");
1001        assert!(!key_marked, "auto:* key must be refused");
1002        assert!(
1003            knowledge.facts.is_empty(),
1004            "nothing machine-derived may reach the durable store"
1005        );
1006    }
1007
1008    #[test]
1009    fn enabled_capture_still_stores_machine_derived_facts() {
1010        let _env = crate::core::data_dir::test_env_lock();
1011        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1012
1013        let knowledge_root = tempfile::tempdir().unwrap();
1014
1015        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1016        let stored = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1017
1018        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1019
1020        assert!(stored, "the default behaviour must be unchanged");
1021    }
1022
1023    #[test]
1024    fn disabled_capture_does_not_refresh_existing_machine_facts() {
1025        // Facts from an earlier enabled run stay on disk — the fix stops new
1026        // writes, it does not delete history. But a disabled run must not keep
1027        // touching them either, or they would look perpetually fresh to the
1028        // lifecycle and never age out.
1029        let _env = crate::core::data_dir::test_env_lock();
1030        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1031        let knowledge_root = tempfile::tempdir().unwrap();
1032        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1033        knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1034        let confirmed_while_enabled = knowledge.facts[0].last_confirmed;
1035
1036        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
1037        let refreshed = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1038        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1039
1040        assert!(!refreshed);
1041        assert_eq!(knowledge.facts.len(), 1);
1042        assert_eq!(
1043            knowledge.facts[0].last_confirmed, confirmed_while_enabled,
1044            "a refused write must not refresh the existing fact"
1045        );
1046    }
1047
1048    #[cfg(feature = "embeddings")]
1049    #[test]
1050    fn file_lock_is_exclusive_across_handles() {
1051        // flock-style locks are tied to the open file description, so two
1052        // independent `open()`s in the same process behave like two separate
1053        // processes: while the first holds the exclusive lock, the second must
1054        // fail to acquire it. This validates the cross-process guarantee that
1055        // protects parallel CLI writes (issue #326).
1056        let dir = tempfile::tempdir().unwrap();
1057        let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
1058
1059        let second = std::fs::OpenOptions::new()
1060            .create(true)
1061            .truncate(false)
1062            .write(true)
1063            .open(dir.path().join(".knowledge.lock"))
1064            .unwrap();
1065        assert!(
1066            second.try_lock_exclusive().is_err(),
1067            "a second handle must not acquire the lock while it is held"
1068        );
1069
1070        drop(held);
1071        // `close()` releases the flock synchronously, so the lock IS free here.
1072        // Under heavy parallel test load, however, a single non-blocking
1073        // `try_lock_exclusive()` can momentarily observe `EWOULDBLOCK` from
1074        // scheduling jitter. A short bounded retry removes that flake without
1075        // weakening the guarantee: the lock must become acquirable again.
1076        let mut reacquired = false;
1077        for _ in 0..50 {
1078            if second.try_lock_exclusive().is_ok() {
1079                reacquired = true;
1080                break;
1081            }
1082            std::thread::sleep(std::time::Duration::from_millis(10));
1083        }
1084        assert!(
1085            reacquired,
1086            "lock must be acquirable within 500ms of release"
1087        );
1088    }
1089
1090    #[test]
1091    fn write_json_atomic_leaves_valid_file_and_no_temp() {
1092        let dir = tempfile::tempdir().unwrap();
1093        let path = dir.path().join("knowledge.json");
1094        write_json_atomic(&path, "{\"ok\":true}").unwrap();
1095        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
1096        let leftover = std::fs::read_dir(dir.path())
1097            .unwrap()
1098            .filter_map(Result::ok)
1099            .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
1100        assert!(!leftover, "no temp file should remain");
1101    }
1102
1103    #[test]
1104    fn load_rebuilds_ephemeral_index_without_changing_json() {
1105        let _isolated = crate::core::data_dir::isolated_data_dir();
1106        let project = tempfile::tempdir().unwrap();
1107        let root = project.path().to_str().unwrap();
1108        let policy = MemoryPolicy::default();
1109        let mut knowledge = ProjectKnowledge::new(root);
1110        knowledge.remember(
1111            "architecture",
1112            "database",
1113            "PostgreSQL",
1114            "test",
1115            0.9,
1116            &policy,
1117        );
1118        knowledge.save().unwrap();
1119
1120        let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
1121        assert!(loaded.index.token_positions.contains_key("postgresql"));
1122        assert_eq!(loaded.recall("postgresql").len(), 1);
1123
1124        let json = serde_json::to_string(&loaded).unwrap();
1125        assert!(!json.contains("\"index\""));
1126    }
1127}