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        #[cfg(unix)]
458        {
459            use std::os::unix::fs::PermissionsExt;
460            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
461        }
462
463        let path = dir.join("knowledge.json");
464        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
465        admission.authorize_gateway()?;
466        write_json_atomic(&path, &json)?;
467        // Every save comes through here under the project lock, so the
468        // journal sees each write path in save order (`journal`).
469        super::journal::append(
470            &dir,
471            &super::journal::diff(&existing.facts, &self.facts),
472            &Utc::now().to_rfc3339(),
473        );
474        Ok(())
475    }
476
477    /// Runs `f` while holding this project's locks — the in-process per-hash
478    /// mutex *and* the cross-process advisory file lock — without loading or
479    /// saving the knowledge JSON itself. [`mutate_locked`](Self::mutate_locked)
480    /// is built on this, and side-car stores that must stay consistent with the
481    /// facts (today: the embedding index) call it directly so their
482    /// read-modify-write is serialized against parallel
483    /// `remember`/`remove`/`reindex`. That side-car write used to run lock-free,
484    /// so concurrent callers clobbered each other's embeddings and pruned
485    /// just-stored vectors, degrading semantic recall (issue #412, a #326
486    /// follow-up).
487    #[cfg(feature = "embeddings")]
488    pub fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
489        let hash = hash_project_root(project_root);
490        let lock = knowledge_lock(&hash);
491        let _guard = lock
492            .lock()
493            .unwrap_or_else(std::sync::PoisonError::into_inner);
494
495        // Cross-process lock: create the dir up front so the lock file has a
496        // home, then block until any other process releases it. Held for the
497        // whole critical section via `_file_lock`'s lifetime.
498        let _file_lock = match knowledge_dir(&hash) {
499            Ok(dir) => {
500                let _ = std::fs::create_dir_all(&dir);
501                acquire_file_lock(&dir)
502            }
503            Err(_) => None,
504        };
505
506        f()
507    }
508
509    /// Runs a read-modify-write cycle under `with_project_lock`, then saves
510    /// atomically. The knowledge is (re)loaded *inside* the lock so
511    /// the closure always operates on the latest on-disk state; this is what
512    /// prevents lost updates when several `remember` calls run in parallel —
513    /// whether as threads in one process (parallel MCP calls) or as separate
514    /// processes (parallel CLI invocations, CLI + daemon + MCP server) — see
515    /// issue #326. Returns the persisted knowledge plus the closure's return
516    /// value so the caller can build a response from the committed state.
517    pub(crate) fn with_project_lock_checked<T>(
518        project_root: &str,
519        f: impl FnOnce() -> T,
520    ) -> Result<T, String> {
521        use fs2::FileExt;
522        let hash = hash_project_root(project_root);
523        let lock = knowledge_lock(&hash);
524        let deadline = std::time::Instant::now() + KNOWLEDGE_LOCK_WAIT;
525        let _guard = loop {
526            match lock.try_lock() {
527                Ok(guard) => break guard,
528                Err(std::sync::TryLockError::WouldBlock)
529                    if std::time::Instant::now() < deadline =>
530                {
531                    std::thread::sleep(std::time::Duration::from_millis(5));
532                }
533                Err(_) => return Err("knowledge lock unavailable".into()),
534            }
535        };
536        let dir = knowledge_dir(&hash)?;
537        std::fs::create_dir_all(&dir).map_err(|_| "knowledge directory unavailable")?;
538        let file = crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
539            .map_err(|_| "knowledge file lock unavailable")?;
540        loop {
541            match file.try_lock_exclusive() {
542                Ok(()) => break,
543                Err(e)
544                    if crate::core::file_lock::is_contended(&e)
545                        && std::time::Instant::now() < deadline =>
546                {
547                    std::thread::sleep(std::time::Duration::from_millis(5));
548                }
549                Err(_) => return Err("knowledge file lock unavailable".into()),
550            }
551        }
552        let output = f();
553        let _ = FileExt::unlock(&file);
554        Ok(output)
555    }
556
557    pub fn mutate_locked<T>(
558        project_root: &str,
559        f: impl FnOnce(&mut Self) -> T,
560    ) -> Result<(Self, T), String> {
561        let admission = super::protection::KnowledgeAdmission::capture(project_root)?;
562        Self::mutate_locked_with_admission(project_root, &admission, f)
563    }
564
565    pub fn mutate_locked_with_admission<T>(
566        project_root: &str,
567        admission: &super::protection::KnowledgeAdmission,
568        f: impl FnOnce(&mut Self) -> T,
569    ) -> Result<(Self, T), String> {
570        crate::core::providers::provenance::with_reuse_deadline(|| {
571            Self::mutate_locked_in_scope(project_root, admission, f)
572        })
573    }
574
575    fn mutate_locked_in_scope<T>(
576        project_root: &str,
577        admission: &super::protection::KnowledgeAdmission,
578        f: impl FnOnce(&mut Self) -> T,
579    ) -> Result<(Self, T), String> {
580        if Path::new(project_root).is_dir() {
581            crate::core::policy::runtime::with_explicit_project_source_view(
582                Path::new(project_root),
583                |authority| Self::mutate_locked_scoped(project_root, admission, Some(authority), f),
584            )?
585        } else {
586            if crate::core::policy::runtime::inherited_source_view().is_some() {
587                return Err("knowledge project root unavailable".into());
588            }
589            if let Some(request) = crate::core::policy::diagnostics::request_project()
590                && crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
591                    != crate::core::pathutil::safe_canonicalize_bounded(
592                        Path::new(project_root),
593                        2000,
594                    )
595            {
596                return Err("knowledge project does not match protected request".into());
597            }
598            Self::mutate_locked_scoped(project_root, admission, None, f)
599        }
600    }
601
602    fn mutate_locked_scoped<T>(
603        project_root: &str,
604        admission: &super::protection::KnowledgeAdmission,
605        authority: Option<&crate::core::policy::runtime::PublicationAuthority>,
606        f: impl FnOnce(&mut Self) -> T,
607    ) -> Result<(Self, T), String> {
608        Self::with_project_lock_checked(project_root, || {
609            // An identity change moved this project's hash: its store under
610            // the earlier hash (proven by the recorded root) is copied over
611            // first, so a write never forks the project's knowledge.
612            let current = knowledge_dir(&hash_project_root(project_root))?.join("knowledge.json");
613            if !current.exists()
614                && let Some(old) =
615                    crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
616            {
617                crate::core::legacy_adoption::copy_knowledge_partition(&old, project_root)?;
618            }
619            let raw = Self::load_for_checked_capture(project_root)?;
620            admission.require_safe(&raw)?;
621            let mut knowledge = raw.admit_sources();
622            let out = f(&mut knowledge);
623            admission.require_safe(&knowledge)?;
624            admission.authorize_gateway()?;
625            if let Some(authority) = authority {
626                authority.verify()?;
627            }
628            knowledge.save_with_admission(admission)?;
629            Ok((knowledge.admit_sources(), out))
630        })?
631    }
632
633    pub fn load(project_root: &str) -> Option<Self> {
634        let admission = super::protection::KnowledgeAdmission::capture(project_root).ok()?;
635        let legacy_source_safe = |legacy_hash: &str| {
636            let Ok(path) = knowledge_dir(legacy_hash) else {
637                return false;
638            };
639            let path = path.join("knowledge.json");
640            match std::fs::symlink_metadata(&path) {
641                Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
642                Err(_) => false,
643                Ok(_) => {
644                    let Some(content) =
645                        crate::core::policy::diagnostics::Target::read_store_file(&path)
646                    else {
647                        return false;
648                    };
649                    let Ok(legacy) = serde_json::from_str::<Self>(&content) else {
650                        return false;
651                    };
652                    crate::core::pathutil::safe_canonicalize_bounded(
653                        Path::new(&legacy.project_root),
654                        2000,
655                    ) == crate::core::pathutil::safe_canonicalize_bounded(
656                        Path::new(project_root),
657                        2000,
658                    ) && admission.require_safe(&legacy).is_ok()
659                }
660            }
661        };
662        match super::protection::current(project_root) {
663            Ok(Some(_)) => {
664                let path = knowledge_dir(&hash_project_root(project_root))
665                    .ok()?
666                    .join("knowledge.json");
667                std::fs::symlink_metadata(path).ok()?;
668                let raw = Self::load_for_checked_capture(project_root).ok()?;
669                return admission.view(&raw).ok().map(Self::admit_sources);
670            }
671            Err(_) => return None,
672            Ok(None) => {}
673        }
674        let hash = hash_project_root(project_root);
675        let dir = knowledge_dir(&hash).ok()?;
676        let path = dir.join("knowledge.json");
677
678        if let Ok(mut k) = parse_store_cached(&path) {
679            // The recorded root is the authority; the hash is derived
680            // and is re-keyed for a store copied from an earlier hash.
681            if crate::core::pathutil::safe_canonicalize_bounded(Path::new(&k.project_root), 2000)
682                != crate::core::pathutil::safe_canonicalize_bounded(Path::new(project_root), 2000)
683            {
684                return None;
685            }
686            k.project_hash = hash;
687            k.rebuild_index();
688            return admission.view(&k).ok().map(Self::admit_sources);
689        }
690
691        let old_hash = crate::core::project_hash::hash_path_only(project_root);
692        if old_hash != hash {
693            if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
694                return None;
695            }
696            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
697            if let Ok(content) = std::fs::read_to_string(&path)
698                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
699            {
700                k.project_hash = hash;
701                k.rebuild_index();
702                if admission.require_safe(&k).is_err()
703                    || admission.authorize_gateway().is_err()
704                    || k.save_with_admission(&admission).is_err()
705                {
706                    return None;
707                }
708                return admission.view(&k).ok().map(Self::admit_sources);
709            }
710        }
711
712        // Migrate stores created before path normalization (issue #325): on
713        // Windows the CLI keyed its store by a backslash path, splitting it from
714        // the forward-slash MCP store. Pull any such legacy store into the
715        // canonical (normalized) location so facts converge.
716        for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
717            if legacy_hash == hash {
718                continue;
719            }
720            if !legacy_source_safe(&legacy_hash) || admission.authorize_gateway().is_err() {
721                return None;
722            }
723            crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
724            if let Ok(content) = std::fs::read_to_string(&path)
725                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
726            {
727                k.project_hash = hash;
728                k.rebuild_index();
729                if admission.require_safe(&k).is_err()
730                    || admission.authorize_gateway().is_err()
731                    || k.save_with_admission(&admission).is_err()
732                {
733                    return None;
734                }
735                return admission.view(&k).ok().map(Self::admit_sources);
736            }
737        }
738
739        // The project's identity (git remote, package name, `.lean-ctx-id`)
740        // changed, so its hash moved: a store whose recorded root is this
741        // path is the project's own. Its files are copied (never moved) into
742        // the current partition and pass the same guards as above.
743        if let Some(old_hash) =
744            crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
745        {
746            if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
747                return None;
748            }
749            crate::core::legacy_adoption::copy_knowledge_partition(&old_hash, project_root).ok()?;
750            if let Ok(content) = std::fs::read_to_string(&path)
751                && let Ok(mut k) = serde_json::from_str::<Self>(&content)
752                && crate::core::pathutil::safe_canonicalize_bounded(
753                    Path::new(&k.project_root),
754                    2000,
755                ) == crate::core::pathutil::safe_canonicalize_bounded(
756                    Path::new(project_root),
757                    2000,
758                )
759            {
760                k.project_hash = hash;
761                k.rebuild_index();
762                if admission.require_safe(&k).is_err()
763                    || admission.authorize_gateway().is_err()
764                    || k.save_with_admission(&admission).is_err()
765                {
766                    return None;
767                }
768                return admission.view(&k).ok().map(Self::admit_sources);
769            }
770        }
771
772        None
773    }
774
775    pub fn load_or_create(project_root: &str) -> Self {
776        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
777    }
778
779    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
780    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
781    pub fn migrate_legacy_empty_root(
782        target_root: &str,
783        policy: &MemoryPolicy,
784    ) -> Result<bool, String> {
785        if target_root.trim().is_empty() {
786            return Ok(false);
787        }
788
789        if super::protection::current(target_root)?.is_some() {
790            return Err("unscoped legacy knowledge requires explicit protected migration".into());
791        }
792        // This explicit compatibility migration may read the old empty-root
793        // store, but general load/save must never treat an empty root as authority.
794        let mut legacy_store = None;
795        for hash in [
796            hash_project_root(""),
797            crate::core::project_hash::hash_path_only(""),
798        ] {
799            let path = knowledge_dir(&hash)?.join("knowledge.json");
800            match std::fs::symlink_metadata(&path) {
801                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
802                Err(_) => return Err("legacy knowledge input unavailable".into()),
803                Ok(_) => {}
804            }
805            let text = crate::core::policy::diagnostics::Target::read_store_file(&path)
806                .ok_or("legacy knowledge input is uninspectable")?;
807            let legacy: Self =
808                serde_json::from_str(&text).map_err(|_| "invalid legacy knowledge")?;
809            legacy_store = Some((legacy, path));
810            break;
811        }
812        let Some((legacy, legacy_path)) = legacy_store else {
813            return Ok(false);
814        };
815
816        if !legacy.project_root.trim().is_empty() {
817            return Ok(false);
818        }
819        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
820            return Ok(false);
821        }
822
823        Self::with_project_lock_checked(target_root, || {
824            if super::protection::current(target_root)?.is_some() {
825                return Err("legacy migration withheld by changed project policy".into());
826            }
827            let mut target = Self::load_or_create(target_root);
828
829            fn fact_key(f: &KnowledgeFact) -> String {
830                format!(
831                    "{}|{}|{}|{}|{}",
832                    f.category, f.key, f.value, f.source_session, f.created_at
833                )
834            }
835            fn pattern_key(p: &ProjectPattern) -> String {
836                format!(
837                    "{}|{}|{}|{}",
838                    p.pattern_type, p.description, p.source_session, p.created_at
839                )
840            }
841            fn history_key(h: &ConsolidatedInsight) -> String {
842                format!(
843                    "{}|{}|{}",
844                    h.summary,
845                    h.from_sessions.join(","),
846                    h.timestamp
847                )
848            }
849
850            let mut seen_facts: std::collections::HashSet<String> =
851                target.facts.iter().map(fact_key).collect();
852            for f in legacy.facts {
853                if seen_facts.insert(fact_key(&f)) {
854                    target.facts.push(f);
855                }
856            }
857
858            let mut seen_patterns: std::collections::HashSet<String> =
859                target.patterns.iter().map(pattern_key).collect();
860            for p in legacy.patterns {
861                if seen_patterns.insert(pattern_key(&p)) {
862                    target.patterns.push(p);
863                }
864            }
865
866            let mut seen_history: std::collections::HashSet<String> =
867                target.history.iter().map(history_key).collect();
868            for h in legacy.history {
869                if seen_history.insert(history_key(&h)) {
870                    target.history.push(h);
871                }
872            }
873
874            target.facts.sort_by(|a, b| {
875                b.created_at
876                    .cmp(&a.created_at)
877                    .then_with(|| b.confidence.total_cmp(&a.confidence))
878            });
879            target
880                .patterns
881                .sort_by_key(|x| std::cmp::Reverse(x.created_at));
882            if target.patterns.len() > policy.knowledge.max_patterns {
883                target.patterns.truncate(policy.knowledge.max_patterns);
884            }
885            target
886                .history
887                .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
888            if target.history.len() > policy.knowledge.max_history {
889                target.history.truncate(policy.knowledge.max_history);
890            }
891
892            target.updated_at = Utc::now();
893            if super::protection::current(target_root)?.is_some() {
894                return Err("legacy migration withheld by changed project policy".into());
895            }
896            target.save_unlocked()?;
897
898            let legacy_dir = legacy_path.parent().ok_or("legacy directory unavailable")?;
899            if legacy_path.exists() {
900                let ts = Utc::now().format("%Y%m%d-%H%M%S");
901                let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
902                std::fs::hard_link(&legacy_path, &backup)
903                    .map_err(|_| "legacy backup could not be preserved")?;
904                std::fs::remove_file(&legacy_path)
905                    .map_err(|_| "legacy backup retained but source cleanup failed")?;
906            }
907
908            Ok(true)
909        })?
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    #[cfg(feature = "embeddings")]
917    use fs2::FileExt;
918
919    // --- #1802: `auto_capture = false` must hold at the store, not the callers ---
920
921    fn machine_fact(category: &str, key: &str) -> KnowledgeFact {
922        let now = chrono::Utc::now();
923        KnowledgeFact {
924            anchors: Vec::new(),
925            review: None,
926            capture: Default::default(),
927            origin: crate::core::knowledge::FactOrigin::Local,
928            category: category.to_string(),
929            key: key.to_string(),
930            value: "Finding: run-guards.ts: Read run-guards.ts (92L)".to_string(),
931            source_session: "session-1".to_string(),
932            confidence: 0.8,
933            created_at: now,
934            last_confirmed: now,
935            retrieval_count: 0,
936            last_retrieved: None,
937            valid_from: None,
938            valid_until: None,
939            supersedes: None,
940            confirmation_count: 1,
941            feedback_up: 0,
942            feedback_down: 0,
943            last_feedback: None,
944            privacy: Default::default(),
945            sensitivity: Default::default(),
946            imported_from: None,
947            archetype: Default::default(),
948            fidelity: None,
949            revision_count: 0,
950            status: None,
951            validity: None,
952        }
953    }
954
955    #[test]
956    fn machine_derived_is_recognised_in_either_ingestion_shape() {
957        // `extract_session_facts` marks the category; `auto_capture` marks the
958        // key. Both producers must be recognisable by one predicate.
959        assert!(is_machine_derived("auto:blocker", "run-guards.ts"));
960        assert!(is_machine_derived("blocker", "auto:config.rs"));
961        assert!(!is_machine_derived("konventionen", "naming"));
962        assert!(!is_machine_derived("ops", "deploy"));
963    }
964
965    #[test]
966    fn a_curated_fact_is_never_affected_by_the_gate() {
967        // The key disables *automatic* capture. A fact a person wrote must be
968        // storable whatever the flag says, or the feature would be a footgun.
969        // Isolated: the store boundary reads the data dir and the active
970        // policy, which concurrent tests change.
971        let _data = crate::core::data_dir::isolated_data_dir();
972        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
973        let knowledge_root = tempfile::tempdir().unwrap();
974        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
975        assert!(
976            knowledge.add_fact(machine_fact("konventionen", "naming")),
977            "curated facts must always be accepted"
978        );
979    }
980
981    #[test]
982    fn disabled_capture_blocks_every_machine_derived_shape() {
983        // The regression: a single MCP call re-materialised deleted `auto:*`
984        // facts because only one of two producers consulted the flag. Asserting
985        // at the store covers both, and any producer added later.
986        let _env = crate::core::data_dir::test_env_lock();
987        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
988
989        let knowledge_root = tempfile::tempdir().unwrap();
990
991        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
992        let category_marked = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
993        let key_marked = knowledge.add_fact(machine_fact("blocker", "auto:config.rs"));
994
995        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
996
997        assert!(!category_marked, "auto:* category must be refused");
998        assert!(!key_marked, "auto:* key must be refused");
999        assert!(
1000            knowledge.facts.is_empty(),
1001            "nothing machine-derived may reach the durable store"
1002        );
1003    }
1004
1005    #[test]
1006    fn enabled_capture_still_stores_machine_derived_facts() {
1007        let _env = crate::core::data_dir::test_env_lock();
1008        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1009
1010        let knowledge_root = tempfile::tempdir().unwrap();
1011
1012        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1013        let stored = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1014
1015        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1016
1017        assert!(stored, "the default behaviour must be unchanged");
1018    }
1019
1020    #[test]
1021    fn disabled_capture_does_not_refresh_existing_machine_facts() {
1022        // Facts from an earlier enabled run stay on disk — the fix stops new
1023        // writes, it does not delete history. But a disabled run must not keep
1024        // touching them either, or they would look perpetually fresh to the
1025        // lifecycle and never age out.
1026        let _env = crate::core::data_dir::test_env_lock();
1027        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1028        let knowledge_root = tempfile::tempdir().unwrap();
1029        let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1030        knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1031        let confirmed_while_enabled = knowledge.facts[0].last_confirmed;
1032
1033        crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
1034        let refreshed = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1035        crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1036
1037        assert!(!refreshed);
1038        assert_eq!(knowledge.facts.len(), 1);
1039        assert_eq!(
1040            knowledge.facts[0].last_confirmed, confirmed_while_enabled,
1041            "a refused write must not refresh the existing fact"
1042        );
1043    }
1044
1045    #[cfg(feature = "embeddings")]
1046    #[test]
1047    fn file_lock_is_exclusive_across_handles() {
1048        // flock-style locks are tied to the open file description, so two
1049        // independent `open()`s in the same process behave like two separate
1050        // processes: while the first holds the exclusive lock, the second must
1051        // fail to acquire it. This validates the cross-process guarantee that
1052        // protects parallel CLI writes (issue #326).
1053        let dir = tempfile::tempdir().unwrap();
1054        let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
1055
1056        let second = std::fs::OpenOptions::new()
1057            .create(true)
1058            .truncate(false)
1059            .write(true)
1060            .open(dir.path().join(".knowledge.lock"))
1061            .unwrap();
1062        assert!(
1063            second.try_lock_exclusive().is_err(),
1064            "a second handle must not acquire the lock while it is held"
1065        );
1066
1067        drop(held);
1068        // `close()` releases the flock synchronously, so the lock IS free here.
1069        // Under heavy parallel test load, however, a single non-blocking
1070        // `try_lock_exclusive()` can momentarily observe `EWOULDBLOCK` from
1071        // scheduling jitter. A short bounded retry removes that flake without
1072        // weakening the guarantee: the lock must become acquirable again.
1073        let mut reacquired = false;
1074        for _ in 0..50 {
1075            if second.try_lock_exclusive().is_ok() {
1076                reacquired = true;
1077                break;
1078            }
1079            std::thread::sleep(std::time::Duration::from_millis(10));
1080        }
1081        assert!(
1082            reacquired,
1083            "lock must be acquirable within 500ms of release"
1084        );
1085    }
1086
1087    #[test]
1088    fn write_json_atomic_leaves_valid_file_and_no_temp() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let path = dir.path().join("knowledge.json");
1091        write_json_atomic(&path, "{\"ok\":true}").unwrap();
1092        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
1093        let leftover = std::fs::read_dir(dir.path())
1094            .unwrap()
1095            .filter_map(Result::ok)
1096            .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
1097        assert!(!leftover, "no temp file should remain");
1098    }
1099
1100    #[test]
1101    fn load_rebuilds_ephemeral_index_without_changing_json() {
1102        let _isolated = crate::core::data_dir::isolated_data_dir();
1103        let project = tempfile::tempdir().unwrap();
1104        let root = project.path().to_str().unwrap();
1105        let policy = MemoryPolicy::default();
1106        let mut knowledge = ProjectKnowledge::new(root);
1107        knowledge.remember(
1108            "architecture",
1109            "database",
1110            "PostgreSQL",
1111            "test",
1112            0.9,
1113            &policy,
1114        );
1115        knowledge.save().unwrap();
1116
1117        let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
1118        assert!(loaded.index.token_positions.contains_key("postgresql"));
1119        assert_eq!(loaded.recall("postgresql").len(), 1);
1120
1121        let json = serde_json::to_string(&loaded).unwrap();
1122        assert!(!json.contains("\"index\""));
1123    }
1124}