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