Skip to main content

lean_ctx/core/import/
mod.rs

1//! Import facts from local AI coding-session histories, and from the
2//! project's own instruction files, documentation and git history (K1).
3
4mod bounded;
5pub mod claude_code;
6pub mod codex;
7pub mod cursor;
8pub(crate) mod database;
9pub mod git_history;
10pub mod markdown;
11pub mod opencode;
12mod staging;
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use chrono::Utc;
18use serde_json::Value;
19
20use crate::core::knowledge::KnowledgeFact;
21use staging::{Gateway, Origin, Rules, joined_leaves, mask_strings};
22
23const MAX_FACT_VALUE_CHARS: usize = 500;
24const MAX_ERRORS: usize = 100;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ImportSource {
28    ClaudeCode,
29    Codex,
30    Cursor,
31    OpenCode,
32    /// The project's instruction files and `docs/` ([`markdown`]).
33    Docs,
34    /// The project's local git history ([`git_history`]).
35    Git,
36}
37
38impl ImportSource {
39    #[must_use]
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::ClaudeCode => "claude-code",
43            Self::Codex => "codex",
44            Self::Cursor => "cursor",
45            Self::OpenCode => "opencode",
46            Self::Docs => "docs",
47            Self::Git => "git",
48        }
49    }
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct ImportResult {
54    pub sessions_found: usize,
55    pub facts_extracted: usize,
56    pub files_touched: usize,
57    pub errors: Vec<String>,
58    /// Facts remain available to the CLI until it either persists or displays them.
59    pub facts: Vec<KnowledgeFact>,
60    /// Facts that were found but not kept: admission withholds their value
61    /// or permits it for a reader only (E3). A count — the values are gone.
62    pub facts_skipped: usize,
63    /// History sources that were found but not read to the end: above the
64    /// total cap of their kind, holding one record above the per-record
65    /// bound, or not readable. Never read in part.
66    pub sources_skipped: usize,
67    /// History sources that were not opened or not used: outside every
68    /// named root, withheld by a path rule, the policy or the gateway, or
69    /// changed between their verification and the read.
70    pub sources_refused: usize,
71    /// What a repository-source run read (`import docs`): facts of a file
72    /// it read that were not found again lost their section.
73    pub repo_scope: Option<RepoScope>,
74}
75
76/// The files a docs import read, relative to the project root, and whether
77/// it covered the whole default set (then a file that is gone lost all its
78/// facts).
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct RepoScope {
81    pub files: Vec<String>,
82    pub whole: bool,
83}
84
85/// Why a history source (a transcript file, a history database) was not
86/// read. Typed and content-free: no path, no byte of the source.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SourceRefusal {
89    /// It does not resolve inside any named root (symlinks resolved).
90    OutsideRoot,
91    /// A path rule, the project policy or the gateway withholds it, or it
92    /// is delivered for a reader only and may not feed a store (E3).
93    Withheld,
94    /// It is larger than the total cap of its kind
95    /// (`bounded::MAX_TRANSCRIPT_BYTES`, `database::MAX_HISTORY_DATABASE_BYTES`;
96    /// the read cap for a single JSON document, which is one record). A
97    /// source is never read in part.
98    TooLarge,
99    /// One record of a transcript is larger than the per-record bound (the
100    /// read cap). A record is never cut and never passed uninspected, so the
101    /// transcript is not imported.
102    RecordTooLarge,
103    /// A transcript above the read cap cannot be admitted piece by piece
104    /// without being judged more weakly than as a whole: a rule that is
105    /// active now can match across lines without a bound (a policy-pack or
106    /// configured pattern not proven single-line, a semantic detector), or
107    /// a piece ends inside such a match of a built-in rule. It is not read —
108    /// as it was not before transcripts above the cap were read at all.
109    UnitAdmissionUnsupported,
110    /// One transcript or database yields more distinct facts than may be held
111    /// pending until it is read to its end (`staging::MAX_PENDING_BYTES`). It
112    /// is not imported in part.
113    TooManyFacts,
114    /// It is no longer the file that was verified, or it was written while
115    /// it was read: exchanged at its path, or changed in size or time.
116    Changed,
117    /// It cannot be read as text (missing, binary, I/O error).
118    Unreadable,
119    /// A verified database that the engine cannot open, or whose rows
120    /// cannot be stepped or decoded to their end. It is not imported in part.
121    DatabaseUnreadable,
122    /// A database without the tables and columns its importer reads.
123    UnsupportedSchema,
124}
125// The fixed sentence of each refusal (`Display`) lives in `staging`.
126
127/// A history source that resolves inside a named root: both paths canonical.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub(crate) struct HistorySource {
130    pub root: PathBuf,
131    pub path: PathBuf,
132}
133
134/// Resolve a history source inside a NAMED root — the one rule for every
135/// importer (Claude Code, Codex, Cursor, OpenCode; transcripts and
136/// databases alike).
137///
138/// The named roots are the request's project, when one is bound, and
139/// `history_root`: the directory the importer was started for. In product
140/// code that is a fixed location under the home or platform data directory
141/// (`~/.claude/projects`, `~/.codex`, `~/.cursor/projects`,
142/// `<data dir>/opencode`), never caller input. No authority is derived from
143/// `path` itself — not its directory, not a project found above it. `path`
144/// is accepted only if, with every symlink resolved, it lies inside one of
145/// the named roots; anything else is [`SourceRefusal::OutsideRoot`] and is
146/// not opened.
147///
148/// This is root containment and nothing more. It does not decide whether
149/// the path may be read (the path jail and secret-path rule do: the rooted
150/// admitted reader for a transcript, [`admit_history_database`] for a
151/// database) and it says nothing about content (the reader's admission of
152/// the file, [`AdmittedValue::admit`] for a database value).
153pub(crate) fn resolve_history_source(
154    history_root: &Path,
155    path: &Path,
156) -> Result<HistorySource, SourceRefusal> {
157    let request = crate::core::admitted_sources::request_root();
158    let named = [PathBuf::from(request), history_root.to_path_buf()];
159    let path = path.canonicalize().map_err(|_| SourceRefusal::Unreadable)?;
160    named
161        .iter()
162        .filter(|root| !root.as_os_str().is_empty())
163        .filter_map(|root| root.canonicalize().ok())
164        .find(|root| path.starts_with(root))
165        .map(|root| HistorySource { root, path })
166        .ok_or(SourceRefusal::OutsideRoot)
167}
168
169/// The path-and-policy decision for a history DATABASE, made before any
170/// `Connection::open*`: the same decision a file read gets before its file
171/// is opened (`core::io_boundary::jail_and_check_path`, the call the rooted
172/// admitted reader makes first), under the named root the path resolved in.
173///
174/// It decides on the PATH: the path jail (containment under the root or an
175/// allow-listed path), the protected-store rule, and the secret-like-path
176/// rule of the active role (refused in boundary mode `enforce`). It cannot
177/// classify content it has not read — the values that leave the database
178/// are admitted one by one ([`AdmittedValue::admit`]). Root containment
179/// alone ([`resolve_history_source`]) is NOT this decision.
180///
181/// A decision on a path does not bind the file that is opened later. The
182/// importers therefore never open the path this returns: they go through
183/// [`database::snapshot_history_database`], which makes this decision, opens
184/// the file once, checks it on its descriptor and reads from that descriptor.
185pub(crate) fn admit_history_database(
186    history_root: &Path,
187    path: &Path,
188) -> Result<HistorySource, SourceRefusal> {
189    let source = resolve_history_source(history_root, path)?;
190    crate::core::io_boundary::jail_and_check_path("ctx_knowledge", &source.path, &source.root)
191        .map_err(|_| SourceRefusal::Withheld)?;
192    Ok(source)
193}
194
195/// Why an original value yields nothing. Content-free.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum ValueRefusal {
198    /// The project policy or the gateway withholds the value.
199    Withheld,
200    /// It is delivered for a reader only (restricted, E3): nothing derived
201    /// from it may be stored — a clean-looking part of it included.
202    NotStorable,
203}
204
205/// An ORIGINAL value of a history source after admission — the verdict that
206/// travels with every derivation (G01: admission before selection).
207///
208/// Selecting a snippet can cut a classification marker or a banner away, so
209/// the decision is made on the whole original and inherited by whatever is
210/// taken from it. This type is that inheritance: the fact builder
211/// ([`process_admitted`]) and the selectors in front of it take an
212/// `AdmittedValue` and nothing else, and one exists only
213///
214/// - for a database column value that [`Self::admit`] admitted as a whole:
215///   withheld or not storable → no `AdmittedValue`, so nothing is derived
216///   and nothing stored; masked → the value held here IS the masked one;
217/// - for a value parsed from the text of a transcript the rooted admitted
218///   reader delivered as storable ([`Self::of_admitted_source`]);
219/// - for a value derived from one of these ([`Self::derive`]).
220///
221/// The admission of the derived fact in `push_fact` stays as a second
222/// check. It can only refuse; it never upgrades this verdict.
223#[derive(Debug, Clone)]
224pub(crate) struct AdmittedValue(Value);
225
226impl AdmittedValue {
227    /// Admit one original column value as a whole.
228    ///
229    /// The admitted unit is the VALUE: every decoded text leaf of it — each
230    /// object key, each string, each number in its JSON spelling — in
231    /// document order, one per line, as ONE text through the admission a
232    /// carrier text gets (project policy on the original, then the gateway;
233    /// classification, storability). A marker lies inside one leaf, and a
234    /// leaf is never divided: leaves are only joined, each starting its own
235    /// line, so a marker cannot be split across units and a marker in any
236    /// leaf decides for the whole value.
237    ///
238    /// When admission masks something, the strings are masked one by one in
239    /// place (a key or a number cannot be masked: that refuses the value),
240    /// and the masked value must then pass as a whole unchanged — otherwise
241    /// it is refused. Derivation never sees the raw original.
242    pub(crate) fn admit(original: Value, rules: Rules<'_>) -> Result<Self, ValueRefusal> {
243        let joined = joined_leaves(&original);
244        if joined.is_empty() {
245            return Ok(Self(original));
246        }
247        if admitted_text(&joined, rules)? == joined {
248            return Ok(Self(original));
249        }
250        let mut masked = original;
251        mask_strings(&mut masked, rules)?;
252        let rejoined = joined_leaves(&masked);
253        if admitted_text(&rejoined, rules)? != rejoined {
254            return Err(ValueRefusal::Withheld);
255        }
256        Ok(Self(masked))
257    }
258
259    /// A value parsed from text the rooted admitted reader delivered as
260    /// storable: the whole file was admitted before it was parsed, and
261    /// `read_transcript` hands out nothing else.
262    fn of_admitted_source(value: Value) -> Self {
263        Self(value)
264    }
265
266    /// A value selected or rebuilt from this one. It inherits the verdict:
267    /// the receiver is the witness that an admitted original exists.
268    #[allow(clippy::unused_self)]
269    pub(crate) fn derive(&self, value: Value) -> Self {
270        Self(value)
271    }
272
273    pub(crate) fn value(&self) -> &Value {
274        &self.0
275    }
276}
277
278/// The admitted form of `text` when it may feed a store, under `rules`.
279fn admitted_text(text: &str, rules: Rules<'_>) -> Result<String, ValueRefusal> {
280    let admission = crate::core::context_admission::carrier::admit_carrier_text_under(text, rules)
281        .map_err(|_| ValueRefusal::Withheld)?;
282    if admission.storable {
283        Ok(admission.text)
284    } else {
285        Err(ValueRefusal::NotStorable)
286    }
287}
288
289/// Test-only: admit one original value under the rules configured now. A
290/// database reader counts into its pending set (`staging::Pending::admit`).
291#[cfg(test)]
292pub(crate) fn admit_column(result: &mut ImportResult, original: Value) -> Option<AdmittedValue> {
293    match AdmittedValue::admit(original, None) {
294        Ok(value) => Some(value),
295        Err(ValueRefusal::Withheld | ValueRefusal::NotStorable) => {
296            result.facts_skipped += 1;
297            None
298        }
299    }
300}
301
302impl ImportResult {
303    pub fn merge(&mut self, mut other: Self) {
304        self.sessions_found += other.sessions_found;
305        self.facts_extracted += other.facts_extracted;
306        self.files_touched += other.files_touched;
307        self.facts_skipped += other.facts_skipped;
308        self.sources_skipped += other.sources_skipped;
309        self.sources_refused += other.sources_refused;
310        self.errors.append(&mut other.errors);
311        self.facts.append(&mut other.facts);
312    }
313
314    /// Count a source that was not read. Nothing about it is reported
315    /// beyond the count — no path, no reason text with content.
316    pub(crate) fn refuse(&mut self, refusal: SourceRefusal) {
317        match refusal {
318            SourceRefusal::OutsideRoot | SourceRefusal::Withheld | SourceRefusal::Changed => {
319                self.sources_refused += 1;
320            }
321            SourceRefusal::TooLarge
322            | SourceRefusal::RecordTooLarge
323            | SourceRefusal::UnitAdmissionUnsupported
324            | SourceRefusal::TooManyFacts
325            | SourceRefusal::DatabaseUnreadable
326            | SourceRefusal::UnsupportedSchema
327            | SourceRefusal::Unreadable => self.sources_skipped += 1,
328        }
329    }
330}
331
332pub(crate) fn home_dir() -> Option<PathBuf> {
333    std::env::var_os("HOME").map(PathBuf::from)
334}
335
336/// lean-ctx's own data directory: where an import keeps its private scratch
337/// files (the spool of a transcript read in units, the snapshot of a history
338/// database). Never a directory of the history that is being read.
339fn scratch_parent() -> std::io::Result<PathBuf> {
340    let directory = crate::core::data_dir::lean_ctx_data_dir().map_err(std::io::Error::other)?;
341    std::fs::create_dir_all(&directory)?;
342    Ok(directory)
343}
344
345/// One transcript, at most the read cap, as text that may feed a derived
346/// store.
347///
348/// The file is resolved inside a named root ([`resolve_history_source`]) and
349/// then read through the rooted admitted reader under that root — path jail,
350/// secret-path rule, project policy and gateway decided on the original. A
351/// transcript above the read cap is [`SourceRefusal::TooLarge`] here and
352/// never read in part ([`for_each_transcript_line`] reads it in admitted
353/// units instead); one that is delivered but restricted or incompletely
354/// decoded feeds no store (E3). The gateway rules are those of `gateway`, the
355/// ONE snapshot of this source.
356fn read_transcript(root: &Path, path: &Path, gateway: &Gateway) -> Result<String, SourceRefusal> {
357    use crate::core::admitted_sources::{self, SourceRead};
358    let source = resolve_history_source(root, path)?;
359    let size = std::fs::metadata(&source.path)
360        .map_err(|_| SourceRefusal::Unreadable)?
361        .len();
362    if size > crate::core::limits::max_read_bytes() as u64 {
363        return Err(SourceRefusal::TooLarge);
364    }
365    match admitted_sources::read_with_admission(
366        &source.root.to_string_lossy(),
367        &source.path.to_string_lossy(),
368        "ctx_knowledge",
369        gateway,
370    ) {
371        read @ SourceRead::Admitted(_) => read
372            .storable()
373            .map(str::to_owned)
374            .ok_or(SourceRefusal::Withheld),
375        SourceRead::Withheld(_) => Err(SourceRefusal::Withheld),
376        SourceRead::Unavailable(_) => Err(SourceRefusal::Unreadable),
377    }
378}
379
380/// Hand every line of one JSONL transcript — with its 1-based number — to
381/// `each`, after admission and never before.
382///
383/// Up to the read cap the file is admitted as ONE text ([`read_transcript`]).
384/// Above it, it is admitted in units of complete records ([`bounded::admit`]):
385/// every unit passes the same admission on its original bytes, and `each`
386/// sees the first line only after the last unit was admitted. A refusal is a
387/// refusal of the whole source: on `Err` the caller takes over nothing of it
388/// (above the cap `each` may have seen lines before a late error).
389///
390/// Above the cap the whole read runs inside ONE source view ([`pinned`]).
391fn for_each_transcript_line(
392    root: &Path,
393    path: &Path,
394    gateway: &Gateway,
395    mut each: impl FnMut(usize, &str),
396) -> Result<(), SourceRefusal> {
397    match read_transcript(root, path, gateway) {
398        Ok(content) => {
399            for (index, line) in content.lines().enumerate() {
400                each(index + 1, line);
401            }
402            Ok(())
403        }
404        Err(SourceRefusal::TooLarge) => {
405            let source = resolve_history_source(root, path)?;
406            let mut line_number = 0;
407            pinned(&source, || {
408                bounded::admit(&source, gateway)?.for_each_unit(|unit| {
409                    for line in unit.lines() {
410                        line_number += 1;
411                        each(line_number, line);
412                    }
413                })
414            })
415        }
416        Err(refusal) => Err(refusal),
417    }
418}
419
420/// Run the read of one source in admitted units under ONE policy snapshot.
421///
422/// `policy::runtime::with_project_source_view` pins the pack and the role
423/// for the whole operation — every `policy::runtime::active()` inside it,
424/// the unit-safety inspection as much as each admission, is that one
425/// snapshot — and compares the pack fingerprint and the role fingerprint
426/// again when the operation ends. The gateway rules are one snapshot held by
427/// the unit reader and compared when the last unit was read. A change of
428/// either, or a policy that cannot be resolved, withholds the source; `Ok`
429/// is the point immediately before its pending set is taken over.
430fn pinned<T>(
431    source: &HistorySource,
432    read: impl FnOnce() -> Result<T, SourceRefusal>,
433) -> Result<T, SourceRefusal> {
434    crate::core::policy::runtime::with_project_source_view(&source.root.to_string_lossy(), read)
435        .unwrap_or(Err(SourceRefusal::Withheld))
436}
437
438pub(crate) fn read_jsonl_files(
439    source: ImportSource,
440    root: &Path,
441    files: Vec<PathBuf>,
442) -> ImportResult {
443    let mut result = ImportResult {
444        sessions_found: files.len(),
445        ..ImportResult::default()
446    };
447    let mut touched = HashSet::new();
448    let mut seen = HashSet::new();
449
450    for path in files {
451        // All or nothing per source, under ONE gateway snapshot (label, read,
452        // built facts): the lines fill a pending set; result, dedupe sets and
453        // counters are touched only when the source was read to its end.
454        let gateway = crate::core::context_admission::carrier::gateway_snapshot();
455        let mut pending = staging::Pending::under(gateway.clone());
456        let session = session_id(source, &path, pending.rules());
457        let read = for_each_transcript_line(root, &path, &gateway, |line_number, line| {
458            if line.trim().is_empty() {
459                return;
460            }
461            match serde_json::from_str::<Value>(line) {
462                Ok(value) => staging::select(&AdmittedValue::of_admitted_source(value), |found| {
463                    pending.stage(found);
464                }),
465                Err(error) => pending.error(format!(
466                    "{}:{line_number}: invalid JSONL: {error}",
467                    path.display()
468                )),
469            }
470        });
471        let _ = pending.settle(read, source, &session, &mut result, &mut touched, &mut seen);
472    }
473
474    result.files_touched = touched.len();
475    result.facts_extracted = result.facts.len();
476    result
477}
478
479pub(crate) fn read_json_file(source: ImportSource, root: &Path, path: &Path) -> ImportResult {
480    let mut result = ImportResult {
481        sessions_found: 1,
482        ..ImportResult::default()
483    };
484    let mut touched = HashSet::new();
485    let mut seen = HashSet::new();
486    let gateway = crate::core::context_admission::carrier::gateway_snapshot();
487    let rules = Some(gateway.admission_policy());
488    let session = session_id(source, path, rules);
489    match read_transcript(root, path, &gateway) {
490        Err(refusal) => result.refuse(refusal),
491        Ok(content) => match serde_json::from_str::<Value>(&content) {
492            Ok(value) => process_admitted(
493                Origin::new(source, &session, rules),
494                &AdmittedValue::of_admitted_source(value),
495                &mut result,
496                &mut touched,
497                &mut seen,
498            ),
499            Err(error) => push_error(
500                &mut result,
501                format!("{}: invalid JSON: {error}", path.display()),
502            ),
503        },
504    }
505
506    result.files_touched = touched.len();
507    result.facts_extracted = result.facts.len();
508    result
509}
510
511/// Test-only entry with the former signature: admits `value` as an original
512/// and then builds facts from it. Product code has only
513/// [`process_admitted`].
514#[cfg(test)]
515pub(crate) fn process_value(
516    source: ImportSource,
517    session: &str,
518    value: &Value,
519    result: &mut ImportResult,
520    touched: &mut HashSet<String>,
521    seen: &mut HashSet<(String, String)>,
522) {
523    if let Some(admitted) = admit_column(result, value.clone()) {
524        let origin = Origin::new(source, session, None);
525        process_admitted(origin, &admitted, result, touched, seen);
526    }
527}
528
529/// Build facts from an admitted value: select file paths, decisions and
530/// errors from it. Selection happens here, AFTER the admission of the
531/// original — the parameter type is the proof.
532fn process_admitted(
533    origin: Origin<'_>,
534    value: &AdmittedValue,
535    result: &mut ImportResult,
536    touched: &mut HashSet<String>,
537    seen: &mut HashSet<(String, String)>,
538) {
539    // Found and taken over at once: one parsed JSON document has no failure
540    // point after its read. Transcripts and databases go through `Pending`.
541    staging::select(value, |found| {
542        staging::take_over(found, origin, result, touched, seen);
543    });
544}
545
546pub(crate) fn finish_result(result: &mut ImportResult, touched: &HashSet<String>) {
547    result.files_touched = touched.len();
548    result.facts_extracted = result.facts.len();
549}
550
551pub(crate) fn push_error(result: &mut ImportResult, error: String) {
552    if result.errors.len() < MAX_ERRORS {
553        result.errors.push(error);
554    }
555}
556
557pub(crate) fn session_id(source: ImportSource, path: &Path, rules: Rules<'_>) -> String {
558    let name = path
559        .file_name()
560        .and_then(|name| name.to_str())
561        .unwrap_or("unknown-session");
562    session_label(source, name, rules)
563}
564
565/// The `source_session` label stored with every fact of one session:
566/// `<importer>:<name>`. `name` is a file name or a database's session id —
567/// text that lean-ctx did not choose and that is persisted with the fact. It
568/// is kept only if it passes admission for a store unchanged (E3) under
569/// `rules`, the snapshot of its source; otherwise `<importer>:unnamed-session`.
570pub(crate) fn session_label(source: ImportSource, name: &str, rules: Rules<'_>) -> String {
571    let kept = crate::core::context_admission::carrier::admit_for_store_under(name, rules)
572        .is_ok_and(|admitted| admitted == name);
573    let name = if kept { name } else { "unnamed-session" };
574    format!("{}:{name}", source.as_str())
575}
576
577fn push_fact(
578    result: &mut ImportResult,
579    seen: &mut HashSet<(String, String)>,
580    Origin {
581        source,
582        session,
583        rules,
584    }: Origin<'_>,
585    category: &str,
586    value: &str,
587    confidence: f32,
588) {
589    let value = truncate(value);
590    if value.is_empty() || !seen.insert((category.to_owned(), value.clone())) {
591        return;
592    }
593    // Every fact is built here, whatever it was mined from (a transcript
594    // line, a database row), so this is where it is admitted for the store
595    // it is headed to (E3): a value the gateway masks is kept masked, a
596    // withheld or restricted one is not kept at all — it is counted, and
597    // nothing of it is reported. The fact carries no source binding. The
598    // rules are those of its source's ONE snapshot (`origin.rules`).
599    let admitted = crate::core::context_admission::carrier::admit_for_store_under(&value, rules);
600    let Ok(value) = admitted else {
601        result.facts_skipped += 1;
602        return;
603    };
604    if value.is_empty() {
605        return;
606    }
607
608    let now = Utc::now();
609    result.facts.push(KnowledgeFact {
610        anchors: Vec::new(),
611        review: None,
612        capture: Default::default(),
613        origin: crate::core::knowledge::FactOrigin::Unverified,
614        category: category.to_owned(),
615        key: String::new(),
616        value,
617        source_session: session.to_owned(),
618        confidence,
619        created_at: now,
620        last_confirmed: now,
621        retrieval_count: 0,
622        last_retrieved: None,
623        valid_from: None,
624        valid_until: None,
625        supersedes: None,
626        confirmation_count: 0,
627        feedback_up: 0,
628        feedback_down: 0,
629        last_feedback: None,
630        privacy: Default::default(),
631        sensitivity: Default::default(),
632        imported_from: Some(source.as_str().to_owned()),
633        archetype: Default::default(),
634        fidelity: None,
635        revision_count: 0,
636        status: None,
637        validity: None,
638    });
639}
640
641/// One fact a repository source ([`markdown`], [`git_history`]) yields, before
642/// it is admitted for its store.
643pub(crate) struct RepoFact {
644    pub category: &'static str,
645    /// Where it comes from inside the project: `<file>#<section>/<n>` or
646    /// `git:<commit>`. Content-addressed anchors only, never a value.
647    pub key: String,
648    pub value: String,
649    pub confidence: f32,
650    pub validity: Option<crate::core::knowledge::FactValidity>,
651    pub valid_from: Option<chrono::DateTime<Utc>>,
652    /// The section (or commit) it is anchored to, when one could be captured.
653    pub anchors: Vec<crate::core::knowledge::anchors::CodeAnchor>,
654}
655
656/// Build the fact of a repository source: admitted for its store (E3) under
657/// `rules`, the one snapshot of the source, exactly as [`push_fact`] does for
658/// a history — masked stays masked, withheld or restricted is counted in
659/// `facts_skipped` and dropped. Deduplicated on `(category, value)`.
660pub(crate) fn push_repo_fact(
661    result: &mut ImportResult,
662    seen: &mut HashSet<(String, String)>,
663    source: ImportSource,
664    session: &str,
665    rules: Rules<'_>,
666    fact: RepoFact,
667) {
668    let value = truncate(&fact.value);
669    if value.is_empty() || !seen.insert((fact.category.to_owned(), value.clone())) {
670        return;
671    }
672    let Ok(value) = crate::core::context_admission::carrier::admit_for_store_under(&value, rules)
673    else {
674        result.facts_skipped += 1;
675        return;
676    };
677    if value.is_empty() {
678        return;
679    }
680    let now = Utc::now();
681    result.facts.push(KnowledgeFact {
682        origin: crate::core::knowledge::FactOrigin::Unverified,
683        category: fact.category.to_owned(),
684        key: fact.key,
685        value,
686        source_session: session.to_owned(),
687        confidence: fact.confidence,
688        created_at: now,
689        last_confirmed: now,
690        retrieval_count: 0,
691        last_retrieved: None,
692        valid_from: fact.valid_from,
693        valid_until: None,
694        supersedes: None,
695        confirmation_count: 0,
696        feedback_up: 0,
697        feedback_down: 0,
698        last_feedback: None,
699        privacy: Default::default(),
700        sensitivity: Default::default(),
701        imported_from: Some(source.as_str().to_owned()),
702        archetype: crate::core::knowledge::KnowledgeArchetype::infer_from_category(fact.category),
703        fidelity: None,
704        revision_count: 0,
705        status: None,
706        validity: fact.validity,
707        anchors: fact.anchors,
708        review: None,
709        capture: Default::default(),
710    });
711}
712
713fn collect_file_paths(value: &Value, out: &mut Vec<String>) {
714    match value {
715        Value::Array(values) => {
716            for value in values {
717                collect_file_paths(value, out);
718            }
719        }
720        Value::Object(map) => {
721            for (key, value) in map {
722                if matches!(
723                    key.as_str(),
724                    "file_path" | "filePath" | "path" | "filename" | "file_name"
725                ) {
726                    if let Some(path) = value
727                        .as_str()
728                        .map(str::trim)
729                        .filter(|path| !path.is_empty())
730                    {
731                        out.push(path.to_owned());
732                    }
733                }
734                collect_file_paths(value, out);
735            }
736        }
737        _ => {}
738    }
739}
740
741fn collect_strings(value: &Value, out: &mut Vec<String>) {
742    match value {
743        Value::String(text) => out.push(text.to_owned()),
744        Value::Array(values) => {
745            for value in values {
746                collect_strings(value, out);
747            }
748        }
749        Value::Object(map) => {
750            for value in map.values() {
751                collect_strings(value, out);
752            }
753        }
754        _ => {}
755    }
756}
757
758fn contains_assistant_message(value: &Value) -> bool {
759    match value {
760        Value::Array(values) => values.iter().any(contains_assistant_message),
761        Value::Object(map) => {
762            map.get("role").and_then(Value::as_str) == Some("assistant")
763                || matches!(
764                    map.get("type").and_then(Value::as_str),
765                    Some("assistant" | "assistant_message")
766                )
767                || map.values().any(contains_assistant_message)
768        }
769        _ => false,
770    }
771}
772
773fn matching_snippets(text: &str, predicate: fn(&str) -> bool) -> Vec<String> {
774    text.split(['\n', '.', '!', '?'])
775        .map(str::trim)
776        .filter(|snippet| snippet.len() >= 4 && predicate(snippet))
777        .map(str::to_owned)
778        .collect()
779}
780
781fn is_decision(text: &str) -> bool {
782    let text = text.to_ascii_lowercase();
783    [
784        "decision",
785        "decided",
786        "we chose",
787        "i chose",
788        "chosen",
789        "will use",
790        "we'll use",
791        "we will use",
792        "going with",
793        "approach is",
794    ]
795    .iter()
796    .any(|marker| text.contains(marker))
797}
798
799fn is_error(text: &str) -> bool {
800    let text = text.to_ascii_lowercase();
801    [
802        "error",
803        "failed",
804        "failure",
805        "panic",
806        "unable to",
807        "cannot ",
808        "can't ",
809    ]
810    .iter()
811    .any(|marker| text.contains(marker))
812}
813
814fn truncate(value: &str) -> String {
815    // The first char boundary *at or past* the cap can land one byte over it:
816    // with 2-byte characters starting at an odd offset the boundaries are
817    // 11, 13, … 499, 501, so a 500-byte cap yielded a 501-byte slice. Take the
818    // last boundary at or below the cap instead — the same idiom already used
819    // in `ctx_search` and `ctx_preload`.
820    value[..value.floor_char_boundary(MAX_FACT_VALUE_CHARS)]
821        .trim()
822        .to_owned()
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn truncates_multibyte_facts_without_panicking() {
831        let value = format!("We decided {}", "é".repeat(300));
832        let truncated = truncate(&value);
833        assert!(truncated.len() <= MAX_FACT_VALUE_CHARS);
834        assert!(truncated.is_char_boundary(truncated.len()));
835    }
836
837    #[test]
838    fn extracts_touches_decisions_and_errors() {
839        let value: Value = serde_json::json!({
840            "role": "assistant",
841            "content": "We decided to use a cache. Build failed because input is invalid.",
842            "tool_input": { "file_path": "rust/src/lib.rs" }
843        });
844        let mut result = ImportResult::default();
845        let mut touched = HashSet::new();
846        let mut seen = HashSet::new();
847
848        process_value(
849            ImportSource::Codex,
850            "codex:test",
851            &value,
852            &mut result,
853            &mut touched,
854            &mut seen,
855        );
856        finish_result(&mut result, &touched);
857
858        assert_eq!(result.files_touched, 1);
859        assert!(
860            result
861                .facts
862                .iter()
863                .any(|fact| fact.category == "imported-decision")
864        );
865        assert!(
866            result
867                .facts
868                .iter()
869                .any(|fact| fact.value.starts_with("Observed error:"))
870        );
871    }
872
873    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
874
875    /// A history root with one Claude-Code-shaped session directory, as
876    /// canonical paths (the rooted reader compares resolved paths).
877    fn history() -> (tempfile::TempDir, PathBuf, PathBuf) {
878        let dir = tempfile::tempdir().expect("history dir");
879        let root = dir.path().canonicalize().expect("history root");
880        let sessions = root.join("project-a");
881        std::fs::create_dir_all(&sessions).expect("session dir");
882        (dir, root, sessions)
883    }
884
885    fn assistant_line(text: &str) -> String {
886        serde_json::json!({ "message": { "role": "assistant", "content": text } }).to_string()
887    }
888
889    fn decisions(result: &ImportResult) -> Vec<&str> {
890        result
891            .facts
892            .iter()
893            .filter(|fact| fact.category == "imported-decision")
894            .map(|fact| fact.value.as_str())
895            .collect()
896    }
897
898    /// E3, for every caller: facts were built from transcript text as it
899    /// was and only the CLI admitted them before storing. Each value is now
900    /// admitted where the fact is built — a clean value keeps its bytes, a
901    /// key-shaped value is never handed out raw.
902    #[test]
903    fn a_transcript_is_read_admitted_and_its_facts_are_admitted_where_they_are_built() {
904        let _data = crate::core::data_dir::isolated_data_dir();
905        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
906        let (_dir, root, sessions) = history();
907        std::fs::write(
908            sessions.join("session.jsonl"),
909            format!(
910                "{}\n{}\n",
911                assistant_line("We decided to use JWT RS256 for the session tokens"),
912                assistant_line(&format!(
913                    "We decided to deploy with the key {AWS_KEY} today"
914                )),
915            ),
916        )
917        .expect("transcript");
918
919        let result = claude_code::import_from_root(&root);
920        assert!(result.errors.is_empty(), "{:?}", result.errors);
921        assert_eq!(result.sessions_found, 1);
922        let decisions = decisions(&result);
923        assert!(
924            decisions.contains(&"We decided to use JWT RS256 for the session tokens"),
925            "a clean fact keeps its bytes: {decisions:?}"
926        );
927        assert!(
928            result
929                .facts
930                .iter()
931                .all(|fact| !fact.value.contains(AWS_KEY)),
932            "stored masked, or skipped — never raw"
933        );
934        // Two decisions were found: each is a fact or a counted skip.
935        assert_eq!(decisions.len() + result.facts_skipped, 2);
936        assert_eq!(result.facts_extracted, result.facts.len());
937    }
938
939    /// A value the current policy withholds or restricts is not kept: it is
940    /// counted, and neither the facts nor the errors carry anything of it.
941    #[test]
942    fn a_restricted_value_is_skipped_and_counted() {
943        let _data = crate::core::data_dir::isolated_data_dir();
944        let policy = crate::core::policy::load(
945            "name = \"import-admission-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
946             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
947        )
948        .expect("policy");
949        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
950        let mut result = ImportResult::default();
951        let mut seen = HashSet::new();
952
953        for value in [
954            "We decided to use a cache",
955            "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
956            // The same withheld value again: one distinct value, one count.
957            "CONFIDENTIAL\nWe decided to ship roadmap-marker in May",
958        ] {
959            push_fact(
960                &mut result,
961                &mut seen,
962                Origin::new(ImportSource::Codex, "codex:test", None),
963                "imported-decision",
964                value,
965                0.65,
966            );
967        }
968
969        assert_eq!(decisions(&result), vec!["We decided to use a cache"]);
970        assert_eq!(result.facts_skipped, 1);
971        assert!(result.errors.is_empty(), "nothing is reported about it");
972
973        let mut merged = ImportResult::default();
974        merged.merge(result);
975        assert_eq!(merged.facts_skipped, 1, "the count survives a merge");
976    }
977
978    /// The root rule: a transcript that resolves outside the named history
979    /// root is not read — no root is derived from where it really lives.
980    #[cfg(unix)]
981    #[test]
982    fn a_transcript_outside_every_root_is_not_read() {
983        let _data = crate::core::data_dir::isolated_data_dir();
984        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
985        let (_dir, root, sessions) = history();
986        let elsewhere = tempfile::tempdir().expect("outside dir");
987        let outside = elsewhere
988            .path()
989            .canonicalize()
990            .expect("outside root")
991            .join("outside.jsonl");
992        std::fs::write(
993            &outside,
994            format!(
995                "{}\n",
996                assistant_line("We decided to keep outside-marker secret")
997            ),
998        )
999        .expect("outside transcript");
1000        std::os::unix::fs::symlink(&outside, sessions.join("linked.jsonl")).expect("symlink");
1001
1002        let result = claude_code::import_from_root(&root);
1003        assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1004        assert_eq!(result.facts_skipped, 0, "not read, so nothing was judged");
1005        assert_eq!(result.sources_refused, 1, "counted as refused");
1006        assert!(
1007            result.errors.is_empty(),
1008            "no path, no text: {:?}",
1009            result.errors
1010        );
1011
1012        // The one rule every importer uses, decided on resolved paths, with
1013        // a typed, content-free reason.
1014        let inside = sessions.join("inside.jsonl");
1015        std::fs::write(&inside, "{}\n").expect("inside transcript");
1016        assert_eq!(
1017            resolve_history_source(&root, &inside),
1018            Ok(HistorySource {
1019                root: root.clone(),
1020                path: inside,
1021            })
1022        );
1023        assert_eq!(
1024            resolve_history_source(&root, &outside),
1025            Err(SourceRefusal::OutsideRoot)
1026        );
1027        assert_eq!(
1028            resolve_history_source(&root, &sessions.join("linked.jsonl")),
1029            Err(SourceRefusal::OutsideRoot),
1030            "a link inside the root that points outside"
1031        );
1032        assert_eq!(
1033            resolve_history_source(&root, &root.join("missing.db")),
1034            Err(SourceRefusal::Unreadable)
1035        );
1036        assert!(
1037            !SourceRefusal::OutsideRoot
1038                .to_string()
1039                .contains("outside.jsonl")
1040        );
1041    }
1042
1043    /// The named history roots stay usable: a hidden directory under the
1044    /// home directory is a root the rooted admitted reader accepts, and the
1045    /// `.jsonl` transcripts inside it are read. (Fixtures shaped like
1046    /// `~/.claude/projects` and `~/.codex`.)
1047    #[test]
1048    fn history_files_inside_their_named_roots_are_read() {
1049        let _data = crate::core::data_dir::isolated_data_dir();
1050        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1051        let home = tempfile::tempdir().expect("home");
1052        let home = home.path().canonicalize().expect("home root");
1053
1054        let claude_root = home.join(".claude").join("projects");
1055        let claude_sessions = claude_root.join("-Users-dev-project");
1056        std::fs::create_dir_all(&claude_sessions).expect("claude dir");
1057        std::fs::write(
1058            claude_sessions.join("0b1c.jsonl"),
1059            format!(
1060                "{}\n",
1061                assistant_line("We decided to keep the claude marker")
1062            ),
1063        )
1064        .expect("claude transcript");
1065        let claude = claude_code::import_from_root(&claude_root);
1066        assert_eq!((claude.sources_refused, claude.sources_skipped), (0, 0));
1067        assert!(claude.errors.is_empty(), "{:?}", claude.errors);
1068        assert_eq!(
1069            decisions(&claude),
1070            vec!["We decided to keep the claude marker"]
1071        );
1072
1073        let codex_root = home.join(".codex");
1074        let codex_sessions = codex_root.join("sessions").join("2026");
1075        std::fs::create_dir_all(&codex_sessions).expect("codex dir");
1076        std::fs::write(
1077            codex_sessions.join("rollout-1.jsonl"),
1078            format!(
1079                "{}\n",
1080                assistant_line("We decided to keep the codex marker")
1081            ),
1082        )
1083        .expect("codex transcript");
1084        let codex = codex::import_from_root(&codex_root);
1085        assert_eq!((codex.sources_refused, codex.sources_skipped), (0, 0));
1086        assert!(codex.errors.is_empty(), "{:?}", codex.errors);
1087        assert_eq!(
1088            decisions(&codex),
1089            vec!["We decided to keep the codex marker"]
1090        );
1091    }
1092
1093    /// A transcript above the read cap whose second record is itself above
1094    /// the per-record bound is skipped whole and counted — it is never read
1095    /// in part and treated as complete, and the record is never cut: the
1096    /// typed reason is the oversized record, not the size of the file.
1097    #[test]
1098    fn a_transcript_above_the_read_cap_is_skipped_and_counted() {
1099        let _data = crate::core::data_dir::isolated_data_dir();
1100        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1101        let (_dir, root, sessions) = history();
1102        let first = assistant_line("We decided to keep the early marker");
1103        let filler = assistant_line(&"x".repeat(4096));
1104        std::fs::write(sessions.join("large.jsonl"), format!("{first}\n{filler}\n"))
1105            .expect("large transcript");
1106
1107        crate::test_env::set_var("LCTX_MAX_READ_BYTES", "1024");
1108        let result = claude_code::import_from_root(&root);
1109        let rules = crate::core::context_admission::carrier::gateway_snapshot();
1110        let reason = resolve_history_source(&root, &sessions.join("large.jsonl"))
1111            .and_then(|source| pinned(&source, || bounded::admit(&source, &rules).map(|_| ())));
1112        crate::test_env::remove_var("LCTX_MAX_READ_BYTES");
1113
1114        assert_eq!(reason, Err(SourceRefusal::RecordTooLarge));
1115        assert_eq!(result.sources_skipped, 1);
1116        assert_eq!(result.sources_refused, 0);
1117        assert!(result.facts.is_empty(), "not even the part below the cap");
1118        assert!(result.errors.is_empty(), "{:?}", result.errors);
1119    }
1120
1121    /// A session label is persisted with every fact: a name that admission
1122    /// would change is not kept.
1123    #[test]
1124    fn a_session_label_is_kept_only_if_it_is_admitted_unchanged() {
1125        let _data = crate::core::data_dir::isolated_data_dir();
1126        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1127        assert_eq!(
1128            session_label(ImportSource::OpenCode, "ses_01HZX", None),
1129            "opencode:ses_01HZX"
1130        );
1131        let label = session_label(
1132            ImportSource::Codex,
1133            &format!("rollout {AWS_KEY}.jsonl"),
1134            None,
1135        );
1136        assert_eq!(label, "codex:unnamed-session");
1137    }
1138
1139    const BLOCK_CONFIDENTIAL: &str = "name = \"import-original-test\"\nversion = \"1.0.0\"\n\
1140         description = \"test\"\n[filters]\nclassification = \"block\"\n\
1141         blocked_labels = [\"CONFIDENTIAL\"]\n";
1142
1143    fn block_confidential() -> crate::core::policy::runtime::TestPolicyOverride {
1144        let policy = crate::core::policy::load(BLOCK_CONFIDENTIAL).expect("policy");
1145        crate::core::policy::runtime::TestPolicyOverride::set(Some(policy))
1146    }
1147
1148    /// A Codex-shaped history database with one table of TEXT rows.
1149    fn codex_database(path: &Path, rows: &[&str]) {
1150        let db = rusqlite::Connection::open(path).expect("database");
1151        db.execute_batch("CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL);")
1152            .expect("schema");
1153        for row in rows {
1154            db.execute("INSERT INTO history (body) VALUES (?1)", [row])
1155                .expect("row");
1156        }
1157    }
1158
1159    /// G01, the order: an original value is admitted as a WHOLE before
1160    /// anything is selected from it. A marker in one leaf decides for the
1161    /// value — the clean, importable sentence in another leaf is not a fact.
1162    #[test]
1163    fn a_marker_anywhere_in_an_original_value_refuses_the_whole_value() {
1164        let _data = crate::core::data_dir::isolated_data_dir();
1165        let _policy = block_confidential();
1166        let marked = serde_json::json!({
1167            "role": "assistant",
1168            "content": "We decided to keep the marked sentence",
1169            "banner": "CONFIDENTIAL",
1170        });
1171        assert!(AdmittedValue::admit(marked.clone(), None).is_err());
1172
1173        let mut result = ImportResult::default();
1174        assert!(admit_column(&mut result, marked).is_none());
1175        assert_eq!(result.facts_skipped, 1, "counted once per original value");
1176
1177        let clean = serde_json::json!({
1178            "role": "assistant",
1179            "content": "We decided to use a bounded cache",
1180        });
1181        let admitted = AdmittedValue::admit(clean.clone(), None).expect("a clean value");
1182        assert_eq!(admitted.value(), &clean, "a clean value keeps its bytes");
1183        let derived = admitted.derive(serde_json::json!({ "role": "assistant" }));
1184        assert_eq!(derived.value()["role"], "assistant");
1185    }
1186
1187    /// A value the gateway masks is derived from in its MASKED form only.
1188    #[test]
1189    fn a_masked_original_is_only_ever_seen_masked() {
1190        let _data = crate::core::data_dir::isolated_data_dir();
1191        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1192        let keyed = serde_json::json!({
1193            "role": "assistant",
1194            "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1195        });
1196        match AdmittedValue::admit(keyed, None) {
1197            Ok(admitted) => {
1198                let text = admitted.value().to_string();
1199                assert!(!text.contains(AWS_KEY), "{text}");
1200                assert!(text.contains("We decided to deploy today"), "{text}");
1201            }
1202            // Not permitted for a store at all: nothing is derived.
1203            Err(refusal) => assert!(
1204                matches!(refusal, ValueRefusal::NotStorable | ValueRefusal::Withheld),
1205                "{refusal:?}"
1206            ),
1207        }
1208    }
1209
1210    /// The same order for a Codex TEXT column: the row with a marker next to
1211    /// an importable sentence yields no fact; the clean row does.
1212    #[test]
1213    fn a_marked_codex_column_value_yields_no_fact() {
1214        let _data = crate::core::data_dir::isolated_data_dir();
1215        let (_dir, root, _sessions) = history();
1216        codex_database(
1217            &root.join("history.sqlite"),
1218            &[
1219                r#"{"role":"assistant","content":"We decided to keep the marked sentence","banner":"CONFIDENTIAL"}"#,
1220                r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#,
1221            ],
1222        );
1223
1224        let _policy = block_confidential();
1225        let result = codex::import_from_root(&root);
1226        assert_eq!(
1227            decisions(&result),
1228            vec!["We decided to use a bounded cache"]
1229        );
1230        assert_eq!(result.facts_skipped, 1);
1231        assert_eq!((result.sources_refused, result.sources_skipped), (0, 0));
1232        assert!(
1233            result
1234                .facts
1235                .iter()
1236                .all(|fact| !fact.value.contains("marked sentence"))
1237        );
1238
1239        // All or nothing per database: a second database whose SECOND row
1240        // cannot be stepped (a generated column over a body that is not
1241        // JSON) adds its one skip and one fixed sentence — nothing of its
1242        // first row, no session, no counter.
1243        let broken = rusqlite::Connection::open(root.join("broken.sqlite")).expect("database");
1244        broken
1245            .execute_batch(
1246                "CREATE TABLE history (id INTEGER PRIMARY KEY, body TEXT NOT NULL); \
1247                 INSERT INTO history (body) VALUES \
1248                 ('{\"role\":\"assistant\",\"content\":\"We decided to keep the early row\"}'), \
1249                 ('not json'); \
1250                 ALTER TABLE history ADD COLUMN kind TEXT \
1251                 GENERATED ALWAYS AS (json_extract(body, '$.role')) VIRTUAL;",
1252            )
1253            .expect("fixture");
1254        drop(broken);
1255        let again = codex::import_from_root(&root);
1256        assert_eq!(decisions(&again), decisions(&result));
1257        assert_eq!((again.sources_skipped, again.sources_refused), (1, 0));
1258        assert_eq!(
1259            (again.sessions_found, again.facts_skipped),
1260            (result.sessions_found, 1)
1261        );
1262        assert_eq!(again.errors.len(), 1, "{:?}", again.errors);
1263        assert!(again.errors[0].ends_with(": history database could not be read; skipped"));
1264    }
1265
1266    /// A key-shaped secret next to an importable sentence in a database
1267    /// column: whatever is stored comes from the masked value.
1268    #[test]
1269    fn a_keyed_codex_column_value_never_yields_the_key() {
1270        let _data = crate::core::data_dir::isolated_data_dir();
1271        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1272        let (_dir, root, _sessions) = history();
1273        let row = serde_json::json!({
1274            "role": "assistant",
1275            "content": format!("We decided to deploy today. The key is {AWS_KEY}"),
1276        })
1277        .to_string();
1278        codex_database(&root.join("history.sqlite"), &[&row]);
1279
1280        let result = codex::import_from_root(&root);
1281        assert_eq!(result.sources_refused, 0);
1282        assert!(
1283            result
1284                .facts
1285                .iter()
1286                .all(|fact| !fact.value.contains(AWS_KEY))
1287        );
1288        if result.facts_skipped == 0 {
1289            assert!(
1290                decisions(&result).contains(&"We decided to deploy today"),
1291                "the clean sentence of the masked value is imported: {:?}",
1292                decisions(&result)
1293            );
1294        }
1295    }
1296
1297    /// The same order for a transcript: the rooted admitted reader admits
1298    /// the whole file before a line is parsed, so a marker in one place
1299    /// withholds the importable sentence in another.
1300    #[test]
1301    fn a_marked_transcript_yields_no_fact() {
1302        let _data = crate::core::data_dir::isolated_data_dir();
1303        let (_dir, root, sessions) = history();
1304        std::fs::write(
1305            sessions.join("session.jsonl"),
1306            format!(
1307                "CONFIDENTIAL\n{}\n",
1308                assistant_line("We decided to keep the marked sentence")
1309            ),
1310        )
1311        .expect("transcript");
1312
1313        let _policy = block_confidential();
1314        let result = claude_code::import_from_root(&root);
1315        assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1316        assert_eq!(result.sources_refused, 1);
1317        assert_eq!(result.facts_skipped, 0, "the file was refused as a whole");
1318        assert!(result.errors.is_empty(), "{:?}", result.errors);
1319    }
1320
1321    /// The path decision before a database is opened: a secret-like path
1322    /// under boundary mode `enforce` is refused like a file read would be —
1323    /// not opened, counted.
1324    #[test]
1325    fn a_database_at_a_refused_path_is_not_opened() {
1326        let _data = crate::core::data_dir::isolated_data_dir();
1327        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1328        let (_dir, root, _sessions) = history();
1329        let secret_dir = root.join(".aws");
1330        std::fs::create_dir_all(&secret_dir).expect("secret-like dir");
1331        let database = secret_dir.join("history.sqlite");
1332        codex_database(
1333            &database,
1334            &[r#"{"role":"assistant","content":"We decided to use a bounded cache"}"#],
1335        );
1336
1337        crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
1338        let decision = admit_history_database(&root, &database);
1339        let result = codex::import_from_root(&root);
1340        crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1341
1342        assert_eq!(decision, Err(SourceRefusal::Withheld));
1343        assert_eq!(result.sources_refused, 1);
1344        assert_eq!(result.facts_skipped, 0);
1345        assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1346
1347        // In boundary mode `warn` the same path is a readable source. The
1348        // mode is named: a role's default mode is `enforce`
1349        // (`roles::default_boundary_mode`), so "no variable" is not `warn`.
1350        crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "warn");
1351        let warned = admit_history_database(&root, &database);
1352        crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE");
1353        assert!(warned.is_ok());
1354    }
1355
1356    /// A transcript above the read cap is imported: read in units of complete
1357    /// records, each unit admitted on its original bytes before a line is
1358    /// parsed. The units cover the file — every record is a fact, in order,
1359    /// multi-byte text intact — and the source rule holds across units: a
1360    /// marking in the LAST unit withholds the records of the first.
1361    ///
1362    /// A pattern that could match across lines without a bound is never
1363    /// taken for a single-line one (it would refuse unit admission).
1364    #[test]
1365    fn a_transcript_above_the_read_cap_is_imported_in_admitted_units() {
1366        let _data = crate::core::data_dir::isolated_data_dir();
1367        let policy = crate::core::policy::load(&format!("{BLOCK_CONFIDENTIAL}pii = \"block\"\n"))
1368            .expect("policy");
1369        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
1370        let _cap = crate::test_env::EnvVarGuard::set("LCTX_MAX_READ_BYTES", "1024");
1371        let (_dir, root, sessions) = history();
1372        // Digit-free records of one length: the unit boundaries are known.
1373        let expected: Vec<String> = ('a'..='c')
1374            .flat_map(|x| ('a'..='t').map(move |y| (x, y)))
1375            .map(|(x, y)| format!("We decided to keep the unit marker é {x}{y}"))
1376            .collect();
1377        let records: Vec<String> = expected.iter().map(|text| assistant_line(text)).collect();
1378        let write = |records: &[String], end: &str| {
1379            std::fs::write(sessions.join("long.jsonl"), records.join("\n") + "\n" + end)
1380                .expect("transcript");
1381        };
1382        let width = records[0].len();
1383        let per_unit = bounded::unit_bytes() / (width + 1);
1384        assert!(records.len() > 3 * per_unit, "several units");
1385
1386        write(&records, "");
1387        let result = claude_code::import_from_root(&root);
1388        assert_eq!((result.sources_skipped, result.sources_refused), (0, 0));
1389        assert!(result.errors.is_empty(), "{:?}", result.errors);
1390        assert_eq!(decisions(&result), expected);
1391
1392        write(&records, "CONFIDENTIAL\n");
1393        let result = claude_code::import_from_root(&root);
1394        assert!(result.facts.is_empty(), "{:?}", decisions(&result));
1395        assert_eq!((result.sources_skipped, result.sources_refused), (0, 1));
1396
1397        use crate::core::context_admission::cross_line::pattern_is_single_line;
1398        assert!(pattern_is_single_line(r"EMP-\d{4}") && !pattern_is_single_line(r"left\s+right"));
1399
1400        // All or nothing per source: the read fails after the first unit was
1401        // parsed. Nothing of that source is taken over — no fact, no counter
1402        // but its one skip, and no dedupe state: the decision it shares with
1403        // the NEXT source is still imported from there.
1404        write(&records, "");
1405        std::fs::write(sessions.join("next.jsonl"), format!("{}\n", records[0])).expect("next");
1406        bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(Some(1)));
1407        let result = claude_code::import_from_root(&root);
1408        bounded::FAIL_BEFORE_UNIT.with(|fault| fault.set(None));
1409        assert_eq!(decisions(&result), vec![expected[0].as_str()]);
1410        assert_eq!(
1411            (
1412                result.sources_skipped,
1413                result.sources_refused,
1414                result.facts_skipped
1415            ),
1416            (1, 0, 0)
1417        );
1418        assert_eq!((result.facts_extracted, result.errors.len()), (1, 0));
1419    }
1420
1421    /// The database is bound to the descriptor it was verified on. A file
1422    /// exchanged at the path after the verification — renamed over, or
1423    /// replaced by a link — is refused with a typed reason: nothing is read,
1424    /// neither from the exchanged file nor from the verified one.
1425    #[cfg(unix)]
1426    #[test]
1427    fn a_database_exchanged_after_verification_is_refused() {
1428        let _data = crate::core::data_dir::isolated_data_dir();
1429        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
1430        let (_dir, root, sessions) = history();
1431        let database = root.join("history.sqlite");
1432        let other = sessions.join("other.sqlite");
1433        let row = r#"{"role":"assistant","content":"We decided to keep the verified marker"}"#;
1434        codex_database(&database, &[row]);
1435        codex_database(&other, &[row]);
1436
1437        let verified = database::verify_history_database(&root, &database).expect("verified");
1438        std::fs::rename(&other, &database).expect("rename over");
1439        assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1440
1441        let verified = database::verify_history_database(&root, &database).expect("verified");
1442        std::fs::rename(&database, &other).expect("move away");
1443        std::os::unix::fs::symlink(&other, &database).expect("link in its place");
1444        assert_eq!(verified.into_snapshot().err(), Some(SourceRefusal::Changed));
1445    }
1446}