Skip to main content

ai_session_search/
service.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed application services shared by CLI, MCP, and language bindings.
5//!
6//! Services own operation boundaries, while [`Db`] remains the storage layer.
7//! Adapters must not duplicate SQL, filtering, pagination, or lifecycle policy.
8
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fs;
11use std::num::NonZeroUsize;
12use std::sync::atomic::AtomicBool;
13use std::sync::Arc;
14
15use anyhow::{anyhow, bail, Context, Result};
16
17use crate::config::{Config, IndexRefresh, ScoringConfig};
18use crate::db::{
19    Db, MessageBatchControl, QueryCancellation, SchemaState, MIN_READABLE_SCHEMA_VERSION,
20};
21use crate::indexer::{self, AutoReindexOutcome, IndexCoordinator};
22use crate::message_search::{
23    apply_message_presentation_cancellable, attach_match_evidence_cancellable, ContextWindow,
24    DetailLevel, ExecutionOrder, FieldViewBudget, LineWindow, MatchViewBudget, MatchWindow,
25    MessageResponsePlan, MessageRetrievalPlan, MessageSearchError, MessageSearchInclude,
26    MessageSearchIncludedData, MessageSearchOrderedDigest, MessageSearchOrigins, MessageSearchPlan,
27    MessageSearchRequest, MessageSearchResponse, MessageSearchRuntimeDiagnostics,
28    MessageSearchSpecification, MessageTarget, PageInfo, ReceiptLevel, ResolvedExtent,
29    ResolvedMessagePredicates, ResolvedMessagePresentation, ResolvedMessageSearchRequest,
30    SearchSurface, ValueOrigin, DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
31};
32use crate::message_search_batches::{
33    ensure_message_search_active, MessageSearchBatch, MessageSearchBatches,
34};
35use crate::models::{
36    DiagnosticStatus, FileCrossRef, FileEditSummary, FileQuery, FileVersion, IndexStatus,
37    MessageFilters, MessageHit, SearchExplain, SearchFilters, SearchHit, SessionMeta,
38    SessionRecord,
39};
40use crate::runtime::ExecutionRuntime;
41use crate::search_scope::{EffectiveAccessScope, TrustedAccessInputs};
42
43/// RAII application root shared by native frontends and language bindings.
44///
45/// Opening an instance applies the configured SQLite contention and performance
46/// policy exactly once. Dropping it closes the owned database connection.
47pub struct SessionSearch {
48    config: Config,
49    access: EffectiveAccessScope,
50    db: Db,
51}
52
53#[cfg(test)]
54mod analysis_service_tests {
55    use super::*;
56    use crate::models::{
57        Message, MessageAuthorship, MessageKind, MessageProvenance, MessageRecordRelation,
58        Provider, Role,
59    };
60    use crate::util::minimal_record;
61
62    fn human_original_provenance() -> MessageProvenance {
63        MessageProvenance {
64            authorship: MessageAuthorship::Human,
65            record_relation: MessageRecordRelation::Original,
66            ..MessageProvenance::default()
67        }
68    }
69
70    /// Index one user message per string and return the opened app, so a selection test can state
71    /// its corpus in one line instead of rebuilding `ParsedSession` per case.
72    fn app_with_user_messages(
73        dir: &std::path::Path,
74        config: Config,
75        texts: &[&str],
76    ) -> SessionSearch {
77        let mut config = config;
78        config.index.db_path = Some(dir.join("index.db").to_string_lossy().into_owned());
79        config.index.cache_dir = Some(dir.join("cache").to_string_lossy().into_owned());
80        let app = SessionSearch::open(config).unwrap();
81        let mut parsed = minimal_record(
82            Provider::Claude,
83            std::path::Path::new("/fixture/selection.jsonl"),
84            String::new(),
85        );
86        parsed.session.id = "claude:selection".into();
87        parsed.session.provider_session_id = "selection".into();
88        parsed.messages = texts
89            .iter()
90            .enumerate()
91            .map(|(seq, text)| Message {
92                seq: seq as i64,
93                role: Role::User,
94                ts: None,
95                tool_name: None,
96                kind: MessageKind::Conversation,
97                tool_call_id: None,
98                is_compaction: false,
99                content: (*text).to_string(),
100                provenance: human_original_provenance(),
101            })
102            .collect();
103        app.database().upsert_session(&parsed, 0, 0).unwrap();
104        app
105    }
106
107    /// Write a minimal standard-shaped skill package with one classification category.
108    fn write_skill(search_root: &std::path::Path, name: &str, category: &str, pattern: &str) {
109        let root = search_root.join(name);
110        std::fs::create_dir_all(&root).unwrap();
111        std::fs::write(
112            root.join("SKILL.md"),
113            format!(
114                "---\nname: {name}\ndescription: test skill\nmetadata:\n  version: 2.1.0\n---\n\nbody\n"
115            ),
116        )
117        .unwrap();
118        std::fs::write(
119            root.join("aise-capability.toml"),
120            format!(
121                "schema_version = 1\nkind = \"message-classification\"\n\n\
122                 [[categories]]\nname = \"{category}\"\npatterns = [\'\'\'{pattern}\'\'\']\n"
123            ),
124        )
125        .unwrap();
126    }
127
128    fn classify_messages(
129        analysis: &AnalysisService<'_>,
130        skill: crate::skill_catalog::SkillSelector,
131        filters: MessageFilters,
132    ) -> crate::corrections::MessageClassificationReport {
133        let report = analysis
134            .run_skill(&crate::skill_run::SkillRunQuery {
135                skill,
136                definition: None,
137                input: crate::skill_run::SkillCapabilityInput::MessageClassification(
138                    crate::skill_run::MessageClassificationQuery {
139                        filters,
140                        additional_skills: Vec::new(),
141                    },
142                ),
143            })
144            .unwrap();
145        let crate::skill_run::SkillCapabilityOutput::MessageClassification(output) = report.output;
146        output.report
147    }
148
149    #[test]
150    fn typed_skill_run_resolves_one_descriptor_and_returns_tagged_provenance() {
151        let dir = tempfile::tempdir().unwrap();
152        let skills_root = dir.path().join("skills");
153        let skill_root = skills_root.join("my-review");
154        std::fs::create_dir_all(&skill_root).unwrap();
155        std::fs::write(
156            skill_root.join("SKILL.md"),
157            "---\nname: my-review\ndescription: test classification\nmetadata:\n  version: \
158             2.1.0\n---\n\ninstructions\n",
159        )
160        .unwrap();
161        std::fs::write(
162            skill_root.join("aise-capability.toml"),
163            "schema_version = 1\nkind = \"message-classification\"\n\n\
164             [[categories]]\nname = \"clobber\"\npatterns = ['''\\byou overwrote\\b''']\n",
165        )
166        .unwrap();
167
168        let mut config = Config::default();
169        config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
170        let app = app_with_user_messages(dir.path(), config, &["you overwrote the notes"]);
171        let report = app
172            .analysis()
173            .run_skill(&crate::skill_run::SkillRunQuery {
174                skill: crate::skill_catalog::SkillSelector::Name(
175                    crate::skill_catalog::SkillNameSelector {
176                        name: crate::skill_catalog::SkillName::try_from("my-review".to_string())
177                            .unwrap(),
178                    },
179                ),
180                definition: None,
181                input: crate::skill_run::SkillCapabilityInput::MessageClassification(
182                    crate::skill_run::MessageClassificationQuery {
183                        filters: MessageFilters::default(),
184                        additional_skills: Vec::new(),
185                    },
186                ),
187            })
188            .unwrap();
189
190        let value = serde_json::to_value(&report).unwrap();
191        assert_eq!(value["requested_selector"]["name"], "my-review");
192        assert_eq!(value["resolved_skill"]["name"], "my-review");
193        assert_eq!(value["output"]["capability"], "message-classification");
194        assert_eq!(
195            value["output"]["result"]["report"]["matches"][0]["category"],
196            "clobber"
197        );
198        assert_eq!(
199            value["output"]["result"]["receipt"]["sha256"]
200                .as_str()
201                .unwrap()
202                .len(),
203            64
204        );
205        assert_eq!(
206            value["output"]["result"]["receipt"],
207            value["output"]["result"]["report"]["policies"][0],
208            "the primary envelope receipt is derived from the first evaluated policy"
209        );
210
211        let duplicate = app
212            .analysis()
213            .run_skill(&crate::skill_run::SkillRunQuery {
214                skill: crate::skill_catalog::SkillSelector::Name(
215                    crate::skill_catalog::SkillNameSelector {
216                        name: crate::skill_catalog::SkillName::try_from("my-review".to_string())
217                            .unwrap(),
218                    },
219                ),
220                definition: None,
221                input: crate::skill_run::SkillCapabilityInput::MessageClassification(
222                    crate::skill_run::MessageClassificationQuery {
223                        filters: MessageFilters::default(),
224                        additional_skills: vec![crate::skill_catalog::SkillSelector::Path(
225                            crate::skill_catalog::SkillPathSelector { path: skill_root },
226                        )],
227                    },
228                ),
229            })
230            .expect_err("the same canonical skill selected by name and path must fail");
231        assert!(
232            duplicate.to_string().contains("selected more than once"),
233            "{duplicate:#}"
234        );
235    }
236
237    /// `--skill NAME` must REPLACE the built-in rules, not merge with them, and the report must
238    /// say which policy produced each match.
239    ///
240    /// The two fixture messages are chosen so each policy matches exactly one and misses the
241    /// other: `you overwrote` hits no built-in category, and `you forgot` hits no category the
242    /// external skill defines. A merge, a silent fallback, or an ignored selection each produce a
243    /// different count here, so this fails loudly rather than looking plausible.
244    #[test]
245    fn a_named_skill_replaces_the_built_in_rules_and_names_itself_in_the_report() {
246        let dir = tempfile::tempdir().unwrap();
247        let skills_root = dir.path().join("skills");
248        write_skill(
249            &skills_root,
250            "my-corrections",
251            "clobber",
252            r"\byou overwrote\b",
253        );
254
255        let mut config = Config::default();
256        config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
257        let app = app_with_user_messages(
258            dir.path(),
259            config,
260            &["you overwrote my notes", "you forgot the tests"],
261        );
262        let analysis = app.analysis();
263
264        let selected = classify_messages(
265            &analysis,
266            crate::skill_catalog::SkillSelector::name("my-corrections").unwrap(),
267            MessageFilters::default(),
268        );
269        assert_eq!(
270            selected
271                .matches
272                .iter()
273                .map(|hit| (
274                    hit.policy_name.as_str(),
275                    hit.category.as_str(),
276                    hit.matched_text.as_str()
277                ))
278                .collect::<Vec<_>>(),
279            vec![("my-corrections", "clobber", "you overwrote")],
280            "only the selected skill's categories may apply"
281        );
282        assert_eq!(
283            selected
284                .policies
285                .iter()
286                .map(|receipt| (receipt.name.as_str(), receipt.version.as_str()))
287                .collect::<Vec<_>>(),
288            vec![("my-corrections", "2.1.0")],
289            "the receipt reports the policy's own version, not the aise version"
290        );
291        assert_eq!(
292            selected.policies[0].sha256.len(),
293            64,
294            "a receipt without a digest cannot reproduce a run"
295        );
296
297        let defaulted = classify_messages(
298            &analysis,
299            crate::skill_catalog::SkillSelector::name("corrections").unwrap(),
300            MessageFilters::default(),
301        );
302        assert_eq!(
303            defaulted
304                .matches
305                .iter()
306                .map(|hit| (hit.policy_name.as_str(), hit.category.as_str()))
307                .collect::<Vec<_>>(),
308            vec![(crate::corrections::EMBEDDED_POLICY_NAME, "skip_step")],
309            "omitting --skill must evaluate the embedded policy, and ONLY it"
310        );
311    }
312
313    /// A direct definition replaces only the selected package's executable rules. The package
314    /// continues to own identity and authorization, while the canonical in-memory digest makes
315    /// repeated calls with the same ordered definition reproducible.
316    #[test]
317    fn a_direct_definition_replaces_file_rules_without_replacing_skill_identity() {
318        let dir = tempfile::tempdir().unwrap();
319        let skills_root = dir.path().join("skills");
320        write_skill(
321            &skills_root,
322            "my-corrections",
323            "file-rule",
324            r"\byou overwrote\b",
325        );
326
327        let mut config = Config::default();
328        config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
329        let app = app_with_user_messages(
330            dir.path(),
331            config,
332            &["you overwrote my notes", "that answer is incorrect"],
333        );
334        let query = crate::skill_run::SkillRunQuery {
335            skill: crate::skill_catalog::SkillSelector::name("my-corrections").unwrap(),
336            definition: Some(crate::skill_run::MessageClassificationDefinition {
337                categories: vec![crate::corrections::CorrectionCategorySpec {
338                    name: "direct-rule".to_string(),
339                    patterns: vec![r"\bincorrect\b".to_string()],
340                }],
341            }),
342            input: crate::skill_run::SkillCapabilityInput::MessageClassification(
343                crate::skill_run::MessageClassificationQuery::default(),
344            ),
345        };
346
347        let first = app.analysis().run_skill(&query).unwrap();
348        let second = app.analysis().run_skill(&query).unwrap();
349        assert_eq!(first.resolved_skill.name.as_str(), "my-corrections");
350        assert_eq!(
351            first.resolved_skill.package_version.as_deref(),
352            Some("2.1.0")
353        );
354        assert!(matches!(
355            first.resolved_skill.selected_location,
356            crate::skill_run::SelectedSkillLocation::Path { .. }
357        ));
358        assert!(matches!(
359            first.resolved_skill.execution_source,
360            crate::skill_run::CapabilityExecutionSource::Inline
361        ));
362        let crate::skill_run::SkillCapabilityOutput::MessageClassification(first_output) =
363            first.output;
364        let crate::skill_run::SkillCapabilityOutput::MessageClassification(second_output) =
365            second.output;
366        assert_eq!(
367            first_output
368                .report
369                .matches
370                .iter()
371                .map(|matched| (
372                    matched.policy_name.as_str(),
373                    matched.category.as_str(),
374                    matched.matched_text.as_str()
375                ))
376                .collect::<Vec<_>>(),
377            vec![("my-corrections", "direct-rule", "incorrect")],
378            "the direct definition must replace, rather than merge with, the file rules"
379        );
380        assert_eq!(first_output.receipt, first_output.report.policies[0]);
381        assert_eq!(
382            first_output.receipt.sha256, second_output.receipt.sha256,
383            "the same ordered definition must produce the same canonical digest"
384        );
385
386        let invalid = crate::skill_run::SkillRunQuery {
387            definition: Some(crate::skill_run::MessageClassificationDefinition {
388                categories: Vec::new(),
389            }),
390            ..query
391        };
392        let error = app
393            .analysis()
394            .run_skill(&invalid)
395            .expect_err("an empty direct definition must use the shared policy validator");
396        assert!(
397            error.to_string().contains("defines no categories"),
398            "the validation error must say how the definition is invalid: {error:#}"
399        );
400    }
401
402    #[test]
403    fn a_direct_definition_and_additional_package_share_one_byte_budget() {
404        let dir = tempfile::tempdir().unwrap();
405        let skills_root = dir.path().join("skills");
406        write_skill(&skills_root, "primary", "unused", r"\bunused\b");
407        write_skill(&skills_root, "additional", "file-rule", r"\bwrong\b");
408        let additional_capability = skills_root.join("additional/aise-capability.toml");
409        let mut file = std::fs::read_to_string(&additional_capability).unwrap();
410        file.push_str(&format!("\n# {}\n", "x".repeat(500 * 1024)));
411        std::fs::write(&additional_capability, file).unwrap();
412
413        let mut config = Config::default();
414        config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
415        let app = app_with_user_messages(dir.path(), config, &["wrong"]);
416        let error = app
417            .analysis()
418            .run_skill(&crate::skill_run::SkillRunQuery {
419                skill: crate::skill_catalog::SkillSelector::name("primary").unwrap(),
420                definition: Some(crate::skill_run::MessageClassificationDefinition {
421                    categories: vec![crate::corrections::CorrectionCategorySpec {
422                        name: "inline".to_string(),
423                        patterns: vec![format!("(?x){}needle", " ".repeat(600 * 1024))],
424                    }],
425                }),
426                input: crate::skill_run::SkillCapabilityInput::MessageClassification(
427                    crate::skill_run::MessageClassificationQuery {
428                        filters: MessageFilters::default(),
429                        additional_skills: vec![crate::skill_catalog::SkillSelector::name(
430                            "additional",
431                        )
432                        .unwrap()],
433                    },
434                ),
435            })
436            .expect_err("direct and additional package definitions must share one budget");
437        let message = error.to_string();
438        assert!(
439            message.contains("aggregate")
440                && message.contains("additional")
441                && message.contains("aise-capability.toml")
442                && message.contains("bytes were consumed by earlier selections"),
443            "{message}"
444        );
445    }
446
447    /// An unknown `--skill` name must fail, naming the value and where to look up valid ones.
448    ///
449    /// The dangerous alternative is not a bad message: it is returning `Ok` with default-policy
450    /// results, which looks like a successful run against rules the caller never selected.
451    #[test]
452    fn an_unknown_skill_name_fails_instead_of_silently_using_the_defaults() {
453        let dir = tempfile::tempdir().unwrap();
454        let app = app_with_user_messages(dir.path(), Config::default(), &["you forgot the tests"]);
455        let error = app
456            .analysis()
457            .run_skill(&crate::skill_run::SkillRunQuery {
458                skill: crate::skill_catalog::SkillSelector::name("not-installed").unwrap(),
459                definition: None,
460                input: crate::skill_run::SkillCapabilityInput::MessageClassification(
461                    crate::skill_run::MessageClassificationQuery::default(),
462                ),
463            })
464            .expect_err("an unknown skill must not resolve to the defaults");
465        let message = format!("{error:#}");
466        assert!(
467            message.contains("not-installed") && message.contains("aise skills list"),
468            "the error must name the value and how to find valid ones: {message}"
469        );
470    }
471
472    #[test]
473    fn analysis_service_reuses_indexed_correction_planning_and_role_queries() {
474        let dir = tempfile::tempdir().unwrap();
475        let mut config = Config::default();
476        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
477        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
478        let app = SessionSearch::open(config).unwrap();
479        let mut parsed = minimal_record(
480            Provider::Claude,
481            std::path::Path::new("/fixture/session.jsonl"),
482            String::new(),
483        );
484        parsed.session.id = "claude:analysis".into();
485        parsed.session.provider_session_id = "analysis".into();
486        parsed.messages = vec![
487            Message {
488                seq: 0,
489                role: Role::User,
490                ts: None,
491                tool_name: None,
492                kind: MessageKind::Conversation,
493                tool_call_id: None,
494                is_compaction: false,
495                content: "actually, that is wrong".into(),
496                provenance: human_original_provenance(),
497            },
498            Message {
499                seq: 1,
500                role: Role::Slash,
501                ts: None,
502                tool_name: None,
503                kind: MessageKind::Conversation,
504                tool_call_id: None,
505                is_compaction: false,
506                content: "/plan verify migration".into(),
507                provenance: Default::default(),
508            },
509        ];
510        app.database().upsert_session(&parsed, 0, 0).unwrap();
511        let mut other = minimal_record(
512            Provider::Codex,
513            std::path::Path::new("/fixture/codex.jsonl"),
514            String::new(),
515        );
516        other.session.id = "codex:analysis".into();
517        other.session.provider_session_id = "analysis".into();
518        other.messages = vec![Message {
519            seq: 0,
520            role: Role::User,
521            ts: None,
522            tool_name: None,
523            kind: MessageKind::Conversation,
524            tool_call_id: None,
525            is_compaction: false,
526            content: "unrelated provider message".into(),
527            provenance: human_original_provenance(),
528        }];
529        app.database().upsert_session(&other, 0, 0).unwrap();
530
531        let analysis = app.analysis();
532        let filters = MessageFilters::default();
533        let corrections = classify_messages(
534            &analysis,
535            crate::skill_catalog::SkillSelector::name("corrections").unwrap(),
536            filters.clone(),
537        );
538        assert_eq!(corrections.matches.len(), 1);
539        // A default run evaluates exactly the embedded policy, and says so in its receipt.
540        assert_eq!(
541            corrections
542                .policies
543                .iter()
544                .map(|receipt| receipt.name.as_str())
545                .collect::<Vec<_>>(),
546            vec![crate::corrections::EMBEDDED_POLICY_NAME]
547        );
548        assert_eq!(
549            corrections.matches[0].policy_name,
550            crate::corrections::EMBEDDED_POLICY_NAME
551        );
552        let planning = analysis.planning(&filters, &["^/plan$".into()]).unwrap();
553        assert_eq!(planning.len(), 1);
554        assert_eq!(planning[0].command, "/plan");
555        let roles = analysis.role_statistics(&filters).unwrap();
556        assert_eq!(roles.len(), 2);
557        assert_eq!(roles.iter().map(|row| row.count).sum::<i64>(), 3);
558        let provider_roles = analysis
559            .role_statistics(&MessageFilters {
560                providers: Some(vec![Provider::Claude]),
561                ..Default::default()
562            })
563            .unwrap();
564        assert_eq!(provider_roles.iter().map(|row| row.count).sum::<i64>(), 2);
565    }
566
567    #[test]
568    fn analysis_run_matches_paged_documents_reference_on_indexed_sessions() {
569        use crate::analysis_pipeline::{
570            AnalysisPolicySpec, ClassificationRuleSpec, ClassificationTarget, PhraseTextMode,
571            PhraseVocabularyPolicySpec,
572        };
573
574        let dir = tempfile::tempdir().unwrap();
575        let mut config = Config::default();
576        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
577        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
578        let app = SessionSearch::open(config).unwrap();
579        // Multi-message sessions with junction-spanning phrases and a code fence that
580        // opens in one message and closes in the next.
581        for (id, provider, texts) in [
582            (
583                "claude:multi",
584                Provider::Claude,
585                vec!["use tdd across chunks", "tdd again across chunks"],
586            ),
587            (
588                "codex:fence",
589                Provider::Codex,
590                vec!["prose one\n```", "code hidden\n```\nprose two"],
591            ),
592            ("gemini-cli:empty", Provider::GeminiCli, vec![]),
593        ] {
594            let mut parsed = minimal_record(
595                Provider::Claude,
596                std::path::Path::new("/fixture/session.jsonl"),
597                String::new(),
598            );
599            parsed.session.provider = provider;
600            parsed.session.id = id.into();
601            parsed.session.provider_session_id = id.into();
602            parsed.messages = texts
603                .iter()
604                .enumerate()
605                .map(|(seq, text)| Message {
606                    seq: seq as i64,
607                    role: Role::User,
608                    ts: None,
609                    tool_name: None,
610                    kind: MessageKind::Conversation,
611                    tool_call_id: None,
612                    is_compaction: false,
613                    content: (*text).into(),
614                    provenance: human_original_provenance(),
615                })
616                .collect();
617            app.database().upsert_session(&parsed, 0, 0).unwrap();
618        }
619        let policy = AnalysisPolicySpec {
620            classification_rules: vec![ClassificationRuleSpec {
621                dimension: "technique".into(),
622                label: "tdd".into(),
623                target: ClassificationTarget::UserText,
624                pattern: "(?i)\\btdd\\b".into(),
625                weight: 1,
626            }],
627            relationship_rules: vec![],
628            phrase_vocabulary: Some(PhraseVocabularyPolicySpec {
629                widths: vec![2],
630                max_unique_phrases: 1000,
631                min_document_tokens: 0,
632                excluded_tokens: vec![],
633                exclude_numeric_tokens: true,
634                text_mode: PhraseTextMode::ProseOnly,
635            }),
636            max_classification_chars: None,
637        }
638        .compile()
639        .unwrap();
640        let filters = SearchFilters::default();
641
642        let request = crate::models::AnalysisRequest::new(
643            filters.clone(),
644            crate::models::AnalysisSessionSelection::AllEligible,
645        )
646        .unwrap();
647        let streamed = app.analysis().run(&request, &policy).unwrap();
648
649        // Reference: the public paged-documents API still materializes joined text; the
650        // streaming run must be indistinguishable from analyzing those documents.
651        let limited = SearchFilters {
652            limit: 2,
653            ..filters
654        };
655        let mut documents = Vec::new();
656        let mut cursor = None;
657        loop {
658            let page = app.analysis().documents(&limited, cursor.as_ref()).unwrap();
659            documents.extend(page.documents);
660            match page.next_cursor {
661                Some(next) => cursor = Some(next),
662                None => break,
663            }
664        }
665        let reference = policy.analyze(documents).unwrap();
666
667        assert_eq!(
668            serde_json::to_value(&streamed.result).unwrap(),
669            serde_json::to_value(&reference).unwrap()
670        );
671        assert!(!streamed.result.vocabulary.is_empty());
672        assert!(streamed.result.sessions["claude:multi"].has_user_text);
673        assert!(!streamed.result.sessions["gemini-cli:empty"].has_user_text);
674    }
675
676    #[test]
677    fn analysis_run_streams_one_snapshot_and_resolves_across_pages() {
678        use crate::analysis_pipeline::{
679            AnalysisPolicy, ClassificationRuleSpec, ClassificationTarget, RelationshipKind,
680            RelationshipResolution, RelationshipRuleSpec,
681        };
682
683        let dir = tempfile::tempdir().unwrap();
684        let mut config = Config::default();
685        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
686        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
687        let app = SessionSearch::open(config).unwrap();
688        for (id, provider, title, text) in [
689            ("claude:root", Provider::Claude, "Root", "use TDD"),
690            ("codex:root", Provider::Codex, "Root", ""),
691            (
692                "gemini-cli:child",
693                Provider::GeminiCli,
694                "Branch of Root",
695                "use TDD",
696            ),
697        ] {
698            let mut parsed = minimal_record(
699                provider,
700                std::path::Path::new("/fixture/session.jsonl"),
701                String::new(),
702            );
703            parsed.session.id = id.into();
704            parsed.session.provider_session_id = id.into();
705            parsed.session.title = Some(title.into());
706            if !text.is_empty() {
707                parsed.messages.push(Message {
708                    seq: 0,
709                    role: Role::User,
710                    ts: None,
711                    tool_name: None,
712                    kind: MessageKind::Conversation,
713                    tool_call_id: None,
714                    is_compaction: false,
715                    content: text.into(),
716                    provenance: human_original_provenance(),
717                });
718            }
719            app.database().upsert_session(&parsed, 0, 0).unwrap();
720        }
721        let policy = AnalysisPolicy::compile(
722            vec![ClassificationRuleSpec {
723                dimension: "technique".into(),
724                label: "tdd".into(),
725                target: ClassificationTarget::UserText,
726                pattern: "(?i)\\btdd\\b".into(),
727                weight: 1,
728            }],
729            vec![RelationshipRuleSpec {
730                id: "branch_of".into(),
731                kind: RelationshipKind::Branch,
732                pattern: "^Branch of (?P<parent>.+)$".into(),
733            }],
734        )
735        .unwrap();
736        let request = crate::models::AnalysisRequest::new(
737            SearchFilters::default(),
738            crate::models::AnalysisSessionSelection::AllEligible,
739        )
740        .unwrap();
741        let automatic = app.analysis().run(&request, &policy).unwrap();
742        for batch_size in [1, 2, 50] {
743            let batched = app
744                .analysis()
745                .run_with_session_batch_size(
746                    &request,
747                    std::num::NonZeroUsize::new(batch_size).unwrap(),
748                    &policy,
749                )
750                .unwrap();
751            assert_eq!(
752                serde_json::to_value(&batched).unwrap(),
753                serde_json::to_value(&automatic).unwrap(),
754                "internal batch size {batch_size} must not alter results, counts, or digests"
755            );
756        }
757        let run = app
758            .analysis()
759            .run_with_session_batch_size(&request, std::num::NonZeroUsize::new(1).unwrap(), &policy)
760            .unwrap();
761
762        assert_eq!(run.result.sessions.len(), 3);
763        assert_eq!(run.result.sessions["gemini-cli:child"].score, 1);
764        assert_eq!(
765            run.result.sessions["gemini-cli:child"].relationship_hints[0].resolution,
766            RelationshipResolution::Ambiguous {
767                session_ids: vec!["claude:root".into(), "codex:root".into()]
768            }
769        );
770
771        let limited = crate::models::AnalysisRequest::new(
772            SearchFilters::default(),
773            crate::models::AnalysisSessionSelection::FirstCanonicalSessions {
774                max_sessions: std::num::NonZeroUsize::new(2).unwrap(),
775            },
776        )
777        .unwrap();
778        assert_eq!(
779            app.analysis()
780                .run_with_session_batch_size(
781                    &limited,
782                    std::num::NonZeroUsize::new(1).unwrap(),
783                    &policy,
784                )
785                .unwrap()
786                .result
787                .sessions
788                .len(),
789            2
790        );
791    }
792
793    #[test]
794    fn receipted_analysis_names_canonical_session_selection_and_exact_count_units() {
795        use std::num::NonZeroUsize;
796
797        use crate::analysis_pipeline::AnalysisPolicySpec;
798        use crate::models::{AnalysisRequest, AnalysisSessionSelection};
799
800        let dir = tempfile::tempdir().unwrap();
801        let mut config = Config::default();
802        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
803        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
804        let app = SessionSearch::open(config).unwrap();
805        for (id, updated_at) in [
806            ("claude:a-newer", "2026-03-01T00:00:00Z"),
807            ("claude:z-older", "2026-01-01T00:00:00Z"),
808        ] {
809            let mut parsed = minimal_record(
810                Provider::Claude,
811                std::path::Path::new(if id == "claude:a-newer" {
812                    "/fixture/a-newer.jsonl"
813                } else {
814                    "/fixture/z-older.jsonl"
815                }),
816                String::new(),
817            );
818            parsed.session.id = id.into();
819            parsed.session.provider_session_id = id.into();
820            parsed.session.updated_at = Some(updated_at.parse().unwrap());
821            parsed.messages = vec![
822                Message {
823                    seq: 0,
824                    role: Role::User,
825                    ts: None,
826                    tool_name: None,
827                    kind: MessageKind::Conversation,
828                    tool_call_id: None,
829                    is_compaction: false,
830                    content: "human input".into(),
831                    provenance: human_original_provenance(),
832                },
833                Message {
834                    seq: 1,
835                    role: Role::Assistant,
836                    ts: None,
837                    tool_name: None,
838                    kind: MessageKind::Conversation,
839                    tool_call_id: None,
840                    is_compaction: false,
841                    content: "assistant output".into(),
842                    provenance: Default::default(),
843                },
844                Message {
845                    seq: 2,
846                    role: Role::Tool,
847                    ts: None,
848                    tool_name: Some("test".into()),
849                    kind: MessageKind::ToolResult,
850                    tool_call_id: None,
851                    is_compaction: false,
852                    content: "tool output".into(),
853                    provenance: Default::default(),
854                },
855            ];
856            app.database().upsert_session(&parsed, 0, 0).unwrap();
857        }
858        let policy = AnalysisPolicySpec::default().compile().unwrap();
859        let request = AnalysisRequest::new(
860            SearchFilters::default(),
861            AnalysisSessionSelection::FirstCanonicalSessions {
862                max_sessions: NonZeroUsize::new(1).unwrap(),
863            },
864        )
865        .unwrap();
866
867        let run = app.analysis().run(&request, &policy).unwrap();
868        assert_eq!(
869            run.result.sessions.keys().cloned().collect::<Vec<_>>(),
870            ["claude:a-newer"]
871        );
872        assert_eq!(run.receipt.selected_sessions, 1);
873        assert_eq!(run.receipt.messages_in_selected_sessions, 3);
874        assert_eq!(run.receipt.analyzed_user_messages, 1);
875        assert!(run.receipt.has_more);
876        assert_eq!(
877            run.receipt.last_selected_session_id.as_deref(),
878            Some("claude:a-newer")
879        );
880        assert!(run.receipt.policy_digest.starts_with("sha256:"));
881        assert!(run.receipt.corpus_digest.starts_with("sha256:"));
882        assert!(run.receipt.result_digest.starts_with("sha256:"));
883        assert_ne!(run.receipt.policy_digest, run.receipt.corpus_digest);
884        assert_ne!(run.receipt.corpus_digest, run.receipt.result_digest);
885        assert_eq!(run.receipt.policy_digest, policy.policy_digest());
886        assert_eq!(run.receipt.result_digest, run.result.result_digest());
887        assert_eq!(
888            serde_json::to_value(run.receipt.selection).unwrap(),
889            serde_json::json!({
890                "kind": "first_canonical_sessions",
891                "max_sessions": 1
892            })
893        );
894
895        let all = app
896            .analysis()
897            .run(
898                &AnalysisRequest::new(
899                    SearchFilters::default(),
900                    AnalysisSessionSelection::AllEligible,
901                )
902                .unwrap(),
903                &policy,
904            )
905            .unwrap();
906        assert_eq!(all.receipt.selected_sessions, 2);
907        assert_eq!(all.receipt.messages_in_selected_sessions, 6);
908        assert_eq!(all.receipt.analyzed_user_messages, 2);
909        assert!(!all.receipt.has_more);
910        assert_ne!(all.receipt.corpus_digest, run.receipt.corpus_digest);
911    }
912
913    #[test]
914    fn analysis_request_rejects_generic_session_limit_with_actionable_replacement() {
915        use crate::models::{AnalysisRequest, AnalysisSessionSelection};
916
917        let error = AnalysisRequest::new(
918            SearchFilters {
919                limit: 5,
920                ..SearchFilters::default()
921            },
922            AnalysisSessionSelection::AllEligible,
923        )
924        .unwrap_err();
925
926        assert!(error.to_string().contains("SearchFilters.limit"));
927        assert!(error.to_string().contains("FirstCanonicalSessions"));
928    }
929}
930
931impl SessionSearch {
932    /// Load platform configuration and open the configured index.
933    pub fn load() -> Result<Self> {
934        Self::open(Config::load()?)
935    }
936
937    /// Open an index using an explicit configuration.
938    ///
939    /// `existing-only` opens a SQLite-enforced read-only handle and never creates directories,
940    /// installs schema objects, refreshes sources, or changes journal policy. SQLite may create or
941    /// update empty `-wal`/`-shm` coordination sidecars when opening a WAL database whose sidecars
942    /// are absent; it does not modify the durable database contents. Other refresh modes
943    /// may create a new current-schema database and later perform the configured incremental
944    /// refresh. Newer, hybrid, and incomplete current schemas fail before an ordinary query.
945    pub fn open(config: Config) -> Result<Self> {
946        let inputs = TrustedAccessInputs::capture(&config.search.scope, Vec::new())?;
947        Self::open_with_access_inputs(config, inputs)
948    }
949
950    /// Open an index with trusted runtime roots supplied by a harness integration.
951    pub fn open_with_access_inputs(
952        config: Config,
953        access_inputs: TrustedAccessInputs,
954    ) -> Result<Self> {
955        Self::open_with_access_inputs_and_runtime(config, access_inputs, None)
956    }
957
958    /// Open an independent prepared-index reader sharing one fixed data-parallel worker budget.
959    ///
960    /// The caller must select `existing-only`; this constructor reuses all normal schema and
961    /// authority validation while preventing per-reader Rayon pools. Each returned application
962    /// owns one SQLite read connection, so a bounded set can read a WAL database concurrently.
963    pub(crate) fn open_prepared_reader(
964        config: Config,
965        access_inputs: TrustedAccessInputs,
966        runtime: Arc<ExecutionRuntime>,
967    ) -> Result<Self> {
968        if config.index.refresh != IndexRefresh::ExistingOnly {
969            anyhow::bail!(
970                "prepared readers require index.refresh=existing-only after the freshness coordinator completes"
971            );
972        }
973        Self::open_with_access_inputs_and_runtime(config, access_inputs, Some(runtime))
974    }
975
976    fn open_with_access_inputs_and_runtime(
977        config: Config,
978        access_inputs: TrustedAccessInputs,
979        runtime: Option<Arc<ExecutionRuntime>>,
980    ) -> Result<Self> {
981        let access = EffectiveAccessScope::resolve(&config.search.scope, access_inputs)?;
982        let schema_state = IndexCoordinator::new(&config).inspect_schema()?;
983        match schema_state {
984            SchemaState::Missing if config.index.refresh == IndexRefresh::ExistingOnly => {
985                anyhow::bail!(
986                    "existing-only index {} does not exist; run `aise reindex --full` without --index-refresh existing-only",
987                    config.db_path().display()
988                )
989            }
990            SchemaState::Older { current, .. }
991                if config.index.refresh == IndexRefresh::ExistingOnly
992                    && current < MIN_READABLE_SCHEMA_VERSION =>
993            {
994                anyhow::bail!(
995                    "existing-only index {} uses unreadable schema generation {current}; run `aise reindex --full` without --index-refresh existing-only",
996                    config.db_path().display()
997                )
998            }
999            SchemaState::Missing | SchemaState::Current | SchemaState::Older { .. } => {}
1000            SchemaState::Newer { current, supported } => anyhow::bail!(
1001                "index {} uses schema generation {current}, newer than this aise build supports ({supported}); upgrade aise before opening it",
1002                config.db_path().display()
1003            ),
1004            SchemaState::UnknownProviders { providers } => anyhow::bail!(
1005                "{}",
1006                SchemaState::unknown_providers_message(&config.db_path(), &providers)
1007            ),
1008            SchemaState::RepairableLayout { reason }
1009                if config.index.refresh == IndexRefresh::ExistingOnly =>
1010            {
1011                anyhow::bail!(
1012                    "existing-only index {} needs a one-time message-search index rebuild ({reason}); run a writable aise command (without --index-refresh existing-only) to self-heal it in place",
1013                    config.db_path().display()
1014                )
1015            }
1016            SchemaState::RepairableLayout { .. } => {
1017                // Elect one cross-process writer, re-inspect under the lock (a peer process may have
1018                // already healed it), and rebuild the derived message-search indexes online from the
1019                // intact base rows via the atomic exclusive migration. Then fall through to the
1020                // normal open below against the now-current schema.
1021                let heal_threads = NonZeroUsize::new(config.resolve_threads())
1022                    .expect("Config::resolve_threads always returns at least one");
1023                let heal_busy_timeout = config.index.busy_timeout_ms;
1024                crate::indexer::with_index_update_lock(&config, || {
1025                    if matches!(
1026                        IndexCoordinator::new(&config).inspect_schema()?,
1027                        SchemaState::RepairableLayout { .. }
1028                    ) {
1029                        let db = Db::open_with_threads(
1030                            &config.db_path(),
1031                            heal_busy_timeout,
1032                            heal_threads,
1033                        )?;
1034                        db.migrate_message_search_schema_exclusive()?;
1035                    }
1036                    Ok(())
1037                })?;
1038            }
1039            SchemaState::RecoveryRequired { reason } => anyhow::bail!(
1040                "index {} requires offline recovery: {reason}; stop AISE processes, then run `aise reindex --full`",
1041                config.db_path().display()
1042            ),
1043        }
1044        if config.index.refresh != IndexRefresh::ExistingOnly {
1045            fs::create_dir_all(config.cache_dir())?;
1046        }
1047        let worker_threads = NonZeroUsize::new(config.resolve_threads())
1048            .expect("Config::resolve_threads always returns at least one");
1049        let mut db = if config.index.refresh == IndexRefresh::ExistingOnly {
1050            if let Some(runtime) = runtime {
1051                Db::open_existing_read_only_with_runtime(
1052                    &config.db_path(),
1053                    config.index.busy_timeout_ms,
1054                    runtime,
1055                )?
1056            } else {
1057                Db::open_existing_read_only_with_threads(
1058                    &config.db_path(),
1059                    config.index.busy_timeout_ms,
1060                    worker_threads,
1061                )?
1062            }
1063        } else {
1064            Db::open_with_threads(
1065                &config.db_path(),
1066                config.index.busy_timeout_ms,
1067                worker_threads,
1068            )?
1069        };
1070        db.set_access_scope(access.clone());
1071        db.set_implicit_index_maintenance(config.index.refresh != IndexRefresh::ExistingOnly);
1072        Ok(Self { config, access, db })
1073    }
1074
1075    /// Open with explicit maintenance authority even when implicit refresh is configured as
1076    /// `existing-only`. CLI `reindex` and `compact` are explicit write requests; ordinary query
1077    /// commands continue to receive a SQLite-enforced read-only handle. The stored configuration
1078    /// retains the user's selected refresh policy.
1079    pub(crate) fn open_for_maintenance(mut config: Config) -> Result<Self> {
1080        let refresh = config.index.refresh;
1081        config.index.refresh = IndexRefresh::BeforeQuery;
1082        let mut app = Self::open(config)?;
1083        app.config.index.refresh = refresh;
1084        Ok(app)
1085    }
1086
1087    /// Effective configuration used by every service handle.
1088    pub const fn config(&self) -> &Config {
1089        &self.config
1090    }
1091
1092    /// Immutable access authority used by every read service.
1093    pub const fn access_scope(&self) -> &EffectiveAccessScope {
1094        &self.access
1095    }
1096
1097    /// Session catalog operations.
1098    pub const fn catalog(&self) -> CatalogService<'_> {
1099        CatalogService::new(&self.db)
1100    }
1101
1102    /// Message search and context operations.
1103    pub const fn messages(&self) -> MessageService<'_> {
1104        MessageService::new(&self.config, &self.db, SearchSurface::Rust)
1105    }
1106
1107    /// Construct message search for a language or protocol adapter's documented defaults.
1108    #[doc(hidden)]
1109    pub const fn messages_for_surface(&self, surface: SearchSurface) -> MessageService<'_> {
1110        MessageService::new(&self.config, &self.db, surface)
1111    }
1112
1113    /// Open an exhaustive literal, regex, or queryless search as fully enriched batches on a
1114    /// dedicated read-only connection.
1115    ///
1116    /// Ordinary callers should prefer [`MessageService::search`], which returns one materialized
1117    /// response. `batch_rows` trades active memory for handoff and enrichment-query frequency; it
1118    /// does not change result membership, ordering, context, the request-wide union of included
1119    /// data, or terminal metadata. It accepts any positive `usize`; an allocation failure returns
1120    /// an error that asks for a smaller value.
1121    ///
1122    /// # Errors
1123    ///
1124    /// Returns an error when the request selects a finite page or fuzzy matching. Use
1125    /// [`MessageService::search`] for those requests.
1126    pub fn message_search_batches(
1127        &self,
1128        request: MessageSearchRequest,
1129        batch_rows: NonZeroUsize,
1130    ) -> Result<MessageSearchBatches> {
1131        self.message_search_batches_for_surface(SearchSurface::Rust, request, batch_rows)
1132    }
1133
1134    /// Construct exhaustive batches with one output surface's documented default policy.
1135    #[doc(hidden)]
1136    pub fn message_search_batches_for_surface(
1137        &self,
1138        surface: SearchSurface,
1139        request: MessageSearchRequest,
1140        batch_rows: NonZeroUsize,
1141    ) -> Result<MessageSearchBatches> {
1142        MessageSearchBatches::spawn(
1143            self.config.clone(),
1144            self.access.clone(),
1145            surface,
1146            request,
1147            batch_rows,
1148        )
1149    }
1150
1151    /// File history operations.
1152    pub const fn files(&self) -> FileService<'_> {
1153        FileService::new(&self.db)
1154    }
1155
1156    /// Destination-independent session export operations.
1157    pub const fn exports(&self) -> ExportService<'_> {
1158        ExportService::new(&self.db)
1159    }
1160
1161    /// Index lifecycle operations.
1162    pub const fn index(&self) -> IndexService<'_> {
1163        IndexService::new(&self.config, &self.db)
1164    }
1165
1166    /// Index diagnostics and destructive maintenance operations.
1167    pub const fn maintenance(&self) -> MaintenanceService<'_> {
1168        MaintenanceService::new(&self.config, &self.db)
1169    }
1170
1171    /// Effective provider roots and discovery status.
1172    pub const fn sources(&self) -> SourceService<'_> {
1173        SourceService::new(&self.config)
1174    }
1175
1176    /// Indexed correction, planning, and statistics operations.
1177    pub const fn analysis(&self) -> AnalysisService<'_> {
1178        AnalysisService::new(&self.config, &self.db)
1179    }
1180
1181    /// Crate-internal storage access for operations not yet represented by a service.
1182    ///
1183    /// External consumers (Python bindings, integration tests, downstream crates) must use
1184    /// the typed services above; raw [`Db`] access is not part of the supported API.
1185    pub(crate) const fn database(&self) -> &Db {
1186        &self.db
1187    }
1188
1189    /// Install a frontend-specific progress sink.
1190    pub fn set_progress_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
1191        self.db.set_progress_reporter(reporter);
1192    }
1193}
1194
1195#[cfg(test)]
1196mod execution_runtime_tests {
1197    use super::*;
1198    use crate::config::{SearchScopeConfig, SearchScopeMode};
1199    use crate::message_search::{MessageQuery, MessageTarget};
1200    use crate::models::{
1201        FileEdit, FileQuery, Message, MessageFilters, MessageKind, MessageSearchMode, Provider,
1202        Role, SearchFilters,
1203    };
1204    use crate::util::minimal_record;
1205    use std::sync::atomic::{AtomicU32, Ordering};
1206    use std::sync::Arc;
1207
1208    fn all_session_filters(limit: usize) -> SearchFilters {
1209        SearchFilters {
1210            limit,
1211            ..SearchFilters::default()
1212        }
1213    }
1214
1215    #[test]
1216    fn ordinary_open_refuses_newer_schema_before_initialization_can_mutate_it() {
1217        let root = tempfile::tempdir().unwrap();
1218        let db_path = root.path().join("index.db");
1219        drop(Db::open(&db_path).unwrap());
1220        let conn = rusqlite::Connection::open(&db_path).unwrap();
1221        conn.pragma_update(None, "user_version", crate::db::SCHEMA_VERSION + 1)
1222            .unwrap();
1223        let schema_before: String = conn
1224            .query_row(
1225                "select group_concat(coalesce(sql, ''), char(10))
1226                   from (select sql from sqlite_schema order by type, name)",
1227                [],
1228                |row| row.get(0),
1229            )
1230            .unwrap();
1231        drop(conn);
1232
1233        let mut config = Config::default();
1234        config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1235        let error = SessionSearch::open(config)
1236            .err()
1237            .expect("newer schema must be rejected")
1238            .to_string();
1239        assert!(error.contains("newer than this aise build"), "{error}");
1240
1241        let conn = rusqlite::Connection::open_with_flags(
1242            &db_path,
1243            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1244        )
1245        .unwrap();
1246        let schema_after: String = conn
1247            .query_row(
1248                "select group_concat(coalesce(sql, ''), char(10))
1249                   from (select sql from sqlite_schema order by type, name)",
1250                [],
1251                |row| row.get(0),
1252            )
1253            .unwrap();
1254        assert_eq!(schema_after, schema_before);
1255    }
1256
1257    /// A provider variant can be added without a schema-generation bump (Prime Agent was, at
1258    /// generation 5), so an index a newer aise wrote can carry a `sessions.provider` value this
1259    /// build cannot decode. Before this guard the older build failed deep inside whichever query
1260    /// touched such a row with "unrecognized provider ... run `aise reindex --full`", and that
1261    /// command failed the same way while enumerating retained sessions -- circular advice that
1262    /// locked the older build out of every command (observed from a Pi session on 2026-08-13).
1263    /// The open must refuse once, name the provider, and say the fix: upgrade aise.
1264    #[test]
1265    fn ordinary_open_refuses_an_index_carrying_a_provider_this_build_does_not_know() {
1266        let root = tempfile::tempdir().unwrap();
1267        let db_path = root.path().join("index.db");
1268        {
1269            let db = Db::open(&db_path).unwrap();
1270            let parsed = minimal_record(
1271                Provider::Codex,
1272                std::path::Path::new("/tmp/known-session.jsonl"),
1273                String::new(),
1274            );
1275            db.replace_session(&parsed, 0, 0).unwrap();
1276        }
1277        let conn = rusqlite::Connection::open(&db_path).unwrap();
1278        conn.execute(
1279            "update sessions set provider = 'future-provider' where id = 'codex:known-session'",
1280            [],
1281        )
1282        .unwrap();
1283        drop(conn);
1284
1285        let mut config = Config::default();
1286        config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1287        for refresh in [IndexRefresh::Auto, IndexRefresh::ExistingOnly] {
1288            let mut config = config.clone();
1289            config.index.refresh = refresh;
1290            let error = SessionSearch::open(config)
1291                .err()
1292                .unwrap_or_else(|| panic!("{refresh:?}: unknown provider must be rejected"))
1293                .to_string();
1294            assert!(error.contains("future-provider"), "{refresh:?}: {error}");
1295            assert!(error.contains("upgrade aise"), "{refresh:?}: {error}");
1296            assert!(
1297                error.contains(env!("CARGO_PKG_VERSION")),
1298                "{refresh:?}: names this build so the reader can compare: {error}"
1299            );
1300            assert!(
1301                !error.contains("reindex --full"),
1302                "{refresh:?}: reindex is the command that fails; advising it is circular: {error}"
1303            );
1304        }
1305    }
1306
1307    #[test]
1308    fn restricted_open_without_authoritative_roots_fails_before_database_creation() {
1309        let root = tempfile::tempdir().unwrap();
1310        let db_path = root.path().join("must-not-exist.db");
1311        let mut config = Config::default();
1312        config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1313        config.search.scope = SearchScopeConfig {
1314            mode: SearchScopeMode::AllowedRoots,
1315            roots: Vec::new(),
1316            include_invocation_directory: false,
1317        };
1318
1319        let error = SessionSearch::open_with_access_inputs(config, TrustedAccessInputs::default())
1320            .err()
1321            .expect("restricted open must fail")
1322            .to_string();
1323
1324        assert!(error.contains("resolved no authoritative roots"), "{error}");
1325        assert!(!db_path.exists());
1326    }
1327
1328    #[test]
1329    fn allowed_roots_scope_is_consistent_across_read_services_and_exact_ids() {
1330        let root = tempfile::tempdir().unwrap();
1331        let allowed = root.path().join("allowed");
1332        let outside = root.path().join("outside");
1333        let sibling = root.path().join("allowed-sibling");
1334        fs::create_dir_all(&allowed).unwrap();
1335        fs::create_dir_all(&outside).unwrap();
1336        fs::create_dir_all(&sibling).unwrap();
1337
1338        let mut config = Config::default();
1339        config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1340        config.index.cache_dir = Some(root.path().join("cache").to_string_lossy().into_owned());
1341        config.search.scope = SearchScopeConfig {
1342            mode: SearchScopeMode::AllowedRoots,
1343            roots: vec![allowed.to_string_lossy().into_owned()],
1344            include_invocation_directory: false,
1345        };
1346        let app =
1347            SessionSearch::open_with_access_inputs(config, TrustedAccessInputs::default()).unwrap();
1348
1349        let insert = |id: &str, workspace: &std::path::Path, transcript: &std::path::Path| {
1350            let mut parsed = minimal_record(Provider::Claude, transcript, String::new());
1351            parsed.session.id = id.into();
1352            parsed.session.provider_session_id = id.replace(':', "-");
1353            parsed.session.cwd = Some(workspace.to_string_lossy().into_owned());
1354            parsed.session.repo_root = Some(workspace.join("repo").to_string_lossy().into_owned());
1355            if id != "claude:allowed" {
1356                parsed.session.title = Some("scope needle scope needle scope needle".into());
1357            }
1358            parsed.session.preview_text = "scope needle".into();
1359            parsed.transcript_text = "scope needle transcript".into();
1360            parsed.messages = vec![Message {
1361                seq: 0,
1362                role: Role::User,
1363                ts: None,
1364                tool_name: None,
1365                kind: MessageKind::Conversation,
1366                tool_call_id: None,
1367                is_compaction: false,
1368                content: "scope needle message".into(),
1369                provenance: Default::default(),
1370            }];
1371            parsed.file_edits = vec![FileEdit {
1372                seq: 0,
1373                ts: None,
1374                tool: "Write".into(),
1375                file_path: workspace.join("scope.txt").to_string_lossy().into_owned(),
1376                file_name: "scope.txt".into(),
1377                new_content: Some(format!("content from {id}")),
1378                edits: Vec::new(),
1379            }];
1380            app.database().upsert_session(&parsed, 0, 0).unwrap();
1381        };
1382
1383        insert(
1384            "claude:allowed",
1385            &allowed.join("project"),
1386            &outside.join("allowed-transcript.jsonl"),
1387        );
1388        // A transcript below an allowed root must not grant authority to an unrelated workspace.
1389        insert(
1390            "claude:hidden",
1391            &outside.join("project"),
1392            &allowed.join("hidden-transcript.jsonl"),
1393        );
1394        // Prefix matching must respect path components rather than string prefixes.
1395        insert(
1396            "claude:sibling",
1397            &sibling.join("project"),
1398            &outside.join("sibling-transcript.jsonl"),
1399        );
1400
1401        let sessions = app
1402            .catalog()
1403            .list_sessions(&all_session_filters(0))
1404            .unwrap();
1405        assert_eq!(sessions.len(), 1);
1406        assert_eq!(sessions[0].id, "claude:allowed");
1407        let session_hits = app
1408            .catalog()
1409            .search_sessions(
1410                "scope needle",
1411                &all_session_filters(0),
1412                None,
1413                &ScoringConfig::default(),
1414            )
1415            .unwrap();
1416        assert_eq!(session_hits.len(), 1);
1417        assert_eq!(session_hits[0].session.id, "claude:allowed");
1418        let narrow_scoring = ScoringConfig::default();
1419        let bounded_hits = app
1420            .catalog()
1421            .search_sessions(
1422                "scope needle",
1423                &all_session_filters(1),
1424                None,
1425                &narrow_scoring,
1426            )
1427            .unwrap();
1428        assert_eq!(bounded_hits.len(), 1);
1429        assert_eq!(bounded_hits[0].session.id, "claude:allowed");
1430
1431        let message_response = app
1432            .messages()
1433            .search(
1434                MessageSearchRequest::builder(
1435                    MessageQuery::literal("scope needle").unwrap(),
1436                    MessageTarget::content(),
1437                )
1438                .build()
1439                .unwrap(),
1440            )
1441            .unwrap();
1442        assert_eq!(message_response.hits().len(), 1);
1443        assert_eq!(message_response.hits()[0].session_id, "claude:allowed");
1444        assert!(app
1445            .messages()
1446            .context("claude:hidden", 0, 1, 1)
1447            .unwrap()
1448            .is_empty());
1449        assert!(app
1450            .messages()
1451            .session_metadata(&["claude:hidden".into()])
1452            .unwrap()
1453            .is_empty());
1454
1455        let files = app.files().search(&FileQuery::default()).unwrap();
1456        assert_eq!(files.len(), 1);
1457        assert!(std::path::Path::new(&files[0].file_path).starts_with(&allowed));
1458        let cross_reference = app.files().cross_reference(&FileQuery::default()).unwrap();
1459        assert_eq!(cross_reference.len(), 1);
1460        assert_eq!(cross_reference[0].session_id, "claude:allowed");
1461        let history = app
1462            .files()
1463            .history("scope.txt", &FileQuery::default())
1464            .unwrap();
1465        assert_eq!(history.len(), 1);
1466        assert_eq!(history[0].session_id, "claude:allowed");
1467        let reconstructed = app
1468            .files()
1469            .reconstruct("scope.txt", &FileQuery::default(), None)
1470            .unwrap();
1471        assert_eq!(reconstructed.session_id, "claude:allowed");
1472        assert_eq!(reconstructed.content, "content from claude:allowed");
1473        let analysis = app
1474            .analysis()
1475            .documents(&all_session_filters(10), None)
1476            .unwrap();
1477        assert_eq!(analysis.documents.len(), 1);
1478        assert_eq!(analysis.documents[0].session.id, "claude:allowed");
1479
1480        assert!(app
1481            .exports()
1482            .render_full("claude:allowed", crate::export::ExportFormat::Json)
1483            .is_ok());
1484        let hidden_error = app
1485            .catalog()
1486            .resolve_session("claude:hidden")
1487            .unwrap_err()
1488            .to_string();
1489        assert!(hidden_error.contains("no session matches"));
1490        assert!(!hidden_error.contains("allowed-sibling"));
1491        assert!(!hidden_error.contains("outside"));
1492        assert!(app
1493            .exports()
1494            .render_full("claude:hidden", crate::export::ExportFormat::Json)
1495            .unwrap_err()
1496            .to_string()
1497            .contains("no session matches"));
1498    }
1499
1500    #[test]
1501    fn applications_own_independent_worker_counts() {
1502        let root = tempfile::tempdir().unwrap();
1503        let mut one_config = Config::default();
1504        one_config.index.db_path = Some(root.path().join("one.db").to_string_lossy().into_owned());
1505        one_config.performance.threads = 1;
1506        let mut two_config = Config::default();
1507        two_config.index.db_path = Some(root.path().join("two.db").to_string_lossy().into_owned());
1508        two_config.performance.threads = 2;
1509
1510        let one = SessionSearch::open(one_config).unwrap();
1511        let two = SessionSearch::open(two_config).unwrap();
1512
1513        assert_eq!(one.database().worker_threads(), 1);
1514        assert_eq!(two.database().worker_threads(), 2);
1515    }
1516
1517    #[test]
1518    fn prepared_readers_require_existing_only_and_reuse_the_shared_worker_budget() {
1519        let root = tempfile::tempdir().unwrap();
1520        let mut config = Config::default();
1521        config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1522        drop(SessionSearch::open(config.clone()).unwrap());
1523        let runtime = Arc::new(ExecutionRuntime::new(
1524            NonZeroUsize::new(2).expect("nonzero worker count"),
1525        ));
1526
1527        let error = SessionSearch::open_prepared_reader(
1528            config.clone(),
1529            TrustedAccessInputs::default(),
1530            Arc::clone(&runtime),
1531        )
1532        .err()
1533        .expect("writable policy must be rejected")
1534        .to_string();
1535        assert!(error.contains("existing-only"), "{error}");
1536
1537        config.index.refresh = IndexRefresh::ExistingOnly;
1538        let first = SessionSearch::open_prepared_reader(
1539            config.clone(),
1540            TrustedAccessInputs::default(),
1541            Arc::clone(&runtime),
1542        )
1543        .unwrap();
1544        let second =
1545            SessionSearch::open_prepared_reader(config, TrustedAccessInputs::default(), runtime)
1546                .unwrap();
1547        assert_eq!(first.database().worker_threads(), 2);
1548        assert_eq!(second.database().worker_threads(), 2);
1549    }
1550
1551    #[test]
1552    fn existing_only_search_never_builds_the_lazy_trigram_index() {
1553        let root = tempfile::tempdir().unwrap();
1554        let mut config = Config::default();
1555        config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1556        config.index.cache_dir = Some(root.path().join("cache").to_string_lossy().into_owned());
1557        let mut parsed = minimal_record(
1558            Provider::Claude,
1559            std::path::Path::new("/fixture/session.jsonl"),
1560            String::new(),
1561        );
1562        parsed.session.id = "claude:existing-only".into();
1563        parsed.session.provider_session_id = "existing-only".into();
1564        parsed.messages = vec![Message {
1565            seq: 0,
1566            role: Role::User,
1567            ts: None,
1568            tool_name: None,
1569            kind: MessageKind::Conversation,
1570            tool_call_id: None,
1571            is_compaction: false,
1572            content: "request failed with ECONNRESET".into(),
1573            provenance: Default::default(),
1574        }];
1575        let writer = SessionSearch::open(config.clone()).unwrap();
1576        writer.database().upsert_session(&parsed, 0, 0).unwrap();
1577        drop(writer);
1578        let conn = rusqlite::Connection::open_with_flags(
1579            config.db_path(),
1580            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1581        )
1582        .unwrap();
1583        let schema_before: String = conn
1584            .query_row(
1585                "select group_concat(coalesce(sql, ''), char(10))
1586                   from (select sql from sqlite_schema order by type, name)",
1587                [],
1588                |row| row.get(0),
1589            )
1590            .unwrap();
1591        drop(conn);
1592
1593        config.index.refresh = IndexRefresh::ExistingOnly;
1594        let mut app = SessionSearch::open(config).unwrap();
1595        let write_error = app
1596            .database()
1597            .upsert_session(&parsed, 1, 1)
1598            .expect_err("existing-only database authority must reject writes")
1599            .to_string();
1600        assert!(
1601            write_error.contains("readonly") || write_error.contains("read-only"),
1602            "{write_error}"
1603        );
1604
1605        let progress_calls = Arc::new(AtomicU32::new(0));
1606        let observed_calls = Arc::clone(&progress_calls);
1607        app.set_progress_reporter(move |_message| {
1608            observed_calls.fetch_add(1, Ordering::Relaxed);
1609        });
1610        let hits = app
1611            .messages()
1612            .search_legacy(
1613                "ECONNRESET",
1614                &MessageFilters {
1615                    match_mode: MessageSearchMode::Regex,
1616                    ..Default::default()
1617                },
1618            )
1619            .unwrap();
1620
1621        assert_eq!(
1622            hits.len(),
1623            1,
1624            "existing-only still scans the unindexed delta"
1625        );
1626        assert_eq!(
1627            progress_calls.load(Ordering::Relaxed),
1628            0,
1629            "existing-only must not start implicit trigram maintenance"
1630        );
1631        assert!(
1632            app.database()
1633                .vocabulary(true, 0, None)
1634                .unwrap()
1635                .iter()
1636                .any(|(term, _, _)| term == "eco" || term == "con" || term == "res"),
1637            "v4 trigram vocabulary remains readable without legacy maintenance"
1638        );
1639        drop(app);
1640        let conn = rusqlite::Connection::open_with_flags(
1641            root.path().join("index.db"),
1642            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1643        )
1644        .unwrap();
1645        let schema_after: String = conn
1646            .query_row(
1647                "select group_concat(coalesce(sql, ''), char(10))
1648                   from (select sql from sqlite_schema order by type, name)",
1649                [],
1650                |row| row.get(0),
1651            )
1652            .unwrap();
1653        assert_eq!(schema_after, schema_before);
1654    }
1655
1656    #[test]
1657    fn existing_only_open_refuses_missing_index_without_creating_paths() {
1658        let root = tempfile::tempdir().unwrap();
1659        let db_path = root.path().join("missing/index.db");
1660        let cache_path = root.path().join("missing/cache");
1661        let mut config = Config::default();
1662        config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1663        config.index.cache_dir = Some(cache_path.to_string_lossy().into_owned());
1664        config.index.refresh = IndexRefresh::ExistingOnly;
1665
1666        let error = SessionSearch::open(config)
1667            .err()
1668            .expect("missing existing-only index must fail")
1669            .to_string();
1670
1671        assert!(error.contains("does not exist"), "{error}");
1672        assert!(error.contains("aise reindex --full"), "{error}");
1673        assert!(!db_path.exists());
1674        assert!(!cache_path.exists());
1675        assert!(!root.path().join("missing").exists());
1676    }
1677}
1678
1679/// Typed indexed analysis shared by native adapters.
1680///
1681/// This service returns data and performs no terminal or filesystem I/O. It deliberately keeps
1682/// legacy recovery-directory counters out of the canonical API: statistics describe the shared
1683/// index and honor the same structural message filters as CLI search.
1684#[derive(Clone, Copy)]
1685pub struct AnalysisService<'app> {
1686    config: &'app Config,
1687    db: &'app Db,
1688}
1689
1690/// Immutable analytical provenance produced with one result under the same SQLite snapshot.
1691#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1692pub struct AnalysisReceipt {
1693    /// Population strategy applied after structural session filtering.
1694    pub selection: crate::models::AnalysisSessionSelection,
1695    /// SQLite schema version observed by this run.
1696    pub database_schema_version: i64,
1697    /// Sessions whose metadata and attributable human-authored text entered the analysis.
1698    pub selected_sessions: u64,
1699    /// All-role message rows belonging to the selected sessions.
1700    pub messages_in_selected_sessions: u64,
1701    /// Original messages with attributable human-authored text that entered analysis.
1702    pub analyzed_user_messages: u64,
1703    /// Whether the named bounded selection left at least one matching session unselected.
1704    pub has_more: bool,
1705    /// Last selected canonical session ID, or `None` for an empty selection.
1706    pub last_selected_session_id: Option<String>,
1707    /// Greatest selected session update/create timestamp, or `None` when unavailable.
1708    pub max_selected_session_updated_at: Option<String>,
1709    /// Identity of normalized compiled policy semantics.
1710    pub policy_digest: String,
1711    /// Identity of selected, ordered session metadata and attributable human-authored input.
1712    pub corpus_digest: String,
1713    /// Identity of the canonical in-memory [`AnalysisResult`](crate::AnalysisResult).
1714    pub result_digest: String,
1715}
1716
1717/// Pure analysis result paired with the exact selection, snapshot input, policy, and result facts.
1718#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1719pub struct ReceiptedAnalysis {
1720    /// Canonical classifications, relationships, vocabulary, and graph inputs.
1721    pub result: crate::analysis_pipeline::AnalysisResult,
1722    /// Same-snapshot selection, count, and digest facts for `result`.
1723    pub receipt: AnalysisReceipt,
1724}
1725
1726const DEFAULT_ANALYSIS_SESSION_BATCH_SIZE: std::num::NonZeroUsize =
1727    std::num::NonZeroUsize::new(50).expect("analysis session batch constant is nonzero");
1728
1729impl<'app> AnalysisService<'app> {
1730    /// Create an analysis service with configuration-backed capability and planning policy.
1731    pub const fn new(config: &'app Config, db: &'app Db) -> Self {
1732        Self { config, db }
1733    }
1734
1735    pub(crate) fn corrections_with_resolved_policies(
1736        &self,
1737        filters: &crate::models::MessageFilters,
1738        policies: &crate::corrections::ResolvedCorrectionPolicySet,
1739    ) -> Result<crate::corrections::MessageClassificationReport> {
1740        let matches = self.db.find_corrections(policies, filters)?;
1741        Ok(crate::corrections::MessageClassificationReport {
1742            policies: policies.receipts(),
1743            matches,
1744        })
1745    }
1746
1747    /// Resolve and execute one read-only deterministic skill capability.
1748    ///
1749    /// For `K` selected packages and `N` catalog entries, descriptor lookup is `O(K * N)`;
1750    /// canonical duplicate detection is expected `O(K)`. Only selected capability documents are
1751    /// compiled, under one aggregate byte budget. Message classification then uses the existing
1752    /// indexed correction query exactly once.
1753    pub fn run_skill(
1754        &self,
1755        query: &crate::skill_run::SkillRunQuery,
1756    ) -> Result<crate::skill_run::SkillRunReport> {
1757        let crate::skill_run::SkillCapabilityInput::MessageClassification(arguments) = &query.input;
1758        let embedded_primary = matches!(
1759            &query.skill,
1760            crate::skill_catalog::SkillSelector::Name(selector)
1761                if selector.name.as_str() == crate::corrections::EMBEDDED_POLICY_NAME
1762        );
1763        let roots = || {
1764            self.config
1765                .skills
1766                .search_paths
1767                .iter()
1768                .map(|root| crate::util::expand_tilde(root))
1769                .collect::<Vec<_>>()
1770        };
1771
1772        let (resolved_skill, policies) = if embedded_primary {
1773            if arguments.additional_skills.iter().any(|selector| {
1774                matches!(
1775                    selector,
1776                    crate::skill_catalog::SkillSelector::Name(selector)
1777                        if selector.name.as_str() == crate::corrections::EMBEDDED_POLICY_NAME
1778                )
1779            }) {
1780                bail!("skill \"corrections\" was selected more than once");
1781            }
1782            let mut capability_budget = crate::message_classification::CapabilityLoadBudget::new();
1783            let mut compiled = vec![match &query.definition {
1784                Some(definition) => crate::message_classification::compile_inline_definition(
1785                    definition.clone(),
1786                    crate::corrections::EMBEDDED_POLICY_NAME.to_string(),
1787                    env!("CARGO_PKG_VERSION").to_string(),
1788                    &mut capability_budget,
1789                )?,
1790                None => crate::corrections::embedded_policy()?,
1791            }];
1792            if !arguments.additional_skills.is_empty() {
1793                let catalog = crate::skill_catalog::load_skill_catalog(&roots());
1794                let descriptors = crate::skill_catalog::resolve_skill_selectors(
1795                    &arguments.additional_skills,
1796                    &catalog,
1797                )?;
1798                if descriptors.iter().any(|descriptor| {
1799                    descriptor.frontmatter.as_ref().is_some_and(|frontmatter| {
1800                        frontmatter.name == crate::corrections::EMBEDDED_POLICY_NAME
1801                    })
1802                }) {
1803                    bail!(
1804                        "the embedded corrections skill and an installed corrections package \
1805                         identify the same capability; remove the duplicate --skill selector"
1806                    );
1807                }
1808                let additional = crate::message_classification::compile_skill_descriptors(
1809                    descriptors,
1810                    &mut capability_budget,
1811                )?;
1812                compiled.extend(additional.policies().iter().cloned());
1813            }
1814            (
1815                crate::skill_run::ResolvedSkillReceipt {
1816                    name: crate::skill_catalog::SkillName::try_from(
1817                        crate::corrections::EMBEDDED_POLICY_NAME.to_string(),
1818                    )
1819                    .map_err(anyhow::Error::msg)?,
1820                    package_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1821                    selected_location: crate::skill_run::SelectedSkillLocation::Embedded,
1822                    execution_source: if query.definition.is_some() {
1823                        crate::skill_run::CapabilityExecutionSource::Inline
1824                    } else {
1825                        crate::skill_run::CapabilityExecutionSource::Embedded
1826                    },
1827                },
1828                crate::corrections::ResolvedCorrectionPolicySet::from_policies(compiled),
1829            )
1830        } else {
1831            let catalog = crate::skill_catalog::load_skill_catalog(&roots());
1832            let selectors = std::iter::once(query.skill.clone())
1833                .chain(arguments.additional_skills.iter().cloned())
1834                .collect::<Vec<_>>();
1835            let descriptors = crate::skill_catalog::resolve_skill_selectors(&selectors, &catalog)?;
1836            let primary = descriptors
1837                .first()
1838                .context("a skill run requires one primary resolved skill")?;
1839            let frontmatter = primary
1840                .frontmatter
1841                .as_ref()
1842                .context("resolved skill has no valid frontmatter")?;
1843            let package_version = frontmatter
1844                .metadata
1845                .get("version")
1846                .cloned()
1847                .context("runnable skill must declare metadata.version in SKILL.md")?;
1848            let resolved_skill = crate::skill_run::ResolvedSkillReceipt {
1849                name: crate::skill_catalog::SkillName::try_from(frontmatter.name.clone())
1850                    .map_err(anyhow::Error::msg)?,
1851                package_version: Some(package_version.clone()),
1852                selected_location: crate::skill_run::SelectedSkillLocation::Path {
1853                    canonical_skill_md: primary.root.join("SKILL.md"),
1854                },
1855                execution_source: if query.definition.is_some() {
1856                    crate::skill_run::CapabilityExecutionSource::Inline
1857                } else {
1858                    crate::skill_run::CapabilityExecutionSource::Path {
1859                        canonical_capability_toml: primary
1860                            .capability
1861                            .clone()
1862                            .require_path(&frontmatter.name)?,
1863                    }
1864                },
1865            };
1866            let policies = match &query.definition {
1867                None => crate::message_classification::compile_skill_descriptors(
1868                    descriptors,
1869                    &mut crate::message_classification::CapabilityLoadBudget::new(),
1870                )?,
1871                Some(definition) => {
1872                    let mut capability_budget =
1873                        crate::message_classification::CapabilityLoadBudget::new();
1874                    let mut compiled =
1875                        vec![crate::message_classification::compile_inline_definition(
1876                            definition.clone(),
1877                            frontmatter.name.clone(),
1878                            package_version,
1879                            &mut capability_budget,
1880                        )?];
1881                    let additional = crate::message_classification::compile_skill_descriptors(
1882                        descriptors.into_iter().skip(1).collect(),
1883                        &mut capability_budget,
1884                    )?;
1885                    compiled.extend(additional.policies().iter().cloned());
1886                    crate::corrections::ResolvedCorrectionPolicySet::from_policies(compiled)
1887                }
1888            };
1889            (resolved_skill, policies)
1890        };
1891        let report = self.corrections_with_resolved_policies(&arguments.filters, &policies)?;
1892        let receipt = report
1893            .policies
1894            .first()
1895            .cloned()
1896            .context("a primary capability produced no policy receipt")?;
1897        Ok(crate::skill_run::SkillRunReport {
1898            requested_selector: query.skill.clone(),
1899            resolved_skill,
1900            output: crate::skill_run::SkillCapabilityOutput::MessageClassification(
1901                crate::skill_run::MessageClassificationResult { receipt, report },
1902            ),
1903        })
1904    }
1905
1906    /// Aggregate slash-command usage with configured and request-specific token patterns.
1907    ///
1908    /// # Errors
1909    ///
1910    /// Returns an error when a configured/request pattern is invalid or the index query fails.
1911    ///
1912    /// # Complexity
1913    ///
1914    /// Time is proportional to filtered user-message rows times the combined command patterns.
1915    /// Memory is proportional to distinct matched commands, sessions, and projects.
1916    pub fn planning(
1917        &self,
1918        filters: &MessageFilters,
1919        command_patterns: &[String],
1920    ) -> Result<Vec<crate::models::PlanningCount>> {
1921        let command_filters =
1922            crate::analytics::compile_planning_filters(self.config, command_patterns)?;
1923        self.db.planning_usage(filters, &command_filters)
1924    }
1925
1926    /// Count indexed messages by normalized role, ordered by role.
1927    ///
1928    /// # Errors
1929    ///
1930    /// Returns an error when the index query fails.
1931    pub fn role_statistics(
1932        &self,
1933        filters: &MessageFilters,
1934    ) -> Result<Vec<crate::analytics::RoleStat>> {
1935        let rows: Vec<_> = self
1936            .db
1937            .message_role_counts(filters)?
1938            .into_iter()
1939            .map(|(role, count)| crate::analytics::RoleStat { role, count })
1940            .collect();
1941        let limit = if filters.limit == 0 {
1942            usize::MAX
1943        } else {
1944            filters.limit
1945        };
1946        Ok(rows.into_iter().skip(filters.offset).take(limit).collect())
1947    }
1948
1949    /// Return one bounded keyset page of provider-normalized session text for outward analysis.
1950    ///
1951    /// # Complexity
1952    ///
1953    /// Time and returned memory are proportional to the page's selected sessions plus their joined
1954    /// attributable human-authored text. Keyset traversal avoids work proportional to prior page
1955    /// offsets.
1956    pub fn documents(
1957        &self,
1958        filters: &SearchFilters,
1959        cursor: Option<&crate::models::AnalysisCursor>,
1960    ) -> Result<crate::models::AnalysisDocumentPage> {
1961        self.db.analysis_documents(filters, cursor)
1962    }
1963
1964    /// Run a compiled provider-neutral policy over one snapshot of the indexed corpus.
1965    ///
1966    /// [`AnalysisRequest`](crate::models::AnalysisRequest) names either every eligible session or
1967    /// a reproducible canonical-session-ID prefix. Internal keyset traversal is automatic and
1968    /// does not alter analysis, receipts, or publication results. Attributable human-authored text
1969    /// streams per message, so memory is bounded by the policy's explicit bounds plus one message
1970    /// — a single session's aggregate user text is never materialized (except when a
1971    /// `user_text`/`any` classification rule runs without `max_classification_chars`).
1972    ///
1973    /// # Complexity
1974    ///
1975    /// Time is proportional to streamed human-authored bytes times applicable policy rules, plus
1976    /// phrase aggregation. Memory is bounded by policy limits plus one message, except an
1977    /// explicitly unbounded `user_text`/`any` classification rule retains its matched text.
1978    pub fn run(
1979        &self,
1980        request: &crate::models::AnalysisRequest,
1981        policy: &crate::analysis_pipeline::AnalysisPolicy,
1982    ) -> Result<ReceiptedAnalysis> {
1983        self.run_with_session_batch_size(request, DEFAULT_ANALYSIS_SESSION_BATCH_SIZE, policy)
1984    }
1985
1986    /// Run with an explicit internal session batch size for invariant tests and internal tuning.
1987    /// This value controls database traversal only and must never change returned results.
1988    pub(crate) fn run_with_session_batch_size(
1989        &self,
1990        request: &crate::models::AnalysisRequest,
1991        session_batch_size: std::num::NonZeroUsize,
1992        policy: &crate::analysis_pipeline::AnalysisPolicy,
1993    ) -> Result<ReceiptedAnalysis> {
1994        let mut accumulator = policy.accumulator();
1995        let traversal = self.db.visit_analysis_sessions(
1996            request,
1997            session_batch_size,
1998            |session, message_count, user_message_count, chunks| {
1999                let mut content_chunks = chunks.map(|chunk| chunk.map(|(_seq, content)| content));
2000                accumulator.push_session_text_stream(
2001                    session,
2002                    message_count,
2003                    user_message_count,
2004                    &mut content_chunks,
2005                )
2006            },
2007        )?;
2008        let result = accumulator.finish()?;
2009        let receipt = AnalysisReceipt {
2010            selection: request.selection(),
2011            database_schema_version: crate::db::SCHEMA_VERSION,
2012            selected_sessions: traversal.selected_sessions,
2013            messages_in_selected_sessions: traversal.messages_in_selected_sessions,
2014            analyzed_user_messages: traversal.analyzed_user_messages,
2015            has_more: traversal.has_more,
2016            last_selected_session_id: traversal.last_selected_session_id,
2017            max_selected_session_updated_at: traversal
2018                .max_selected_session_updated_at
2019                .map(|value| value.to_rfc3339()),
2020            policy_digest: policy.policy_digest(),
2021            corpus_digest: traversal.corpus_digest,
2022            result_digest: result.result_digest(),
2023        };
2024        Ok(ReceiptedAnalysis { result, receipt })
2025    }
2026}
2027
2028/// Read-only session rendering over the shared catalog database.
2029#[derive(Clone, Copy)]
2030pub struct ExportService<'db> {
2031    db: &'db Db,
2032}
2033
2034impl<'db> ExportService<'db> {
2035    /// Create an export service over an existing database connection.
2036    pub const fn new(db: &'db Db) -> Self {
2037        Self { db }
2038    }
2039
2040    /// Resolve an exact session ID or unambiguous prefix and render its complete transcript.
2041    ///
2042    /// The returned document is held in memory and performs no terminal or filesystem I/O.
2043    /// Resolution errors include candidate context for ambiguous prefixes.
2044    ///
2045    /// # Errors
2046    ///
2047    /// Returns an error when the identifier is missing or ambiguous, database access fails, or
2048    /// structured serialization fails.
2049    pub fn render_full(
2050        &self,
2051        id_or_prefix: &str,
2052        format: crate::export::ExportFormat,
2053    ) -> Result<crate::export::ExportDocument> {
2054        let session = self.db.resolve_session(id_or_prefix)?;
2055        crate::export::render_full(&session, format)
2056    }
2057
2058    /// Select sessions and stream their rendered documents into one immutable bundle.
2059    ///
2060    /// This retains no full-corpus transcript collection: memory is bounded by the selected
2061    /// session metadata plus the largest individual rendered document.
2062    /// Time and output bytes are proportional to the selected transcripts; `filters.limit = 0`
2063    /// intentionally selects the complete filtered corpus.
2064    pub fn publish_bundle(
2065        &self,
2066        filters: &SearchFilters,
2067        plan: &crate::export::ExportPublicationPlan,
2068    ) -> Result<crate::export::ExportPublicationReceipt> {
2069        let sessions = self.db.list_recent(filters)?;
2070        let format = plan.format();
2071        let documents = sessions.into_iter().map(|session| {
2072            self.render_full(&session.id, format)
2073                .map(|document| (session.id, document))
2074        });
2075        plan.publish(documents)
2076    }
2077}
2078
2079#[derive(Clone, Copy)]
2080/// Read-only discovery operations bound to a validated configuration.
2081pub struct SourceService<'config> {
2082    config: &'config Config,
2083}
2084
2085impl<'config> SourceService<'config> {
2086    /// Create a source service over `config`.
2087    pub const fn new(config: &'config Config) -> Self {
2088        Self { config }
2089    }
2090
2091    /// Discover enabled sources and return status for every supported provider.
2092    pub fn inventory(&self) -> Vec<crate::source::ProviderSourceStatus> {
2093        crate::source::inventory(self.config)
2094    }
2095}
2096
2097/// Measurements from one completed compaction transaction.
2098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2099pub struct CompactOutcome {
2100    pub before_bytes: u64,
2101    pub after_bytes: u64,
2102}
2103
2104impl CompactOutcome {
2105    pub const fn reclaimed_bytes(self) -> u64 {
2106        self.before_bytes.saturating_sub(self.after_bytes)
2107    }
2108}
2109
2110/// Diagnostics and writer-exclusive index maintenance.
2111#[derive(Clone, Copy)]
2112pub struct MaintenanceService<'app> {
2113    config: &'app Config,
2114    db: &'app Db,
2115}
2116
2117impl<'app> MaintenanceService<'app> {
2118    pub const fn new(config: &'app Config, db: &'app Db) -> Self {
2119        Self { config, db }
2120    }
2121
2122    pub fn diagnostics(&self) -> Result<DiagnosticStatus> {
2123        crate::diagnostics::collect(self.config, self.db)
2124    }
2125
2126    /// Merge FTS segments, rebuild the database, and checkpoint the WAL while
2127    /// holding the same process lock used by index writers.
2128    pub fn compact(&self) -> Result<CompactOutcome> {
2129        indexer::with_index_update_lock(self.config, || {
2130            let before_bytes = self.db.storage_allocation()?.total_bytes;
2131            self.db.optimize_fts()?;
2132            self.db.vacuum()?;
2133            self.db.checkpoint_truncate()?;
2134            Ok(CompactOutcome {
2135                before_bytes,
2136                after_bytes: self.db.storage_allocation()?.total_bytes,
2137            })
2138        })
2139    }
2140}
2141
2142#[derive(Clone, Copy)]
2143/// Explicit refresh, reindex, and status operations sharing the application's elected writer.
2144pub struct IndexService<'app> {
2145    config: &'app Config,
2146    db: &'app Db,
2147}
2148
2149impl<'app> IndexService<'app> {
2150    pub const fn new(config: &'app Config, db: &'app Db) -> Self {
2151        Self { config, db }
2152    }
2153
2154    pub fn refresh(&self) -> Result<AutoReindexOutcome> {
2155        indexer::refresh_index_opportunistically(self.config, self.db, None)
2156    }
2157
2158    pub fn reindex(&self, full: bool) -> Result<(usize, usize)> {
2159        let outcome = self.reindex_report(full)?;
2160        Ok((outcome.files_seen, outcome.sessions_updated))
2161    }
2162
2163    pub fn reindex_report(&self, full: bool) -> Result<indexer::ExplicitReindexOutcome> {
2164        indexer::explicit_reindex_and_migrate(self.config, self.db, full, None)
2165    }
2166
2167    /// Report parser/schema freshness and only repairs applicable to discoverable sources.
2168    pub fn status(&self) -> Result<IndexStatus> {
2169        crate::diagnostics::index_status(self.config, self.db)
2170    }
2171}
2172
2173#[derive(Clone, Copy)]
2174/// Typed session listing, search, resolution, and compact inspection operations.
2175pub struct CatalogService<'db> {
2176    db: &'db Db,
2177}
2178
2179impl<'db> CatalogService<'db> {
2180    pub const fn new(db: &'db Db) -> Self {
2181        Self { db }
2182    }
2183
2184    /// List filtered sessions in database order.
2185    ///
2186    /// # Complexity
2187    ///
2188    /// Current list paging uses SQL `OFFSET`: with `S` rows considered, `N` eligible rows,
2189    /// offset `O`, and positive limit `K`, favorable indexed work is `O(log S + O + K)`; a
2190    /// planner-selected temporary sort is conservatively `O(S log S + O + K)`. Session-span
2191    /// overlap adds `O(S)` constant-time comparisons and no message scan. Returned memory is
2192    /// `O(K + D_K)` for selected row/text bytes. A zero limit intentionally returns the complete
2193    /// filtered corpus, so `K=N`; SQLite page-cache or temporary-sort memory is additional.
2194    pub fn list_sessions(&self, filters: &SearchFilters) -> Result<Vec<SessionRecord>> {
2195        filters.validate()?;
2196        self.db.list_recent(filters)
2197    }
2198
2199    /// List a numeric-offset page in the same `(updated_at DESC, id ASC)` order as
2200    /// [`CatalogService::list_sessions`]. Intended for bounded protocol adapters and has the same
2201    /// time/I/O bounds; application-retained output is `O(K + D_K)` for this page.
2202    pub fn list_sessions_page(
2203        &self,
2204        filters: &SearchFilters,
2205        offset: usize,
2206    ) -> Result<Vec<SessionRecord>> {
2207        filters.validate()?;
2208        self.db.list_recent_page(filters, offset)
2209    }
2210
2211    /// Search all structurally eligible session fields with configured relevance scoring.
2212    ///
2213    /// # Complexity
2214    ///
2215    /// Let `S` be session rows considered, `B` total eligible field/transcript bytes, `N`
2216    /// eligible sessions, `K` a positive result limit, `D_K` bytes retained in the top-K records,
2217    /// and `D_max` the largest current
2218    /// record plus transcript, `A` query tokens, and `P` configured workers. Batched scoring plus
2219    /// amortized linear top-K compaction does `O(B*(A+1) + N + K log K)` aggregate work; the final
2220    /// term is the retained-page sort. Peak processing memory is
2221    /// `O(K + D_K + batch_budget + D_max)`. Session filtering adds an `O(S)` scan bound but
2222    /// rejects non-overlapping rows before transcript scoring/materialization. Ideal scoring latency is `O(B*(A+1)/P + N/P)` plus
2223    /// serial SQLite traversal, batch joins, retained-set compaction, and final `O(K log K)` sort.
2224    /// A zero limit intentionally retains every match, increasing memory to `O(N + B)` and final
2225    /// sorting to `O(N log N)`; parallel scoring does not change result membership or rank.
2226    pub fn search_sessions(
2227        &self,
2228        query: &str,
2229        filters: &SearchFilters,
2230        current_repo: Option<&str>,
2231        scoring: &ScoringConfig,
2232    ) -> Result<Vec<SearchHit>> {
2233        filters.validate()?;
2234        self.db.search(query, filters, current_repo, scoring)
2235    }
2236
2237    /// Crate-internal session-search path with cooperative cancellation between scoring batches
2238    /// and before retained-set compaction/sort. Public callers keep the stable method above.
2239    pub(crate) fn search_sessions_cancellable(
2240        &self,
2241        query: &str,
2242        filters: &SearchFilters,
2243        current_repo: Option<&str>,
2244        scoring: &ScoringConfig,
2245        cancellation: &QueryCancellation,
2246    ) -> Result<Vec<SearchHit>> {
2247        filters.validate()?;
2248        self.db
2249            .search_cancellable(query, filters, current_repo, scoring, cancellation)
2250    }
2251
2252    /// Resolve one canonical session ID or unique prefix; ambiguous errors list candidates.
2253    pub fn resolve_session(&self, id_or_prefix: &str) -> Result<SessionRecord> {
2254        self.db.resolve_session_record(id_or_prefix)
2255    }
2256
2257    /// Build a bounded evidence summary for one resolved session without filesystem writes.
2258    pub fn inspect(
2259        &self,
2260        id_or_prefix: &str,
2261        options: crate::inspect::InspectionOptions,
2262    ) -> Result<crate::inspect::SessionInspection> {
2263        crate::inspect::inspect_session(self.db, id_or_prefix, options)
2264    }
2265}
2266
2267#[derive(Clone, Copy)]
2268/// Canonical message search, explain, metadata, and context operations used by all adapters.
2269pub struct MessageService<'db> {
2270    config: &'db Config,
2271    db: &'db Db,
2272    surface: SearchSurface,
2273}
2274
2275#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2276pub(crate) enum MessageSearchBatchControl {
2277    Continue,
2278    Stop,
2279}
2280
2281/// Terminal state from a batched traversal. `page` and `ordered_digest` exist only after natural
2282/// exhaustion; a stopped consumer has not received a complete requested result set.
2283pub(crate) struct MessageSearchBatchVisitOutcome {
2284    pub(crate) exhausted: bool,
2285    pub(crate) page: Option<PageInfo>,
2286    pub(crate) planner: Option<SearchExplain>,
2287    pub(crate) origins: Option<MessageSearchOrigins>,
2288    pub(crate) ordered_digest: Option<String>,
2289}
2290
2291impl<'db> MessageService<'db> {
2292    pub const fn new(config: &'db Config, db: &'db Db, surface: SearchSurface) -> Self {
2293        Self {
2294            config,
2295            db,
2296            surface,
2297        }
2298    }
2299
2300    /// Describe the final input catalogue and this service's configured queryless/content defaults.
2301    ///
2302    /// The configured overlay is resolved by [`Self::plan`], not by a parallel documentation
2303    /// algorithm. It performs no session scan or message query and retains only one small request.
2304    pub fn message_search_spec(&self) -> Result<MessageSearchSpecification> {
2305        Self::message_search_spec_for_config(self.config, self.surface)
2306    }
2307
2308    /// Resolve configured defaults without opening or querying an index.
2309    ///
2310    /// MCP tool discovery uses this path so initialize/tools-list remain available while a fresh
2311    /// index is missing or being prepared. The canonical default request has no session selector;
2312    /// reaching the resolver is therefore an internal contract error rather than a database read.
2313    pub(crate) fn message_search_spec_for_config(
2314        config: &Config,
2315        surface: SearchSurface,
2316    ) -> Result<MessageSearchSpecification> {
2317        let request = MessageSearchRequest::builder(
2318            crate::message_search::MessageQuery::All,
2319            MessageTarget::content(),
2320        )
2321        .build()?;
2322        let plan = Self::plan_with_session_resolver(config, surface, request, |_| {
2323            bail!(
2324                "configured message-search specification unexpectedly requires session resolution"
2325            )
2326        })?;
2327        Ok(MessageSearchSpecification::new(
2328            ResolvedMessageSearchRequest::from_plan(&plan)?,
2329        ))
2330    }
2331
2332    /// Open exhaustive batches using this service's configuration, access scope, and surface.
2333    ///
2334    /// This is the adapter seam for CLI and other in-process consumers that already own a
2335    /// configured database handle. It preserves the same access authority on the dedicated
2336    /// read-only snapshot instead of reopening an unrestricted application.
2337    pub(crate) fn search_batches(
2338        &self,
2339        request: MessageSearchRequest,
2340        batch_rows: NonZeroUsize,
2341    ) -> Result<MessageSearchBatches> {
2342        MessageSearchBatches::spawn(
2343            self.config.clone(),
2344            self.db.access_scope().clone(),
2345            self.surface,
2346            request,
2347            batch_rows,
2348        )
2349    }
2350
2351    /// Search individual messages using the selected exact, fuzzy, or regex mode.
2352    ///
2353    /// # Complexity
2354    ///
2355    /// Let `F` be structurally filtered rows, `C <= F` safe-trigram candidates, `B_F`/`B_C`
2356    /// their selected-field bytes, `Q` query length, `P_Q` total postings entries read for the
2357    /// required grams, and `K` the requested page window. Schema-v4 content and tool-argument
2358    /// literal/regex searches do `O(Q + P_Q + C + B_C)` work including authoritative
2359    /// verification; worst case remains `O(F + B_F)` when no safe/selective anchor exists.
2360    /// Tool-argument candidates come from raw JSON, then RFC 6901 projection remains authoritative.
2361    /// Peak Rust result memory is `O(K + bytes(K))` for finite non-fuzzy pages and `O(F + B_F)`
2362    /// when `limit == 0`; SQLite's implementation-owned statement/postings state is conservatively
2363    /// `O(P_Q + C)`. These paths are serial, so latency has the same critical-path bound as work.
2364    /// Fuzzy search scores every filtered row with parallel batches, does
2365    /// `O(B_F + F + W log W)` aggregate work for retained window `W=offset+limit`, and has ideal
2366    /// scoring latency `O(B_F/P + W log W)` over `P` workers plus serial SQLite traversal and
2367    /// scheduling overhead. Content and derived-field fuzzy paths both use bounded parallel
2368    /// batches; tool-argument fuzzy projection remains exhaustive but parallelizes JSON parsing
2369    /// and scoring. Peak Rust memory is `O(W + bytes(W) + batch_budget + largest_row + P*Q)`;
2370    /// validation rejects an unbounded fuzzy page.
2371    pub fn search_legacy(&self, query: &str, filters: &MessageFilters) -> Result<Vec<MessageHit>> {
2372        self.db.search_messages(query, filters)
2373    }
2374
2375    pub fn plan(&self, request: MessageSearchRequest) -> Result<MessageSearchPlan> {
2376        Self::plan_with_session_resolver(self.config, self.surface, request, |value| {
2377            self.catalog().resolve_session(value)
2378        })
2379    }
2380
2381    fn plan_with_session_resolver(
2382        config: &Config,
2383        surface: SearchSurface,
2384        request: MessageSearchRequest,
2385        resolve_session: impl FnOnce(&str) -> Result<SessionRecord>,
2386    ) -> Result<MessageSearchPlan> {
2387        let purpose = request
2388            .purpose()
2389            .map(|selection| -> Result<_> {
2390                let definition = config
2391                    .search
2392                    .purposes
2393                    .get(selection.name())
2394                    .ok_or_else(|| MessageSearchError::InvalidParameter {
2395                        parameter: "purpose",
2396                        reason: format!("unknown message-search purpose {:?}", selection.name()),
2397                    })?;
2398                if selection
2399                    .version()
2400                    .is_some_and(|version| version != definition.version)
2401                {
2402                    return Err(MessageSearchError::InvalidParameter {
2403                        parameter: "purpose_version",
2404                        reason: format!(
2405                            "purpose {:?} version {} is unavailable; configured version is {}",
2406                            selection.name(),
2407                            selection.version().unwrap(),
2408                            definition.version
2409                        ),
2410                    }
2411                    .into());
2412                }
2413                Ok((selection.name().to_string(), definition))
2414            })
2415            .transpose()?;
2416        let purpose_origin = || {
2417            purpose
2418                .as_ref()
2419                .map(|(name, definition)| ValueOrigin::Purpose {
2420                    name: name.clone(),
2421                    version: definition.version,
2422                })
2423        };
2424        let purpose_preferences = purpose
2425            .as_ref()
2426            .map(|(_, definition)| &definition.preferences);
2427
2428        let (requested_limit, offset, explicit_all) = match request.extent() {
2429            crate::message_search::RequestedExtent::Page { limit, offset } => {
2430                (limit, offset, false)
2431            }
2432            crate::message_search::RequestedExtent::AllResults { offset } => (None, offset, true),
2433        };
2434        let (limit, limit_origin) = if explicit_all {
2435            (None, ValueOrigin::Explicit)
2436        } else if let Some(limit) = requested_limit {
2437            (Some(limit), ValueOrigin::Explicit)
2438        } else if let Some(limit) = purpose_preferences.and_then(|value| value.default_limit) {
2439            (Some(limit), purpose_origin().unwrap())
2440        } else if let Some(limit) = config.search.message_search.default_limit {
2441            (Some(limit), ValueOrigin::OperationConfig)
2442        } else {
2443            let surface_limit = match surface {
2444                SearchSurface::Mcp => NonZeroUsize::new(config.mcp.search_messages_limit),
2445                // Native programmatic/interactive surfaces preserve the complete selected corpus
2446                // when no operation/purpose/call limit was supplied. MCP alone supplies an
2447                // implicit finite page because its response is injected directly into model
2448                // context. Fuzzy validation below still rejects an unbounded resolved extent.
2449                SearchSurface::Rust | SearchSurface::Cli | SearchSurface::Python => None,
2450            };
2451            match surface_limit {
2452                Some(limit) => (Some(limit), ValueOrigin::SurfaceConfig { surface }),
2453                None => (None, ValueOrigin::TypedDefault),
2454            }
2455        };
2456        if let (Some(current), Some(maximum)) = (limit, config.search.budgets.max_hits_per_page) {
2457            if current > maximum {
2458                return Err(MessageSearchError::InvalidParameter {
2459                    parameter: "limit",
2460                    reason: format!(
2461                        "resolved message-search limit {} exceeds search.budgets.max_hits_per_page {}; lower the request, purpose, operation default, or MCP default",
2462                        current, maximum
2463                    ),
2464                }
2465                .into());
2466            }
2467        }
2468        let extent = if explicit_all {
2469            ResolvedExtent::AllResults { offset }
2470        } else if let Some(limit) = limit {
2471            ResolvedExtent::Page { limit, offset }
2472        } else if matches!(
2473            request.query(),
2474            crate::message_search::MessageQuery::Fuzzy(_)
2475        ) {
2476            return Err(MessageSearchError::InvalidParameter {
2477                parameter: "limit",
2478                reason:
2479                    "fuzzy search requires a positive page size from the request or configuration"
2480                        .into(),
2481            }
2482            .into());
2483        } else {
2484            ResolvedExtent::AllResults { offset }
2485        };
2486
2487        let (context, context_before_origin, context_after_origin) =
2488            if let Some(context) = request.context() {
2489                (context, ValueOrigin::Explicit, ValueOrigin::Explicit)
2490            } else {
2491                let (before, before_origin) = if let Some(value) =
2492                    purpose_preferences.and_then(|preferences| preferences.context_before)
2493                {
2494                    (value, purpose_origin().unwrap())
2495                } else if let Some(value) = config.search.message_search.context.context_before {
2496                    (value, ValueOrigin::OperationConfig)
2497                } else {
2498                    (0, ValueOrigin::TypedDefault)
2499                };
2500                let (after, after_origin) = if let Some(value) =
2501                    purpose_preferences.and_then(|preferences| preferences.context_after)
2502                {
2503                    (value, purpose_origin().unwrap())
2504                } else if let Some(value) = config.search.message_search.context.context_after {
2505                    (value, ValueOrigin::OperationConfig)
2506                } else {
2507                    (0, ValueOrigin::TypedDefault)
2508                };
2509                (
2510                    ContextWindow::new(before, after),
2511                    before_origin,
2512                    after_origin,
2513                )
2514            };
2515        if let Some(maximum) = config.search.budgets.max_context_neighbors_per_hit {
2516            let total = context
2517                .messages_before()
2518                .checked_add(context.messages_after())
2519                .ok_or_else(|| MessageSearchError::InvalidParameter {
2520                    parameter: "context",
2521                    reason: "resolved message-search context total overflows".to_string(),
2522                })?;
2523            if total > maximum.get() {
2524                return Err(MessageSearchError::InvalidParameter {
2525                    parameter: "context",
2526                    reason: format!(
2527                        "resolved message-search context total {} exceeds search.budgets.max_context_neighbors_per_hit {}; lower context_before or context_after",
2528                        total, maximum
2529                    ),
2530                }
2531                .into());
2532            }
2533        }
2534
2535        let (includes, includes_origin) = if let Some(includes) = request.includes() {
2536            (includes.to_vec(), ValueOrigin::Explicit)
2537        } else if let Some(value) =
2538            purpose_preferences.and_then(|preferences| preferences.include_refs)
2539        {
2540            (
2541                value
2542                    .then_some(MessageSearchInclude::ParsedReferences)
2543                    .into_iter()
2544                    .collect(),
2545                purpose_origin().unwrap(),
2546            )
2547        } else if surface == SearchSurface::Mcp {
2548            (
2549                vec![MessageSearchInclude::NormalizedSessionMetadata],
2550                ValueOrigin::SurfaceConfig { surface },
2551            )
2552        } else {
2553            (Vec::new(), ValueOrigin::TypedDefault)
2554        };
2555        let include_refs = includes.contains(&MessageSearchInclude::ParsedReferences);
2556        let detail = request.presentation().detail();
2557        let detail_origin = if detail.is_some() {
2558            ValueOrigin::Explicit
2559        } else {
2560            ValueOrigin::TypedDefault
2561        };
2562        let (message_lines, message_lines_origin) =
2563            if let Some(value) = request.presentation().message_lines() {
2564                (value, ValueOrigin::Explicit)
2565            } else if detail == Some(DetailLevel::Full) {
2566                (
2567                    LineWindow::Full,
2568                    ValueOrigin::DetailPreset {
2569                        detail: DetailLevel::Full,
2570                    },
2571                )
2572            } else if let Some(value) =
2573                purpose_preferences.and_then(|preferences| preferences.lines_per_message)
2574            {
2575                (LineWindow::from_signed(value)?, purpose_origin().unwrap())
2576            } else {
2577                let value = match surface {
2578                    SearchSurface::Cli => config.cli.lines_per_message,
2579                    SearchSurface::Mcp => config.mcp.lines_per_message,
2580                    SearchSurface::Rust | SearchSurface::Python => 0,
2581                };
2582                (
2583                    LineWindow::from_signed(value)?,
2584                    ValueOrigin::SurfaceConfig { surface },
2585                )
2586            };
2587        let (match_evidence_max_chars, match_evidence_max_chars_origin) = if let Some(value) =
2588            purpose_preferences.and_then(|preferences| preferences.match_evidence_max_chars)
2589        {
2590            (value, purpose_origin().unwrap())
2591        } else if let Some(value) = config.search.message_search.match_evidence_max_chars {
2592            (value, ValueOrigin::OperationConfig)
2593        } else {
2594            (
2595                NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
2596                    .expect("typed match evidence default is positive"),
2597                ValueOrigin::TypedDefault,
2598            )
2599        };
2600        let compact_boundary_chars = match surface {
2601            SearchSurface::Mcp => config.mcp.preview_chars,
2602            SearchSurface::Cli => config.cli.evidence_preview_chars,
2603            SearchSurface::Rust | SearchSurface::Python => DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
2604        };
2605        let (field_view, field_view_origin) =
2606            if let Some(value) = request.presentation().field_view() {
2607                (value, ValueOrigin::Explicit)
2608            } else {
2609                match detail {
2610                    Some(DetailLevel::Compact) => (
2611                        FieldViewBudget::MaxChars {
2612                            max_chars: NonZeroUsize::new(compact_boundary_chars)
2613                                .expect("validated presentation defaults are positive"),
2614                        },
2615                        ValueOrigin::DetailPreset {
2616                            detail: DetailLevel::Compact,
2617                        },
2618                    ),
2619                    Some(DetailLevel::Full) => (
2620                        FieldViewBudget::NoCharLimit,
2621                        ValueOrigin::DetailPreset {
2622                            detail: DetailLevel::Full,
2623                        },
2624                    ),
2625                    None if surface == SearchSurface::Mcp => (
2626                        FieldViewBudget::MaxChars {
2627                            max_chars: NonZeroUsize::new(config.mcp.preview_chars)
2628                                .expect("validated MCP preview_chars is positive"),
2629                        },
2630                        ValueOrigin::SurfaceConfig { surface },
2631                    ),
2632                    None => (FieldViewBudget::NoCharLimit, ValueOrigin::TypedDefault),
2633                }
2634            };
2635        let (match_view, match_view_origin) =
2636            if let Some(value) = request.presentation().match_view() {
2637                (value, ValueOrigin::Explicit)
2638            } else {
2639                match detail {
2640                    Some(DetailLevel::Full) => (
2641                        MatchViewBudget::MinimalSpan,
2642                        ValueOrigin::DetailPreset {
2643                            detail: DetailLevel::Full,
2644                        },
2645                    ),
2646                    Some(DetailLevel::Compact) => (
2647                        MatchViewBudget::MaxChars {
2648                            max_chars: match_evidence_max_chars,
2649                        },
2650                        ValueOrigin::DetailPreset {
2651                            detail: DetailLevel::Compact,
2652                        },
2653                    ),
2654                    None => (
2655                        MatchViewBudget::MaxChars {
2656                            max_chars: match_evidence_max_chars,
2657                        },
2658                        match_evidence_max_chars_origin.clone(),
2659                    ),
2660                }
2661            };
2662        let effective_match_evidence_max_chars = match match_view {
2663            MatchViewBudget::MinimalSpan => match_evidence_max_chars,
2664            MatchViewBudget::MaxChars { max_chars } => max_chars,
2665        };
2666        let (receipt, receipt_origin) = if let Some(value) = request.receipt_level() {
2667            (value, ValueOrigin::Explicit)
2668        } else if let Some(value) =
2669            purpose_preferences.and_then(|preferences| preferences.receipt_level)
2670        {
2671            (value, purpose_origin().unwrap())
2672        } else {
2673            (ReceiptLevel::None, ValueOrigin::TypedDefault)
2674        };
2675
2676        let predicates = request.predicates();
2677        let resolved_session = predicates.session().map(resolve_session).transpose()?;
2678        if let (Some(session), Some(providers)) = (&resolved_session, predicates.providers()) {
2679            if !providers.contains(&session.provider) {
2680                let selected = providers
2681                    .iter()
2682                    .map(|provider| provider.as_str())
2683                    .collect::<Vec<_>>()
2684                    .join(", ");
2685                return Err(MessageSearchError::Conflict(format!(
2686                    "session_id {:?} resolves to provider {}, outside providers=[{}]; include {} \
2687                     in providers or remove session_id",
2688                    predicates.session().unwrap_or_default(),
2689                    session.provider,
2690                    selected,
2691                    session.provider
2692                ))
2693                .into());
2694            }
2695        }
2696        let session_id = resolved_session.map(|session| session.id);
2697        let normalize = |value: &str| crate::util::normalize_path_prefix(value);
2698        let resolved_predicates = ResolvedMessagePredicates {
2699            role: predicates.role(),
2700            kinds: predicates.kinds().map(<[_]>::to_vec),
2701            providers: predicates.providers().map(<[_]>::to_vec),
2702            session_id,
2703            workspace_path_prefix: predicates.workspace_path_prefix().map(normalize),
2704            transcript_path_prefix: predicates.transcript_path_prefix().map(normalize),
2705            exclude_workspace_path_prefixes: predicates
2706                .exclude_workspace_path_prefixes()
2707                .map(normalize)
2708                .collect(),
2709            exclude_transcript_path_prefixes: predicates
2710                .exclude_transcript_path_prefixes()
2711                .map(normalize)
2712                .collect(),
2713            exclude_session_ids: predicates
2714                .exclude_session_ids()
2715                .map(str::to_string)
2716                .collect(),
2717            time: predicates.time(),
2718            sequence: predicates.sequence(),
2719            tool_name_contains: predicates.tool_name_contains().map(str::to_string),
2720            include_compaction: predicates.include_compaction(),
2721        };
2722        let ordering = if matches!(
2723            request.query(),
2724            crate::message_search::MessageQuery::Fuzzy(_)
2725        ) {
2726            ExecutionOrder::FuzzyRelevance
2727        } else {
2728            ExecutionOrder::SessionSequence
2729        };
2730        Ok(MessageSearchPlan {
2731            retrieval: MessageRetrievalPlan {
2732                query: request.query().clone(),
2733                target: request.target().clone(),
2734                predicates: resolved_predicates,
2735                match_window: request.match_window(),
2736                ordering,
2737                extent,
2738            },
2739            response: MessageResponsePlan {
2740                context,
2741                includes,
2742                presentation: ResolvedMessagePresentation {
2743                    include_refs,
2744                    message_lines,
2745                    match_evidence_max_chars: effective_match_evidence_max_chars,
2746                    detail,
2747                    field_view,
2748                    match_view,
2749                },
2750            },
2751            receipt,
2752            origins: MessageSearchOrigins {
2753                result_extent: limit_origin,
2754                context_messages_before: context_before_origin,
2755                context_messages_after: context_after_origin,
2756                includes: includes_origin,
2757                detail: detail_origin,
2758                lines_per_message: message_lines_origin,
2759                field_view: field_view_origin,
2760                match_view: match_view_origin,
2761                receipt_level: receipt_origin,
2762                result_order: ValueOrigin::Derived,
2763            },
2764        })
2765    }
2766
2767    /// Execute the canonical message-search request with surface-specific omitted-limit semantics.
2768    ///
2769    /// With no explicit, purpose, or operation limit, Rust/CLI/Python preserve every literal,
2770    /// regex, or no-text match; MCP uses its configured finite page. Fuzzy search always requires
2771    /// a finite resolved page. Presentation limits are resolved separately and never affect hit
2772    /// membership. Planning, membership, evidence, includes, and context share one SQLite read
2773    /// snapshot, so a concurrent WAL refresh becomes visible only to a later search.
2774    pub fn search(&self, request: MessageSearchRequest) -> Result<MessageSearchResponse> {
2775        self.search_cancellable(request, None)
2776    }
2777
2778    pub(crate) fn search_cancellable(
2779        &self,
2780        request: MessageSearchRequest,
2781        cancellation: Option<&AtomicBool>,
2782    ) -> Result<MessageSearchResponse> {
2783        self.db
2784            .with_read_snapshot(|| self.search_in_snapshot(request, cancellation, || {}))
2785    }
2786
2787    #[cfg(test)]
2788    fn search_with_after_retrieval_for_test(
2789        &self,
2790        request: MessageSearchRequest,
2791        after_retrieval: impl FnOnce(),
2792    ) -> Result<MessageSearchResponse> {
2793        self.db
2794            .with_read_snapshot(|| self.search_in_snapshot(request, None, after_retrieval))
2795    }
2796
2797    fn search_in_snapshot(
2798        &self,
2799        request: MessageSearchRequest,
2800        cancellation: Option<&AtomicBool>,
2801        after_retrieval: impl FnOnce(),
2802    ) -> Result<MessageSearchResponse> {
2803        ensure_message_search_active(cancellation)?;
2804        let plan = self.plan(request)?;
2805        let resolved_request = ResolvedMessageSearchRequest::from_plan(&plan)?;
2806        let include_explain = plan.receipt != ReceiptLevel::None;
2807        let (mut hits, planner) = self
2808            .db
2809            .search_message_plan(&plan.retrieval, include_explain)?;
2810        let (next_offset, extent) = match plan.retrieval.extent {
2811            ResolvedExtent::Page { limit, offset } => {
2812                let has_more = hits.len() > limit.get();
2813                hits.truncate(limit.get());
2814                let next_offset = if has_more {
2815                    Some(
2816                        offset
2817                            .checked_add(limit.get())
2818                            .ok_or_else(|| anyhow!("message page next offset overflows"))?,
2819                    )
2820                } else {
2821                    None
2822                };
2823                (next_offset, plan.retrieval.extent)
2824            }
2825            ResolvedExtent::AllResults { .. } => (None, plan.retrieval.extent),
2826        };
2827        if plan.retrieval.match_window == Some(MatchWindow::Latest) {
2828            hits.reverse();
2829        }
2830        after_retrieval();
2831        ensure_message_search_active(cancellation)?;
2832        let runtime_diagnostics = self.runtime_diagnostics_for_plan(&plan)?;
2833        let (hits, context_windows, included) =
2834            self.enrich_hits_cancellable(&plan, hits, cancellation, runtime_diagnostics, None)?;
2835        let origins = (plan.receipt == ReceiptLevel::Full).then(|| plan.origins.clone());
2836        let match_mode = plan.retrieval.query.mode();
2837        let match_details = match_mode.map(|mode| (plan.retrieval.target.clone(), mode));
2838        let response_query = plan.retrieval.query.text().map(str::to_owned);
2839        let returned = hits.len();
2840        Ok(
2841            MessageSearchResponse::new(crate::message_search::MessageSearchResponseParts {
2842                request: resolved_request,
2843                match_details,
2844                hits,
2845                context_windows,
2846                page: PageInfo::new(extent, returned, next_offset, plan.retrieval.ordering),
2847                response: plan.response,
2848                planner,
2849                origins,
2850                included,
2851            })
2852            .with_query(response_query),
2853        )
2854    }
2855
2856    /// Visit an exhaustive non-fuzzy request in fully enriched, bounded batches.
2857    ///
2858    /// This callback seam is the shared implementation for streaming adapters. The materialized
2859    /// [`MessageService::search`] API remains the simple default for ordinary callers. This method
2860    /// retains only one raw/enriched batch plus its active selected-field, context, include, and
2861    /// presentation bytes; stopping or returning an error drops the SQLite snapshot without
2862    /// draining unread results.
2863    #[cfg(test)]
2864    pub(crate) fn visit_search_batches(
2865        &self,
2866        request: MessageSearchRequest,
2867        batch_rows: NonZeroUsize,
2868        visitor: impl FnMut(MessageSearchBatch) -> Result<MessageSearchBatchControl>,
2869    ) -> Result<MessageSearchBatchVisitOutcome> {
2870        let plan = self.plan(request)?;
2871        self.visit_search_plan_batches(plan, batch_rows, None, visitor)
2872    }
2873
2874    pub(crate) fn visit_search_plan_batches(
2875        &self,
2876        plan: MessageSearchPlan,
2877        batch_rows: NonZeroUsize,
2878        cancellation: Option<&AtomicBool>,
2879        mut visitor: impl FnMut(MessageSearchBatch) -> Result<MessageSearchBatchControl>,
2880    ) -> Result<MessageSearchBatchVisitOutcome> {
2881        Self::validate_batch_plan(&plan)?;
2882        ensure_message_search_active(cancellation)?;
2883
2884        let include_explain = plan.receipt != ReceiptLevel::None;
2885        let match_mode = plan.retrieval.query.mode();
2886        let mut digest = MessageSearchOrderedDigest::new(plan.retrieval.target.clone(), match_mode);
2887        let mut emitted = 0_usize;
2888        let mut emitted_session_metadata = BTreeSet::new();
2889        let visit = self.db.visit_message_plan_batches(
2890            &plan.retrieval,
2891            include_explain,
2892            batch_rows,
2893            |raw_hits| {
2894                ensure_message_search_active(cancellation)?;
2895                let (results, context_windows, included) = self.enrich_hits_cancellable(
2896                    &plan,
2897                    raw_hits,
2898                    cancellation,
2899                    None,
2900                    Some(&mut emitted_session_metadata),
2901                )?;
2902                for result in &results {
2903                    digest.update(result);
2904                }
2905                emitted = emitted
2906                    .checked_add(results.len())
2907                    .ok_or_else(|| anyhow!("batched message-search emitted count overflows"))?;
2908                let control = visitor(MessageSearchBatch {
2909                    results,
2910                    context_windows,
2911                    included,
2912                })?;
2913                Ok(match control {
2914                    MessageSearchBatchControl::Continue => MessageBatchControl::Continue,
2915                    MessageSearchBatchControl::Stop => MessageBatchControl::Stop,
2916                })
2917            },
2918        )?;
2919        debug_assert_eq!(visit.rows_visited, emitted);
2920        let exhausted = visit.exhausted;
2921        let page = exhausted.then(|| {
2922            PageInfo::new(
2923                plan.retrieval.extent,
2924                emitted,
2925                None,
2926                plan.retrieval.ordering,
2927            )
2928        });
2929        let origins =
2930            (exhausted && plan.receipt == ReceiptLevel::Full).then(|| plan.origins.clone());
2931        let ordered_digest =
2932            (exhausted && plan.receipt == ReceiptLevel::Full).then(|| digest.finish());
2933        Ok(MessageSearchBatchVisitOutcome {
2934            exhausted,
2935            page,
2936            planner: visit.explain,
2937            origins,
2938            ordered_digest,
2939        })
2940    }
2941
2942    fn validate_batch_selection(
2943        all_results: bool,
2944        fuzzy: bool,
2945    ) -> std::result::Result<(), MessageSearchError> {
2946        if !all_results {
2947            return Err(MessageSearchError::InvalidParameter {
2948                parameter: "all_results",
2949                reason: "batched message-search traversal requires all_results; use search() for a finite materialized page".into(),
2950            });
2951        }
2952        if fuzzy {
2953            return Err(MessageSearchError::InvalidParameter {
2954                parameter: "query_mode",
2955                reason: "batched message-search traversal supports literal, regex, and queryless all-results requests; pass a positive limit to search() for fuzzy results".into(),
2956            });
2957        }
2958        Ok(())
2959    }
2960
2961    pub(crate) fn validate_batch_request(
2962        surface: SearchSurface,
2963        request: &MessageSearchRequest,
2964    ) -> std::result::Result<(), MessageSearchError> {
2965        let resolves_exhaustively_without_a_surface_limit =
2966            matches!(
2967                request.extent(),
2968                crate::message_search::RequestedExtent::Page { limit: None, .. }
2969            ) && !matches!(surface, SearchSurface::Mcp);
2970        Self::validate_batch_selection(
2971            matches!(
2972                request.extent(),
2973                crate::message_search::RequestedExtent::AllResults { .. }
2974            ) || resolves_exhaustively_without_a_surface_limit,
2975            matches!(
2976                request.query(),
2977                crate::message_search::MessageQuery::Fuzzy(_)
2978            ),
2979        )
2980    }
2981
2982    pub(crate) fn validate_batch_plan(
2983        plan: &MessageSearchPlan,
2984    ) -> std::result::Result<(), MessageSearchError> {
2985        Self::validate_batch_selection(
2986            matches!(plan.retrieval.extent, ResolvedExtent::AllResults { .. }),
2987            matches!(
2988                plan.retrieval.query,
2989                crate::message_search::MessageQuery::Fuzzy(_)
2990            ),
2991        )
2992    }
2993
2994    pub(crate) fn runtime_diagnostics_for_plan(
2995        &self,
2996        plan: &MessageSearchPlan,
2997    ) -> Result<Option<MessageSearchRuntimeDiagnostics>> {
2998        plan.response
2999            .includes
3000            .contains(&MessageSearchInclude::RuntimeDiagnostics)
3001            .then(|| {
3002                serde_json::to_vec(self.config).map(|bytes| {
3003                    MessageSearchRuntimeDiagnostics::new(
3004                        self.surface,
3005                        format!("sha256:{}", crate::hashing::sha256(&bytes)),
3006                    )
3007                })
3008            })
3009            .transpose()
3010            .map_err(Into::into)
3011    }
3012
3013    fn enrich_hits_cancellable(
3014        &self,
3015        plan: &MessageSearchPlan,
3016        hits: Vec<MessageHit>,
3017        cancellation: Option<&AtomicBool>,
3018        runtime_diagnostics: Option<MessageSearchRuntimeDiagnostics>,
3019        emitted_session_metadata: Option<&mut BTreeSet<String>>,
3020    ) -> Result<(
3021        Vec<crate::message_search::MessageSearchHit>,
3022        Vec<Vec<MessageHit>>,
3023        MessageSearchIncludedData,
3024    )> {
3025        ensure_message_search_active(cancellation)?;
3026        let mut hits = attach_match_evidence_cancellable(
3027            &plan.retrieval.query,
3028            &plan.retrieval.target,
3029            plan.response.presentation.match_view,
3030            hits,
3031            || ensure_message_search_active(cancellation),
3032        )?;
3033        ensure_message_search_active(cancellation)?;
3034        apply_message_presentation_cancellable(
3035            &plan.retrieval.target,
3036            plan.response.presentation,
3037            &mut hits,
3038            || ensure_message_search_active(cancellation),
3039        )?;
3040        if plan
3041            .response
3042            .includes
3043            .contains(&MessageSearchInclude::ParsedReferences)
3044        {
3045            for hit in &mut hits {
3046                ensure_message_search_active(cancellation)?;
3047                hit.set_parsed_references(crate::refs::extract_refs_from_text(
3048                    &hit.message().content,
3049                    hit.message().tool_name.as_deref(),
3050                ));
3051            }
3052        }
3053        ensure_message_search_active(cancellation)?;
3054        let include_normalized_metadata = plan
3055            .response
3056            .includes
3057            .contains(&MessageSearchInclude::NormalizedSessionMetadata);
3058        let include_raw_metadata = plan
3059            .response
3060            .includes
3061            .contains(&MessageSearchInclude::RawProviderMetadata);
3062        let deduplicate_session_metadata = emitted_session_metadata.is_some();
3063        let session_ids = if include_normalized_metadata || include_raw_metadata {
3064            let already_emitted = emitted_session_metadata.as_deref();
3065            hits.iter()
3066                .map(|hit| hit.message().session_id.clone())
3067                .filter(|session_id| {
3068                    already_emitted.is_none_or(|emitted| !emitted.contains(session_id))
3069                })
3070                .collect::<BTreeSet<_>>()
3071                .into_iter()
3072                .collect::<Vec<_>>()
3073        } else {
3074            Vec::new()
3075        };
3076        let emit_session_metadata = !deduplicate_session_metadata || !session_ids.is_empty();
3077        let normalized_session_metadata = (include_normalized_metadata && emit_session_metadata)
3078            .then(|| {
3079                self.db
3080                    .session_metadata(&session_ids)
3081                    .map(|metadata| metadata.into_iter().collect::<BTreeMap<_, _>>())
3082            })
3083            .transpose()?;
3084        ensure_message_search_active(cancellation)?;
3085        let raw_provider_metadata = (include_raw_metadata && emit_session_metadata)
3086            .then(|| self.db.session_raw_provider_metadata(&session_ids))
3087            .transpose()?;
3088        ensure_message_search_active(cancellation)?;
3089        let included = MessageSearchIncludedData::new(
3090            normalized_session_metadata,
3091            raw_provider_metadata,
3092            runtime_diagnostics,
3093        );
3094        if let Some(emitted) = emitted_session_metadata {
3095            emitted.extend(session_ids);
3096        }
3097        let context_windows = if plan.response.context.messages_before() == 0
3098            && plan.response.context.messages_after() == 0
3099        {
3100            Vec::new()
3101        } else {
3102            let before = i64::try_from(plan.response.context.messages_before())
3103                .map_err(|_| anyhow!("resolved context_before exceeds SQLite's signed range"))?;
3104            let after = i64::try_from(plan.response.context.messages_after())
3105                .map_err(|_| anyhow!("resolved context_after exceeds SQLite's signed range"))?;
3106            let anchors = hits
3107                .iter()
3108                .map(|hit| (hit.message.session_id.clone(), hit.message.seq))
3109                .collect::<Vec<_>>();
3110            self.db.message_context_windows(&anchors, before, after)?
3111        };
3112        ensure_message_search_active(cancellation)?;
3113        Ok((hits, context_windows, included))
3114    }
3115
3116    fn catalog(&self) -> CatalogService<'db> {
3117        CatalogService::new(self.db)
3118    }
3119
3120    /// Run the same search as [`MessageService::search`] and optionally return its actual planner
3121    /// receipt, including corpus size and any exact/regex prefilter decision.
3122    pub fn search_with_explain(
3123        &self,
3124        query: &str,
3125        filters: &MessageFilters,
3126        include_explain: bool,
3127    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3128        self.db
3129            .search_messages_with_explain(query, filters, include_explain)
3130    }
3131
3132    /// Load compact metadata for the requested canonical IDs; absent IDs are omitted.
3133    pub fn session_metadata(&self, session_ids: &[String]) -> Result<HashMap<String, SessionMeta>> {
3134        self.db.session_metadata(session_ids)
3135    }
3136
3137    /// Return a bounded sequence window around one message.
3138    ///
3139    /// # Complexity
3140    ///
3141    /// The `(session_id, seq)` index makes database work and memory proportional to
3142    /// `before + after + 1`, subject to messages available in the session.
3143    pub fn context(
3144        &self,
3145        session_id: &str,
3146        seq: i64,
3147        before: i64,
3148        after: i64,
3149    ) -> Result<Vec<MessageHit>> {
3150        self.db.message_context(session_id, seq, before, after)
3151    }
3152
3153    /// Read one session's messages, selecting the oldest or newest `filters.limit` by `order`,
3154    /// always returned in chronological (seq-ascending) order. Wraps
3155    /// [`Db::read_session_messages`]; `filters.session_id` must be set.
3156    pub fn read_session(
3157        &self,
3158        filters: &MessageFilters,
3159        order: crate::db::MessageOrder,
3160    ) -> Result<Vec<MessageHit>> {
3161        self.db.read_session_messages(filters, order)
3162    }
3163}
3164
3165#[derive(Clone, Copy)]
3166/// Read-only file activity, causal history, reconstruction, and safe publication operations.
3167pub struct FileService<'db> {
3168    db: &'db Db,
3169}
3170
3171impl<'db> FileService<'db> {
3172    pub const fn new(db: &'db Db) -> Self {
3173        Self { db }
3174    }
3175
3176    /// Aggregate matching file-edit rows.
3177    ///
3178    /// # Complexity
3179    ///
3180    /// Work is proportional to filtered edit rows; returned memory is proportional to grouped
3181    /// files and is unbounded only when the query explicitly uses a zero limit.
3182    pub fn search(&self, query: &FileQuery) -> Result<Vec<FileEditSummary>> {
3183        self.db.file_search(query)
3184    }
3185
3186    /// Return deterministic file/session edit-count pages using `query.limit` and `query.offset`.
3187    pub fn cross_reference(&self, query: &FileQuery) -> Result<Vec<FileCrossRef>> {
3188        self.db.file_cross_ref(query)
3189    }
3190
3191    /// Return causally ordered edits for one selected file.
3192    ///
3193    /// # Complexity
3194    ///
3195    /// Reconstruction work is proportional to matching versions and their stored edit payloads;
3196    /// returned memory is bounded by `query.limit` unless it is explicitly zero. `query.offset`
3197    /// skips versions after stable session/version ordering.
3198    pub fn history(&self, file: &str, query: &FileQuery) -> Result<Vec<FileVersion>> {
3199        crate::files::history(self.db, file, query)
3200    }
3201
3202    /// Replay edits through one requested version.
3203    ///
3204    /// # Complexity
3205    ///
3206    /// Time is proportional to replayed edit operations and content bytes; peak memory includes
3207    /// the reconstructed file plus the selected edit history.
3208    pub fn reconstruct(
3209        &self,
3210        file: &str,
3211        query: &FileQuery,
3212        version: Option<usize>,
3213    ) -> Result<crate::files::ReconstructedFile> {
3214        crate::files::reconstruct_query(self.db, file, query, version)
3215    }
3216
3217    /// Lazily reconstruct every causally ordered version with a complete replay path.
3218    pub fn reconstruct_versions(
3219        &self,
3220        file: &str,
3221        query: &FileQuery,
3222    ) -> Result<crate::files::ReconstructedFileVersions> {
3223        crate::files::reconstruct_versions_query(self.db, file, query)
3224    }
3225
3226    /// Atomically publish every reconstructable version to a new non-replacing directory.
3227    pub fn publish_versions(
3228        &self,
3229        file: &str,
3230        query: &FileQuery,
3231        destination: &std::path::Path,
3232    ) -> Result<crate::files::RecoveryPublicationReceipt> {
3233        let versions = self.reconstruct_versions(file, query)?;
3234        crate::files::publish_reconstructed_versions(versions, destination)
3235    }
3236
3237    /// Collision-safely write a reconstructed file through the shared recovery policy.
3238    pub fn restore(
3239        &self,
3240        reconstructed: &crate::files::ReconstructedFile,
3241        output_dir: Option<&std::path::Path>,
3242    ) -> Result<std::path::PathBuf> {
3243        crate::files::restore_reconstructed(reconstructed, output_dir)
3244    }
3245}
3246
3247#[cfg(test)]
3248mod message_search_service_tests {
3249    use std::num::{NonZeroU32, NonZeroUsize};
3250    use std::sync::{Arc, Barrier};
3251
3252    use super::*;
3253    use crate::config::{MessagePurposePreferences, PurposeDefinition, SearchOperation};
3254    use crate::message_search::{
3255        MessageQuery, MessageSearchRequestBuilder, MessageTarget, PurposeSelection,
3256        RequestedExtent, ResolvedExtent,
3257    };
3258    use crate::models::{Message, MessageKind, Provider, Role};
3259    use crate::util::minimal_record;
3260
3261    fn disposable_db() -> (tempfile::TempDir, Db) {
3262        let directory = tempfile::tempdir().unwrap();
3263        let db = Db::open(&directory.path().join("message-search.db")).unwrap();
3264        db.mark_schema_current().unwrap();
3265        (directory, db)
3266    }
3267
3268    fn literal_request() -> MessageSearchRequestBuilder {
3269        MessageSearchRequest::builder(
3270            MessageQuery::literal("needle").unwrap(),
3271            MessageTarget::content(),
3272        )
3273    }
3274
3275    #[test]
3276    fn message_search_spec_uses_the_real_surface_and_operation_default_planner() {
3277        let (_directory, db) = disposable_db();
3278        let mut config = Config::default();
3279        config.mcp.search_messages_limit = 9;
3280
3281        for surface in [
3282            SearchSurface::Rust,
3283            SearchSurface::Cli,
3284            SearchSurface::Python,
3285        ] {
3286            let spec = MessageService::new(&config, &db, surface)
3287                .message_search_spec()
3288                .unwrap();
3289            assert_eq!(
3290                spec.configured_default().extent(),
3291                crate::message_search::ResolvedRequestExtent::AllResults { offset: 0 }
3292            );
3293            assert_eq!(
3294                spec.registry(),
3295                crate::message_search::MessageSearchParameterRegistry::current()
3296            );
3297        }
3298        let mcp = MessageService::new(&config, &db, SearchSurface::Mcp)
3299            .message_search_spec()
3300            .unwrap();
3301        assert_eq!(
3302            mcp.configured_default().extent(),
3303            crate::message_search::ResolvedRequestExtent::Page {
3304                limit: NonZeroUsize::new(9).unwrap(),
3305                offset: 0,
3306            }
3307        );
3308
3309        config.search.message_search.default_limit = NonZeroUsize::new(4);
3310        config.search.message_search.context.context_before = Some(2);
3311        config.search.message_search.context.context_after = Some(3);
3312        for surface in SearchSurface::ALL {
3313            let spec = MessageService::new(&config, &db, *surface)
3314                .message_search_spec()
3315                .unwrap();
3316            assert_eq!(
3317                spec.configured_default().extent(),
3318                crate::message_search::ResolvedRequestExtent::Page {
3319                    limit: NonZeroUsize::new(4).unwrap(),
3320                    offset: 0,
3321                }
3322            );
3323            assert_eq!(
3324                spec.configured_default().context(),
3325                ContextWindow::new(2, 3)
3326            );
3327        }
3328    }
3329
3330    fn limit_of(plan: &MessageSearchPlan) -> Option<usize> {
3331        match plan.extent() {
3332            ResolvedExtent::Page { limit, .. } => Some(limit.get()),
3333            ResolvedExtent::AllResults { .. } => None,
3334        }
3335    }
3336
3337    fn insert_session(
3338        db: &Db,
3339        provider: Provider,
3340        provider_session_id: &str,
3341        workspace: &str,
3342        transcript: &str,
3343        contents: &[&str],
3344    ) {
3345        let mut parsed = minimal_record(provider, std::path::Path::new(transcript), String::new());
3346        parsed.session.id = format!("{}:{provider_session_id}", provider.as_str());
3347        parsed.session.provider_session_id = provider_session_id.to_string();
3348        parsed.session.cwd = Some(workspace.into());
3349        parsed.session.repo_root = Some(format!("{workspace}/repo"));
3350        parsed.session.source_path = transcript.into();
3351        parsed.session.raw_metadata_json =
3352            Some(format!(r#"{{"fixture_provider":"{}"}}"#, provider.as_str()));
3353        parsed.messages = contents
3354            .iter()
3355            .enumerate()
3356            .map(|(sequence, content)| Message {
3357                seq: sequence as i64,
3358                role: Role::User,
3359                ts: None,
3360                tool_name: None,
3361                kind: MessageKind::Conversation,
3362                tool_call_id: None,
3363                is_compaction: false,
3364                content: (*content).into(),
3365                provenance: Default::default(),
3366            })
3367            .collect();
3368        db.upsert_session(&parsed, 0, 0).unwrap();
3369    }
3370
3371    fn existing_only_search_app(
3372        provider: Provider,
3373        provider_session_id: &str,
3374        contents: &[&str],
3375    ) -> (tempfile::TempDir, SessionSearch) {
3376        let directory = tempfile::tempdir().unwrap();
3377        let db_path = directory.path().join("message-search.db");
3378        let db = Db::open(&db_path).unwrap();
3379        db.mark_schema_current().unwrap();
3380        insert_session(
3381            &db,
3382            provider,
3383            provider_session_id,
3384            "/workspace/message-search",
3385            "/transcripts/message-search.jsonl",
3386            contents,
3387        );
3388        drop(db);
3389        let mut config = Config::default();
3390        config.index.db_path = Some(db_path.to_string_lossy().into_owned());
3391        config.index.cache_dir = Some(
3392            directory
3393                .path()
3394                .join("cache")
3395                .to_string_lossy()
3396                .into_owned(),
3397        );
3398        config.index.refresh = IndexRefresh::ExistingOnly;
3399        let app = SessionSearch::open(config).unwrap();
3400        (directory, app)
3401    }
3402
3403    #[test]
3404    fn omitted_limits_keep_rust_cli_and_python_unbounded_while_mcp_stays_bounded() {
3405        let (_directory, db) = disposable_db();
3406        let config = Config::default();
3407        let request = literal_request()
3408            .extent(RequestedExtent::page(None, 7).unwrap())
3409            .build()
3410            .unwrap();
3411
3412        for surface in [
3413            SearchSurface::Rust,
3414            SearchSurface::Cli,
3415            SearchSurface::Python,
3416        ] {
3417            let plan = MessageService::new(&config, &db, surface)
3418                .plan(request.clone())
3419                .unwrap();
3420            assert_eq!(plan.extent(), ResolvedExtent::AllResults { offset: 7 });
3421            assert_eq!(plan.origins().result_extent(), &ValueOrigin::TypedDefault);
3422        }
3423
3424        let mcp = MessageService::new(&config, &db, SearchSurface::Mcp)
3425            .plan(request.clone())
3426            .unwrap();
3427        assert_eq!(limit_of(&mcp), Some(config.mcp.search_messages_limit));
3428        assert_eq!(
3429            mcp.origins().result_extent(),
3430            &ValueOrigin::SurfaceConfig {
3431                surface: SearchSurface::Mcp,
3432            }
3433        );
3434
3435        let fuzzy = MessageSearchRequest::builder(
3436            MessageQuery::fuzzy("needle").unwrap(),
3437            MessageTarget::content(),
3438        )
3439        .build()
3440        .unwrap();
3441        for surface in [
3442            SearchSurface::Rust,
3443            SearchSurface::Cli,
3444            SearchSurface::Python,
3445        ] {
3446            assert!(
3447                MessageService::new(&config, &db, surface)
3448                    .plan(fuzzy.clone())
3449                    .unwrap_err()
3450                    .to_string()
3451                    .contains("requires a positive page size"),
3452                "{surface:?} fuzzy search must not acquire an implicit non-MCP page"
3453            );
3454        }
3455        assert_eq!(
3456            limit_of(
3457                &MessageService::new(&config, &db, SearchSurface::Mcp)
3458                    .plan(fuzzy)
3459                    .unwrap()
3460            ),
3461            Some(config.mcp.search_messages_limit)
3462        );
3463    }
3464
3465    #[test]
3466    fn explicit_all_results_truthfully_bypasses_only_the_finite_page_ceiling() {
3467        let (_directory, db) = disposable_db();
3468        let mut config = Config::default();
3469        config.search.budgets.max_hits_per_page = NonZeroUsize::new(50);
3470        let request = literal_request()
3471            .extent(RequestedExtent::all_results())
3472            .build()
3473            .unwrap();
3474
3475        let plan = MessageService::new(&config, &db, SearchSurface::Rust)
3476            .plan(request)
3477            .expect("an explicit all_results request is not a finite page");
3478        assert_eq!(plan.extent(), ResolvedExtent::AllResults { offset: 0 });
3479        assert_eq!(
3480            plan.origins().result_extent(),
3481            &ValueOrigin::Explicit,
3482            "the receipt must expose the explicit bypass, not an unused default page size"
3483        );
3484    }
3485
3486    #[test]
3487    fn planner_precedence_is_explicit_and_policy_conflicts_are_rejected() {
3488        let (_directory, db) = disposable_db();
3489        let mut config = Config::default();
3490        config.search.message_search.default_limit = NonZeroUsize::new(8);
3491        config.search.message_search.context.context_before = Some(2);
3492        config.search.purposes.insert(
3493            "focused-review".into(),
3494            PurposeDefinition {
3495                version: NonZeroU32::new(1).unwrap(),
3496                operation: SearchOperation::MessageSearch,
3497                preferences: MessagePurposePreferences {
3498                    default_limit: NonZeroUsize::new(6),
3499                    context_before: Some(3),
3500                    context_after: Some(4),
3501                    receipt_level: Some(ReceiptLevel::Summary),
3502                    include_refs: Some(true),
3503                    lines_per_message: Some(-5),
3504                    match_evidence_max_chars: NonZeroUsize::new(90),
3505                },
3506            },
3507        );
3508        let purpose = PurposeSelection::new("focused-review", NonZeroU32::new(1)).unwrap();
3509        let service = MessageService::new(&config, &db, SearchSurface::Mcp);
3510
3511        let purpose_plan = service
3512            .plan(literal_request().purpose(purpose.clone()).build().unwrap())
3513            .unwrap();
3514        assert_eq!(limit_of(&purpose_plan), Some(6));
3515        assert!(matches!(
3516            purpose_plan.origins().result_extent(),
3517            ValueOrigin::Purpose { name, version }
3518                if name == "focused-review" && version.get() == 1
3519        ));
3520        assert_eq!(purpose_plan.context(), ContextWindow::new(3, 4));
3521        assert!(purpose_plan.presentation().include_refs());
3522        assert_eq!(
3523            purpose_plan.presentation().message_lines(),
3524            LineWindow::Tail(NonZeroUsize::new(5).unwrap())
3525        );
3526        assert_eq!(
3527            purpose_plan.presentation().match_evidence_max_chars().get(),
3528            90
3529        );
3530        assert!(matches!(
3531            purpose_plan.origins().match_view(),
3532            ValueOrigin::Purpose { name, version }
3533                if name == "focused-review" && version.get() == 1
3534        ));
3535        assert_eq!(purpose_plan.receipt_level(), ReceiptLevel::Summary);
3536
3537        let explicit = service
3538            .plan(
3539                literal_request()
3540                    .purpose(purpose)
3541                    .extent(RequestedExtent::page(Some(3), 0).unwrap())
3542                    .context(ContextWindow::new(9, 10))
3543                    .includes([])
3544                    .message_lines(LineWindow::Head(NonZeroUsize::new(2).unwrap()))
3545                    .match_view(MatchViewBudget::max_chars(44).unwrap())
3546                    .receipt_level(ReceiptLevel::Full)
3547                    .build()
3548                    .unwrap(),
3549            )
3550            .unwrap();
3551        assert_eq!(limit_of(&explicit), Some(3));
3552        assert_eq!(explicit.origins().result_extent(), &ValueOrigin::Explicit);
3553        assert_eq!(
3554            explicit.origins().context_messages_before(),
3555            &ValueOrigin::Explicit
3556        );
3557        assert_eq!(explicit.origins().receipt_level(), &ValueOrigin::Explicit);
3558        assert_eq!(explicit.presentation().match_evidence_max_chars().get(), 44);
3559        assert_eq!(explicit.origins().match_view(), &ValueOrigin::Explicit);
3560
3561        config.search.budgets.max_hits_per_page = NonZeroUsize::new(4);
3562        let limit_error = MessageService::new(&config, &db, SearchSurface::Mcp)
3563            .plan(literal_request().build().unwrap())
3564            .unwrap_err()
3565            .to_string();
3566        assert!(limit_error.contains("max_hits_per_page"), "{limit_error}");
3567        assert!(limit_error.contains("8"), "{limit_error}");
3568        assert!(limit_error.contains("4"), "{limit_error}");
3569
3570        config.search.budgets.max_hits_per_page = None;
3571        config.search.budgets.max_context_neighbors_per_hit = NonZeroUsize::new(4);
3572        let context_error = MessageService::new(&config, &db, SearchSurface::Mcp)
3573            .plan(
3574                literal_request()
3575                    .context(ContextWindow::new(9, 10))
3576                    .build()
3577                    .unwrap(),
3578            )
3579            .unwrap_err()
3580            .to_string();
3581        assert!(
3582            context_error.contains("max_context_neighbors_per_hit"),
3583            "{context_error}"
3584        );
3585        assert!(context_error.contains("19"), "{context_error}");
3586        assert!(context_error.contains("4"), "{context_error}");
3587    }
3588
3589    #[test]
3590    fn planner_resolves_unique_session_prefix_and_rejects_ambiguous_prefix() {
3591        let (_directory, db) = disposable_db();
3592        insert_session(
3593            &db,
3594            Provider::Claude,
3595            "abcdef",
3596            "/workspace/a",
3597            "/transcripts/a.jsonl",
3598            &["needle"],
3599        );
3600        insert_session(
3601            &db,
3602            Provider::Claude,
3603            "abcxyz",
3604            "/workspace/b",
3605            "/transcripts/b.jsonl",
3606            &["needle"],
3607        );
3608        let config = Config::default();
3609        let service = MessageService::new(&config, &db, SearchSurface::Rust);
3610
3611        let unique = service
3612            .plan(
3613                literal_request()
3614                    .session_id("claude:abcd")
3615                    .unwrap()
3616                    .build()
3617                    .unwrap(),
3618            )
3619            .unwrap();
3620        assert_eq!(
3621            unique.retrieval.predicates.session_id.as_deref(),
3622            Some("claude:abcdef")
3623        );
3624
3625        assert!(service
3626            .plan(
3627                literal_request()
3628                    .session_id("claude:abc")
3629                    .unwrap()
3630                    .build()
3631                    .unwrap(),
3632            )
3633            .unwrap_err()
3634            .to_string()
3635            .contains("ambiguous"));
3636    }
3637
3638    #[test]
3639    fn provider_sets_filter_results_and_reject_a_session_outside_the_selected_sources() {
3640        let (_directory, db) = disposable_db();
3641        for (provider, id) in [
3642            (Provider::Claude, "provider-set-claude"),
3643            (Provider::Codex, "provider-set-codex"),
3644            (Provider::GeminiCli, "provider-set-gemini"),
3645        ] {
3646            insert_session(
3647                &db,
3648                provider,
3649                id,
3650                "/workspace/providers",
3651                &format!("/transcripts/{id}.jsonl"),
3652                &["shared provider-set needle"],
3653            );
3654        }
3655        let config = Config::default();
3656        let service = MessageService::new(&config, &db, SearchSurface::Rust);
3657        let request = literal_request()
3658            .providers(vec![Provider::Codex, Provider::Claude, Provider::Codex])
3659            .unwrap()
3660            .extent(RequestedExtent::all_results())
3661            .build()
3662            .unwrap();
3663        let response = service.search(request).unwrap();
3664        assert_eq!(
3665            response
3666                .hits()
3667                .iter()
3668                .map(|hit| hit.message().provider)
3669                .collect::<Vec<_>>(),
3670            [Provider::Claude, Provider::Codex]
3671        );
3672        assert_eq!(
3673            response.request().provider_scope(),
3674            &crate::message_search::ProviderScope::Selected {
3675                providers: vec![Provider::Claude, Provider::Codex]
3676            }
3677        );
3678
3679        let error = service
3680            .plan(
3681                literal_request()
3682                    .providers(vec![Provider::Codex])
3683                    .unwrap()
3684                    .session_id("claude:provider-set-claude")
3685                    .unwrap()
3686                    .build()
3687                    .unwrap(),
3688            )
3689            .unwrap_err()
3690            .to_string();
3691        assert!(error.contains("session_id"), "{error}");
3692        assert!(error.contains("provider claude"), "{error}");
3693        assert!(error.contains("providers=[codex]"), "{error}");
3694        assert!(error.contains("include claude"), "{error}");
3695    }
3696
3697    #[test]
3698    fn bounded_batches_match_the_simple_materialized_response_and_stop_without_completion() {
3699        let (_directory, db) = disposable_db();
3700        insert_session(
3701            &db,
3702            Provider::Claude,
3703            "bounded-service",
3704            "/workspace/bounded",
3705            "/transcripts/bounded.jsonl",
3706            &[
3707                "needle zero https://example.com/zero",
3708                "context one",
3709                "needle two https://example.com/two",
3710                "needle three",
3711                "needle four",
3712            ],
3713        );
3714        let config = Config::default();
3715        let service = MessageService::new(&config, &db, SearchSurface::Rust);
3716        let request = literal_request()
3717            .session_id("claude:bounded-service")
3718            .unwrap()
3719            .extent(RequestedExtent::all_results())
3720            .context(ContextWindow::new(1, 1))
3721            .includes([
3722                MessageSearchInclude::NormalizedSessionMetadata,
3723                MessageSearchInclude::ParsedReferences,
3724            ])
3725            .receipt_level(ReceiptLevel::Full)
3726            .build()
3727            .unwrap();
3728        let materialized = service.search(request.clone()).unwrap();
3729
3730        let mut batch_lengths = Vec::new();
3731        let mut sequences = Vec::new();
3732        let mut field_views = Vec::new();
3733        let mut context_sequences = Vec::new();
3734        let mut parsed_reference_counts = Vec::new();
3735        let mut included_sessions = BTreeSet::new();
3736        let outcome =
3737            service
3738                .visit_search_batches(request.clone(), NonZeroUsize::new(2).unwrap(), |batch| {
3739                    batch_lengths.push(batch.results.len());
3740                    sequences.extend(batch.results.iter().map(|result| result.seq));
3741                    field_views.extend(
3742                        batch
3743                            .results
3744                            .iter()
3745                            .map(|result| result.field_view().text().to_string()),
3746                    );
3747                    parsed_reference_counts.extend(
3748                        batch
3749                            .results
3750                            .iter()
3751                            .map(|result| result.parsed_references().map_or(0, <[_]>::len)),
3752                    );
3753                    context_sequences.extend(batch.context_windows.iter().map(|window| {
3754                        window.iter().map(|message| message.seq).collect::<Vec<_>>()
3755                    }));
3756                    if let Some(metadata) = batch.included.normalized_session_metadata() {
3757                        included_sessions.extend(metadata.keys().cloned());
3758                    }
3759                    Ok(MessageSearchBatchControl::Continue)
3760                })
3761                .unwrap();
3762
3763        assert_eq!(batch_lengths, vec![2, 2]);
3764        assert_eq!(
3765            sequences,
3766            materialized
3767                .results()
3768                .iter()
3769                .map(|result| result.seq)
3770                .collect::<Vec<_>>()
3771        );
3772        assert_eq!(
3773            field_views,
3774            materialized
3775                .results()
3776                .iter()
3777                .map(|result| result.field_view().text().to_string())
3778                .collect::<Vec<_>>()
3779        );
3780        assert_eq!(
3781            parsed_reference_counts,
3782            materialized
3783                .results()
3784                .iter()
3785                .map(|result| result.parsed_references().map_or(0, <[_]>::len))
3786                .collect::<Vec<_>>()
3787        );
3788        assert_eq!(
3789            context_sequences,
3790            materialized
3791                .context_windows()
3792                .iter()
3793                .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>())
3794                .collect::<Vec<_>>()
3795        );
3796        assert_eq!(
3797            included_sessions,
3798            BTreeSet::from(["claude:bounded-service".to_string()])
3799        );
3800        assert!(outcome.exhausted);
3801        assert_eq!(
3802            outcome.page.expect("natural exhaustion has page metadata"),
3803            materialized.page()
3804        );
3805        assert_eq!(
3806            outcome.ordered_digest.as_deref(),
3807            Some(materialized.ordered_digest().as_str())
3808        );
3809        assert!(outcome.planner.is_some());
3810        assert!(outcome.origins.is_some());
3811
3812        let mut stopped_sequences = Vec::new();
3813        let stopped = service
3814            .visit_search_batches(request, NonZeroUsize::new(2).unwrap(), |batch| {
3815                stopped_sequences.extend(batch.results.iter().map(|result| result.seq));
3816                Ok(MessageSearchBatchControl::Stop)
3817            })
3818            .unwrap();
3819        assert_eq!(stopped_sequences, vec![0, 2]);
3820        assert!(!stopped.exhausted);
3821        assert!(stopped.page.is_none());
3822        assert!(stopped.planner.is_none());
3823        assert!(stopped.origins.is_none());
3824        assert!(stopped.ordered_digest.is_none());
3825    }
3826
3827    #[test]
3828    fn bounded_latest_offset_matches_the_materialized_chronological_result() {
3829        let (_directory, db) = disposable_db();
3830        insert_session(
3831            &db,
3832            Provider::Claude,
3833            "bounded-latest",
3834            "/workspace/bounded-latest",
3835            "/transcripts/bounded-latest.jsonl",
3836            &[
3837                "needle zero",
3838                "needle one",
3839                "needle two",
3840                "needle three",
3841                "needle four",
3842            ],
3843        );
3844        let config = Config::default();
3845        let service = MessageService::new(&config, &db, SearchSurface::Rust);
3846        let request = literal_request()
3847            .session_id("claude:bounded-latest")
3848            .unwrap()
3849            .match_window(MatchWindow::Latest)
3850            .extent(RequestedExtent::all_results_from(2))
3851            .receipt_level(ReceiptLevel::Full)
3852            .build()
3853            .unwrap();
3854        let materialized = service.search(request.clone()).unwrap();
3855        let mut streamed = Vec::new();
3856        let outcome = service
3857            .visit_search_batches(request, NonZeroUsize::new(1).unwrap(), |batch| {
3858                streamed.extend(batch.results.into_iter().map(|result| result.seq));
3859                Ok(MessageSearchBatchControl::Continue)
3860            })
3861            .unwrap();
3862
3863        assert_eq!(streamed, vec![0, 1, 2]);
3864        assert_eq!(
3865            streamed,
3866            materialized
3867                .results()
3868                .iter()
3869                .map(|result| result.seq)
3870                .collect::<Vec<_>>()
3871        );
3872        assert_eq!(
3873            outcome.ordered_digest.as_deref(),
3874            Some(materialized.ordered_digest().as_str())
3875        );
3876    }
3877
3878    #[test]
3879    fn exhaustive_batches_match_the_simple_list_and_own_terminal_state() {
3880        let (_directory, app) = existing_only_search_app(
3881            Provider::Codex,
3882            "batch-fixture",
3883            &["needle zero", "context one", "needle two", "needle three"],
3884        );
3885        let request = literal_request()
3886            .session_id("codex:batch-fixture")
3887            .unwrap()
3888            .extent(RequestedExtent::all_results())
3889            .context(ContextWindow::new(1, 1))
3890            .includes([
3891                MessageSearchInclude::NormalizedSessionMetadata,
3892                MessageSearchInclude::RawProviderMetadata,
3893                MessageSearchInclude::RuntimeDiagnostics,
3894            ])
3895            .receipt_level(ReceiptLevel::Full)
3896            .build()
3897            .unwrap();
3898        let materialized = app.messages().search(request.clone()).unwrap();
3899        let mut batches = app
3900            .message_search_batches(request, NonZeroUsize::new(2).unwrap())
3901            .unwrap();
3902        assert_eq!(batches.request().receipt_level(), ReceiptLevel::Full);
3903        assert_eq!(
3904            batches
3905                .runtime_diagnostics()
3906                .expect("requested request-wide diagnostics")
3907                .config_digest(),
3908            materialized
3909                .included()
3910                .runtime_diagnostics()
3911                .expect("materialized response diagnostics")
3912                .config_digest()
3913        );
3914        assert!(batches
3915            .completion()
3916            .unwrap_err()
3917            .to_string()
3918            .contains("unread results"));
3919        let resolved_request = batches.request().clone();
3920        let mut sequences = Vec::new();
3921        let mut contexts = Vec::new();
3922        let mut serialized_results = Vec::new();
3923        let mut normalized_metadata_batches = 0;
3924        let mut raw_metadata_batches = 0;
3925        while let Some(batch) = batches.next_batch().unwrap() {
3926            assert!(batch.results().len() <= 2);
3927            assert!(batch.included().runtime_diagnostics().is_none());
3928            if let Some(metadata) = batch.included().normalized_session_metadata() {
3929                normalized_metadata_batches += 1;
3930                assert_eq!(metadata.len(), 1);
3931                assert!(metadata.contains_key("codex:batch-fixture"));
3932            }
3933            if let Some(metadata) = batch.included().raw_provider_metadata() {
3934                raw_metadata_batches += 1;
3935                assert_eq!(metadata.len(), 1);
3936                assert!(metadata.contains_key("codex:batch-fixture"));
3937            }
3938            serialized_results.extend((0..batch.results().len()).map(|index| {
3939                serde_json::to_value(
3940                    batch
3941                        .result_document(&resolved_request, index)
3942                        .expect("index comes from the batch result length"),
3943                )
3944                .unwrap()
3945            }));
3946            sequences.extend(batch.results().iter().map(|result| result.seq));
3947            contexts.extend(
3948                batch
3949                    .context_windows()
3950                    .iter()
3951                    .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>()),
3952            );
3953        }
3954        let completion = batches.completion().unwrap();
3955        assert_eq!(normalized_metadata_batches, 1);
3956        assert_eq!(raw_metadata_batches, 1);
3957        assert_eq!(
3958            sequences,
3959            materialized
3960                .results()
3961                .iter()
3962                .map(|result| result.seq)
3963                .collect::<Vec<_>>()
3964        );
3965        assert_eq!(
3966            contexts,
3967            materialized
3968                .context_windows()
3969                .iter()
3970                .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>())
3971                .collect::<Vec<_>>()
3972        );
3973        assert_eq!(completion.page(), materialized.page());
3974        assert_eq!(
3975            serialized_results,
3976            (0..materialized.results().len())
3977                .map(|index| {
3978                    serde_json::to_value(materialized.result_document(index).unwrap()).unwrap()
3979                })
3980                .collect::<Vec<_>>()
3981        );
3982        assert_eq!(
3983            serde_json::to_value(completion.page_document()).unwrap(),
3984            serde_json::to_value(materialized.page_document()).unwrap()
3985        );
3986        assert_eq!(
3987            serde_json::to_value(completion.receipt_document(&resolved_request)).unwrap(),
3988            serde_json::to_value(materialized.receipt_document()).unwrap()
3989        );
3990        assert_eq!(
3991            completion.ordered_digest(),
3992            Some(materialized.ordered_digest().as_str())
3993        );
3994        assert!(completion.search_explanation().is_some());
3995        assert!(completion.parameter_origins().is_some());
3996        assert!(batches.next_batch().unwrap().is_none());
3997        batches.close().unwrap();
3998        batches.close().unwrap();
3999    }
4000
4001    #[test]
4002    fn exhaustive_batches_preserve_the_rust_surface_unbounded_default() {
4003        let (_directory, app) = existing_only_search_app(
4004            Provider::Codex,
4005            "batch-default-fixture",
4006            &["needle zero", "needle one", "needle two"],
4007        );
4008        let request = literal_request()
4009            .session_id("codex:batch-default-fixture")
4010            .unwrap()
4011            .build()
4012            .unwrap();
4013        let materialized = app.messages().search(request.clone()).unwrap();
4014        let mut batches = app
4015            .message_search_batches(request, NonZeroUsize::new(2).unwrap())
4016            .unwrap();
4017        let mut sequences = Vec::new();
4018        while let Some(batch) = batches.next_batch().unwrap() {
4019            sequences.extend(batch.results().iter().map(|result| result.seq));
4020        }
4021        assert_eq!(
4022            sequences,
4023            materialized
4024                .results()
4025                .iter()
4026                .map(|result| result.seq)
4027                .collect::<Vec<_>>()
4028        );
4029        assert_eq!(
4030            batches.request().extent(),
4031            crate::message_search::ResolvedRequestExtent::AllResults { offset: 0 }
4032        );
4033        assert_eq!(batches.completion().unwrap().page(), materialized.page());
4034    }
4035
4036    #[test]
4037    fn exhaustive_batches_reject_finite_pages_with_the_simple_api_recovery() {
4038        let (_directory, app) =
4039            existing_only_search_app(Provider::Antigravity, "batch-fixture", &["needle zero"]);
4040        let request = literal_request()
4041            .session_id("antigravity:batch-fixture")
4042            .unwrap()
4043            .extent(RequestedExtent::page(Some(1), 0).unwrap())
4044            .build()
4045            .unwrap();
4046        let error = app
4047            .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4048            .err()
4049            .expect("finite page must be rejected")
4050            .to_string();
4051        assert!(error.contains("requires all_results"));
4052        assert!(error.contains("use search() for a finite materialized page"));
4053    }
4054
4055    #[test]
4056    fn exhaustive_batches_keep_one_snapshot_and_close_does_not_drain() {
4057        let (directory, app) = existing_only_search_app(
4058            Provider::GeminiCli,
4059            "batch-fixture",
4060            &["needle zero", "needle one", "needle two"],
4061        );
4062        let request = literal_request()
4063            .session_id("gemini-cli:batch-fixture")
4064            .unwrap()
4065            .extent(RequestedExtent::all_results())
4066            .build()
4067            .unwrap();
4068        let mut batches = app
4069            .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4070            .unwrap();
4071        let first = batches.next_batch().unwrap().unwrap();
4072        assert_eq!(
4073            first
4074                .results()
4075                .iter()
4076                .map(|result| result.seq)
4077                .collect::<Vec<_>>(),
4078            vec![0]
4079        );
4080
4081        let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4082        insert_session(
4083            &writer,
4084            Provider::GeminiCli,
4085            "batch-fixture",
4086            "/workspace/message-search",
4087            "/transcripts/message-search.jsonl",
4088            &["needle zero", "needle one", "needle two", "needle late"],
4089        );
4090        let mut remaining = Vec::new();
4091        while let Some(batch) = batches.next_batch().unwrap() {
4092            remaining.extend(batch.results().iter().map(|result| result.seq));
4093        }
4094        assert_eq!(remaining, vec![1, 2]);
4095        assert_eq!(
4096            writer
4097                .search_messages("needle", &MessageFilters::default())
4098                .unwrap()
4099                .len(),
4100            4
4101        );
4102
4103        let request = literal_request()
4104            .session_id("gemini-cli:batch-fixture")
4105            .unwrap()
4106            .extent(RequestedExtent::all_results())
4107            .build()
4108            .unwrap();
4109        for _ in 0..16 {
4110            let mut immediately_stopped = app
4111                .message_search_batches(request.clone(), NonZeroUsize::new(1).unwrap())
4112                .unwrap();
4113            immediately_stopped.close().unwrap();
4114        }
4115        assert_eq!(writer.wal_checkpoint_busy_for_test().unwrap(), 0);
4116
4117        let mut stopped = app
4118            .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4119            .unwrap();
4120        assert!(stopped.next_batch().unwrap().is_some());
4121        stopped.close().unwrap();
4122        stopped.close().unwrap();
4123        assert!(stopped
4124            .next_batch()
4125            .unwrap_err()
4126            .to_string()
4127            .contains("closed"));
4128        assert!(stopped
4129            .completion()
4130            .unwrap_err()
4131            .to_string()
4132            .contains("no terminal metadata"));
4133        assert_eq!(writer.wal_checkpoint_busy_for_test().unwrap(), 0);
4134    }
4135
4136    #[test]
4137    fn materialized_search_keeps_hits_and_context_in_one_snapshot() {
4138        let (directory, app) = existing_only_search_app(
4139            Provider::GeminiCli,
4140            "materialized-snapshot",
4141            &["needle before refresh"],
4142        );
4143        let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4144        let request = literal_request()
4145            .session_id("gemini-cli:materialized-snapshot")
4146            .unwrap()
4147            .extent(RequestedExtent::all_results())
4148            .context(ContextWindow::new(0, 1))
4149            .build()
4150            .unwrap();
4151
4152        let response = app
4153            .messages()
4154            .search_with_after_retrieval_for_test(request.clone(), || {
4155                insert_session(
4156                    &writer,
4157                    Provider::GeminiCli,
4158                    "materialized-snapshot",
4159                    "/workspace/message-search",
4160                    "/transcripts/message-search.jsonl",
4161                    &["needle before refresh", "context committed during refresh"],
4162                );
4163            })
4164            .unwrap();
4165        assert_eq!(response.results().len(), 1);
4166        assert!(response
4167            .context_windows()
4168            .iter()
4169            .flatten()
4170            .all(|message| message.content != "context committed during refresh"));
4171
4172        let refreshed = app.messages().search(request).unwrap();
4173        assert!(refreshed
4174            .context_windows()
4175            .iter()
4176            .flatten()
4177            .any(|message| message.content == "context committed during refresh"));
4178    }
4179
4180    #[test]
4181    fn exhaustive_batches_resolve_session_prefix_inside_the_result_snapshot() {
4182        let (directory, app) = existing_only_search_app(
4183            Provider::Pi,
4184            "snapshot-original",
4185            &["needle before replacement"],
4186        );
4187        let request = literal_request()
4188            .session_id("pi:snapshot")
4189            .unwrap()
4190            .extent(RequestedExtent::all_results())
4191            .build()
4192            .unwrap();
4193        let ready = Arc::new(Barrier::new(2));
4194        let worker_ready = Arc::clone(&ready);
4195        let mut batches = MessageSearchBatches::spawn_with_before_traversal(
4196            app.config.clone(),
4197            app.access.clone(),
4198            SearchSurface::Rust,
4199            request,
4200            NonZeroUsize::new(1).unwrap(),
4201            move || {
4202                worker_ready.wait();
4203            },
4204        )
4205        .unwrap();
4206
4207        // The worker has resolved the prefix and anchored its transaction, but has not started
4208        // result traversal. Replacing the source identity now must affect only later searches.
4209        let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4210        insert_session(
4211            &writer,
4212            Provider::Pi,
4213            "snapshot-replacement",
4214            "/workspace/message-search",
4215            "/transcripts/message-search.jsonl",
4216            &["needle after replacement"],
4217        );
4218        ready.wait();
4219
4220        let mut sessions = Vec::new();
4221        while let Some(batch) = batches.next_batch().unwrap() {
4222            sessions.extend(
4223                batch
4224                    .results()
4225                    .iter()
4226                    .map(|result| result.message.session_id.clone()),
4227            );
4228        }
4229        assert_eq!(sessions, vec!["pi:snapshot-original"]);
4230        assert_eq!(
4231            writer.resolve_session("pi:snapshot").unwrap().session.id,
4232            "pi:snapshot-replacement"
4233        );
4234    }
4235
4236    #[test]
4237    fn latest_literal_and_regex_select_newest_matches_then_present_chronologically() {
4238        let (_directory, db) = disposable_db();
4239        insert_session(
4240            &db,
4241            Provider::Claude,
4242            "latest-window",
4243            "/workspace/latest",
4244            "/transcripts/latest.jsonl",
4245            &["needle zero", "unrelated", "needle two", "needle three"],
4246        );
4247        let config = Config::default();
4248        let service = MessageService::new(&config, &db, SearchSurface::Rust);
4249
4250        for query in [
4251            MessageQuery::literal("needle").unwrap(),
4252            MessageQuery::regex("needle (zero|two|three)").unwrap(),
4253        ] {
4254            let response = service
4255                .search(
4256                    MessageSearchRequest::builder(query, MessageTarget::content())
4257                        .session_id("claude:latest")
4258                        .unwrap()
4259                        .match_window(MatchWindow::Latest)
4260                        .extent(RequestedExtent::page(Some(2), 0).unwrap())
4261                        .build()
4262                        .unwrap(),
4263                )
4264                .unwrap();
4265            assert_eq!(
4266                response
4267                    .hits()
4268                    .iter()
4269                    .map(|hit| hit.seq)
4270                    .collect::<Vec<_>>(),
4271                vec![2, 3]
4272            );
4273            assert_eq!(response.page().next_offset(), Some(2));
4274        }
4275    }
4276
4277    #[test]
4278    fn latest_window_applies_to_tool_name_and_tool_argument_targets() {
4279        let (_directory, db) = disposable_db();
4280        let mut parsed = minimal_record(
4281            Provider::Claude,
4282            std::path::Path::new("/transcripts/latest-tools.jsonl"),
4283            String::new(),
4284        );
4285        parsed.session.id = "claude:latest-tools".into();
4286        parsed.session.provider_session_id = "latest-tools".into();
4287        parsed.session.cwd = Some("/workspace/latest-tools".into());
4288        parsed.messages = (0..4)
4289            .map(|seq| Message {
4290                seq,
4291                role: Role::Tool,
4292                ts: None,
4293                tool_name: Some(format!("exec-{seq}")),
4294                kind: MessageKind::ToolCall,
4295                tool_call_id: Some(format!("call-{seq}")),
4296                is_compaction: false,
4297                content: format!(r#"{{"args":{{"cmd":"needle {seq}"}}}}"#),
4298                provenance: Default::default(),
4299            })
4300            .collect();
4301        db.upsert_session(&parsed, 0, 0).unwrap();
4302        let config = Config::default();
4303        let service = MessageService::new(&config, &db, SearchSurface::Rust);
4304
4305        for (query, target) in [
4306            (
4307                MessageQuery::literal("exec").unwrap(),
4308                MessageTarget::tool_name(),
4309            ),
4310            (
4311                MessageQuery::regex(r"exec-[0-3]").unwrap(),
4312                MessageTarget::tool_name(),
4313            ),
4314            (
4315                MessageQuery::literal("needle").unwrap(),
4316                MessageTarget::tool_argument("/cmd").unwrap(),
4317            ),
4318            (
4319                MessageQuery::regex(r"needle [0-3]").unwrap(),
4320                MessageTarget::tool_argument("/cmd").unwrap(),
4321            ),
4322        ] {
4323            let response = service
4324                .search(
4325                    MessageSearchRequest::builder(query, target)
4326                        .session_id("claude:latest-tools")
4327                        .unwrap()
4328                        .match_window(MatchWindow::Latest)
4329                        .extent(RequestedExtent::page(Some(2), 0).unwrap())
4330                        .build()
4331                        .unwrap(),
4332                )
4333                .unwrap();
4334            assert_eq!(
4335                response
4336                    .hits()
4337                    .iter()
4338                    .map(|hit| hit.seq)
4339                    .collect::<Vec<_>>(),
4340                vec![2, 3]
4341            );
4342        }
4343    }
4344
4345    #[test]
4346    fn typed_path_domains_are_separate_while_legacy_path_remains_broad() {
4347        let (_directory, db) = disposable_db();
4348        insert_session(
4349            &db,
4350            Provider::Claude,
4351            "workspace-domain",
4352            "/domains/shared/workspace",
4353            "/elsewhere/workspace.jsonl",
4354            &["needle"],
4355        );
4356        insert_session(
4357            &db,
4358            Provider::Claude,
4359            "transcript-domain",
4360            "/elsewhere/transcript",
4361            "/domains/shared/transcript/session.jsonl",
4362            &["needle"],
4363        );
4364        let config = Config::default();
4365        let service = MessageService::new(&config, &db, SearchSurface::Rust);
4366
4367        let workspace = service
4368            .search(
4369                literal_request()
4370                    .workspace_path_prefix("/domains/shared")
4371                    .unwrap()
4372                    .build()
4373                    .unwrap(),
4374            )
4375            .unwrap();
4376        assert_eq!(workspace.hits().len(), 1);
4377        assert_eq!(workspace.hits()[0].session_id, "claude:workspace-domain");
4378
4379        let transcript = service
4380            .search(
4381                literal_request()
4382                    .transcript_path_prefix("/domains/shared")
4383                    .unwrap()
4384                    .build()
4385                    .unwrap(),
4386            )
4387            .unwrap();
4388        assert_eq!(transcript.hits().len(), 1);
4389        assert_eq!(transcript.hits()[0].session_id, "claude:transcript-domain");
4390
4391        let excluded_workspace = service
4392            .search(
4393                literal_request()
4394                    .exclude_workspace_path_prefix("/domains/shared")
4395                    .unwrap()
4396                    .build()
4397                    .unwrap(),
4398            )
4399            .unwrap();
4400        assert_eq!(excluded_workspace.hits().len(), 1);
4401        assert_eq!(
4402            excluded_workspace.hits()[0].session_id,
4403            "claude:transcript-domain"
4404        );
4405
4406        let excluded_transcript = service
4407            .search(
4408                literal_request()
4409                    .exclude_transcript_path_prefix("/domains/shared")
4410                    .unwrap()
4411                    .build()
4412                    .unwrap(),
4413            )
4414            .unwrap();
4415        assert_eq!(excluded_transcript.hits().len(), 1);
4416        assert_eq!(
4417            excluded_transcript.hits()[0].session_id,
4418            "claude:workspace-domain"
4419        );
4420
4421        let legacy = service
4422            .search_legacy(
4423                "needle",
4424                &MessageFilters {
4425                    path_prefix: Some("/domains/shared".into()),
4426                    ..Default::default()
4427                },
4428            )
4429            .unwrap();
4430        assert_eq!(legacy.len(), 2);
4431    }
4432}
4433
4434#[cfg(test)]
4435mod tests {
4436    use super::*;
4437
4438    #[test]
4439    fn services_share_one_database_and_return_typed_empty_results() {
4440        let dir = tempfile::tempdir().unwrap();
4441        let db = Db::open(&dir.path().join("index.db")).unwrap();
4442        db.mark_schema_current().unwrap();
4443
4444        let config = Config::default();
4445        let messages = MessageService::new(&config, &db, SearchSurface::Rust)
4446            .search_legacy("missing", &MessageFilters::default())
4447            .unwrap();
4448        let files = FileService::new(&db).search(&FileQuery::default()).unwrap();
4449        let mut config = Config::default();
4450        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4451        let app = SessionSearch::open(config).unwrap();
4452        app.database().mark_schema_current().unwrap();
4453        let status = app.index().status().unwrap();
4454
4455        assert!(messages.is_empty());
4456        assert!(files.is_empty());
4457        assert!(status.parser_health.schema_current);
4458        assert_eq!(status.parser_health.indexed_sessions, 0);
4459    }
4460
4461    #[test]
4462    fn catalog_rejects_a_parent_filter_that_can_match_no_user_session() {
4463        let dir = tempfile::tempdir().unwrap();
4464        let db = Db::open(&dir.path().join("index.db")).unwrap();
4465        db.mark_schema_current().unwrap();
4466
4467        let error = CatalogService::new(&db)
4468            .list_sessions(&SearchFilters {
4469                session_kinds: Some(vec![crate::models::SessionKind::User]),
4470                parent_session_id: Some("claude:parent".to_string()),
4471                ..SearchFilters::default()
4472            })
4473            .expect_err("a user-started session cannot have a parent")
4474            .to_string();
4475
4476        assert!(error.contains("session_kinds"), "{error}");
4477        assert!(error.contains("parent_session_id"), "{error}");
4478        assert!(error.contains("subagent"), "{error}");
4479        assert!(error.contains("omit session_kinds"), "{error}");
4480    }
4481
4482    #[test]
4483    fn application_owns_config_database_and_service_lifecycle() {
4484        let dir = tempfile::tempdir().unwrap();
4485        let mut config = Config::default();
4486        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4487        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4488        config.providers.claude.enabled = false;
4489        config.providers.claude_desktop.enabled = false;
4490        config.providers.codex.enabled = false;
4491        config.providers.cursor.enabled = false;
4492        config.providers.antigravity.enabled = false;
4493        config.providers.pi.enabled = false;
4494        config.providers.prime_agent.enabled = false;
4495        config.providers.aistudio.enabled = false;
4496        config.providers.gemini_cli.enabled = false;
4497
4498        let app = SessionSearch::open(config).unwrap();
4499        let outcome = app.index().refresh().unwrap();
4500
4501        assert!(matches!(outcome, AutoReindexOutcome::Updated { .. }));
4502        assert!(app.config().db_path().exists());
4503        assert!(app
4504            .catalog()
4505            .list_sessions(&SearchFilters {
4506                limit: 1,
4507                ..SearchFilters::default()
4508            })
4509            .unwrap()
4510            .is_empty());
4511
4512        let diagnostics = app.maintenance().diagnostics().unwrap();
4513        assert_eq!(
4514            std::path::Path::new(&diagnostics.db_path),
4515            app.config().db_path()
4516        );
4517        let compacted = app.maintenance().compact().unwrap();
4518        assert!(compacted.after_bytes > 0);
4519        assert_eq!(
4520            compacted.reclaimed_bytes(),
4521            compacted.before_bytes.saturating_sub(compacted.after_bytes)
4522        );
4523    }
4524
4525    #[test]
4526    fn explicit_reindex_owns_lock_on_current_schema() {
4527        let dir = tempfile::tempdir().unwrap();
4528        let mut config = Config::default();
4529        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4530        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4531        config.providers.claude.enabled = false;
4532        config.providers.claude_desktop.enabled = false;
4533        config.providers.codex.enabled = false;
4534        config.providers.cursor.enabled = false;
4535        config.providers.antigravity.enabled = false;
4536        config.providers.pi.enabled = false;
4537        config.providers.prime_agent.enabled = false;
4538        config.providers.aistudio.enabled = false;
4539        config.providers.gemini_cli.enabled = false;
4540
4541        let app = SessionSearch::open(config).unwrap();
4542        assert!(!app.database().needs_backfill().unwrap());
4543
4544        assert_eq!(app.index().reindex(false).unwrap(), (0, 0));
4545
4546        assert!(!app.database().needs_backfill().unwrap());
4547        assert!(app.index().status().unwrap().parser_health.schema_current);
4548        assert!(indexer::index_update_lock_path(&app.config().db_path()).exists());
4549    }
4550
4551    #[test]
4552    fn public_full_reindex_promotes_v3_to_current_search_schema() {
4553        let dir = tempfile::tempdir().unwrap();
4554        let mut config = Config::default();
4555        config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4556        config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4557        config.providers.claude.enabled = false;
4558        config.providers.claude_desktop.enabled = false;
4559        config.providers.codex.enabled = false;
4560        config.providers.cursor.enabled = false;
4561        config.providers.antigravity.enabled = false;
4562        config.providers.pi.enabled = false;
4563        config.providers.prime_agent.enabled = false;
4564        config.providers.aistudio.enabled = false;
4565        config.providers.gemini_cli.enabled = false;
4566
4567        drop(Db::open(&config.db_path()).unwrap());
4568        let conn = rusqlite::Connection::open(config.db_path()).unwrap();
4569        conn.execute_batch(
4570            "drop trigger messages_ai;
4571                 drop trigger messages_ad;
4572                 drop trigger messages_au;
4573                 drop table messages_trigram_terms;
4574                 drop table messages_trigram_vocab;
4575                 drop table messages_trigram;
4576                 pragma user_version=3;",
4577        )
4578        .unwrap();
4579        crate::fts::install_released_message_word_index(&conn).unwrap();
4580        crate::trigram_index::ensure_schema(&conn).unwrap();
4581        drop(conn);
4582
4583        let app = SessionSearch::open(config).unwrap();
4584
4585        assert_eq!(app.index().reindex(true).unwrap(), (0, 0));
4586        assert_eq!(
4587            app.database().schema_version().unwrap(),
4588            crate::db::SCHEMA_VERSION
4589        );
4590        let conn = rusqlite::Connection::open_with_flags(
4591            app.config().db_path(),
4592            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4593        )
4594        .unwrap();
4595        let layout: (bool, bool, bool) = conn
4596            .query_row(
4597                "select exists(select 1 from sqlite_schema where name='messages_trigram'),
4598                        exists(select 1 from sqlite_schema where name='messages_trigram_vocab'),
4599                        not exists(select 1 from sqlite_schema where name='trigram_postings')",
4600                [],
4601                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
4602            )
4603            .unwrap();
4604        assert_eq!(layout, (true, true, true));
4605    }
4606}