Skip to main content

projectatlas_db/
lib.rs

1//! Purpose: Persist `ProjectAtlas` 3 indexes in `SQLite`.
2
3mod content_classification;
4mod derived_snapshot;
5mod diagnostics;
6mod hydration;
7mod project_identity;
8mod repository_graph;
9mod schema;
10mod sqlite_profile;
11mod telemetry;
12mod worktree_registry;
13
14pub use content_classification::{
15    FileContentClassification, FileContentClassificationPage,
16    MAX_FILE_CONTENT_CLASSIFICATION_PAGE_ROWS, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
17};
18pub use derived_snapshot::{
19    DerivedGraphSnapshot, DerivedGraphSnapshotImport, DerivedGraphSnapshotMetadata,
20    DerivedSnapshotContent, MAX_DERIVED_SNAPSHOT_JSON_BYTES,
21};
22pub use diagnostics::{
23    DatabaseCoverageSample, DatabaseCoverageSummary, DatabaseCoverageTotalState,
24    DatabaseFilesystemSupport, DatabaseOperatingProfileReport, DatabasePublicationContractState,
25    DatabasePublicationReport, DatabaseSchemaCompatibility, DatabaseSchemaReport,
26    DatabaseSettingsReport, SqliteCompileOptionsIdentity, SqliteRuntimeReport,
27    database_settings_report,
28};
29pub use hydration::{
30    PreparedWorktreeHydrationCandidate, WorktreeHydrationActivation, WorktreeHydrationCandidate,
31};
32pub use project_identity::{ProjectRootTransition, ProjectRootTransitionResult};
33pub use repository_graph::{
34    MAX_REPOSITORY_GRAPH_FRONTIER, RepositoryAffectedSourceFootprint, RepositoryCoverageQuery,
35    RepositoryCoverageRow, RepositoryGraphAdjacencyContinuation, RepositoryGraphAdjacencyPage,
36    RepositoryGraphAdjacencyReadPage, RepositoryGraphAdjacencyRow,
37    RepositoryGraphClassifiedRelationRow, RepositoryGraphDirection, RepositoryGraphPage,
38    RepositoryGraphReadBatch, RepositoryGraphReadBudget, RepositoryGraphReadPage,
39    RepositoryGraphReadPages, RepositoryGraphReadWork, RepositoryGraphRelationQuery,
40    RepositoryGraphRelationRow, RepositoryGraphStagingGuard, RepositoryNavigationConnections,
41    RepositoryNavigationNode, RepositoryResolutionCandidate,
42};
43pub use sqlite_profile::validate_database_location;
44pub use telemetry::{
45    PlannerStatisticsPolicy, PlannerStatisticsState, SpillCleanupState, TelemetryCheckpointState,
46    TelemetryRetentionPolicy, TelemetryRetentionState, WorktreeUsageSnapshot,
47    WorktreeUsageSyncState,
48};
49pub use worktree_registry::{
50    ActiveWorktreeRegistrationGuard, MAIN_WORKTREE_ALIAS, MAX_WORKTREE_ALIAS_BYTES, WorktreeAlias,
51    WorktreeRegistration, WorktreeRegistrationState,
52};
53
54use blake3::Hasher;
55use projectatlas_core::graph::{GraphContractError, ProjectInstanceId};
56use projectatlas_core::health::{
57    CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
58    CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
59    HealthFinding, MESSAGE_MISSING_PURPOSE, MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED,
60    MESSAGE_STALE_PURPOSE, MESSAGE_SUGGESTED_PURPOSE_REVIEW, RECOMMENDATION_DUPLICATE_PURPOSE,
61    RECOMMENDATION_MISSING_PURPOSE_QUEUE, RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED,
62    RECOMMENDATION_REPEATED_TEMPORARY_FOLDER, RECOMMENDATION_STALE_PURPOSE,
63    RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE, STRUCTURAL_HEALTH_CATEGORIES, Severity,
64    TEMP_FOLDER_BUCKETS, finding_id,
65};
66use projectatlas_core::language::{ContentClassification, ContentSelection};
67use projectatlas_core::symbols::{
68    CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
69    SymbolRelation, SymbolSourceSelector,
70};
71use projectatlas_core::telemetry::{
72    TelemetryContractError, TokenOverview, TokenTrendReport, TokenTrendWindow, UsageEvent,
73    UsageInstanceId, UsageInstanceOwner,
74};
75use projectatlas_core::{
76    AGENT_REVIEWED_SOURCE_VALUES, CanonicalProjectRoot, CoreError, HIGH_IMPACT_FILE_NAMES,
77    HIGH_IMPACT_PATH_PREFIXES, HIGH_IMPACT_PATH_SEGMENTS, IndexGeneration, IndexWorkControl,
78    IndexWorkFailure, IndexWorkStage, IndexedNode, LEGACY_HUMAN_PURPOSE_SOURCE, Node, NodeKind,
79    Overview, Purpose, PurposeSource, PurposeStatus, normalize_native_path_display,
80    normalize_repo_path_prefix,
81};
82use rusqlite::types::Value;
83use rusqlite::{
84    Connection, ErrorCode, OpenFlags, OptionalExtension, TransactionBehavior, params,
85    params_from_iter,
86};
87use serde::{Deserialize, Serialize};
88use std::cell::{Cell, RefCell};
89use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
90use std::num::{ParseIntError, TryFromIntError};
91use std::ops::{Deref, DerefMut};
92use std::path::{Path, PathBuf};
93use std::time::Duration;
94use thiserror::Error;
95
96#[cfg(all(test, windows))]
97use schema::PREVIOUS_SCHEMA_VERSION;
98use schema::{
99    FILE_TEXT_FTS_PROJECTION_REVISION_KEY, FILE_TEXT_FTS_SOURCE_REVISION_KEY,
100    INDEX_PUBLICATION_FINGERPRINT_KEY, INDEX_PUBLICATION_GENERATION_KEY,
101    INDEX_PUBLICATION_STATE_KEY, PROJECT_ROOT_KEY, SchemaState,
102};
103#[cfg(test)]
104use schema::{SCHEMA_VERSION, SCHEMA_VERSION_KEY, sqlite_sidecar_path};
105use sqlite_profile::{
106    DatabaseLocation, JournalModePolicy, SQLITE_BUSY_TIMEOUT, open_writable_connection,
107};
108
109/// Maximum persisted text for denormalized symbol-name search summaries.
110const MAX_SYMBOL_SEARCH_SUMMARY_CHARS: usize = 16_000;
111/// Publication acquisition fails fast so callers must restage after contention.
112const SQLITE_PUBLICATION_ACQUIRE_TIMEOUT: Duration = Duration::ZERO;
113/// Ancillary telemetry must not delay a valid navigation result under contention.
114const SQLITE_TELEMETRY_BUSY_TIMEOUT: Duration = Duration::from_millis(25);
115/// Maximum paths admitted to one purpose-curation hydration statement.
116pub const MAX_PURPOSE_CURATION_BATCH_ROWS: usize = 200;
117/// `SQLite` schema version supported by this runtime.
118pub const CURRENT_SCHEMA_VERSION: i64 = schema::SCHEMA_VERSION;
119/// Maximum FTS candidates decoded for one exact-verification request.
120pub const MAX_FILE_TEXT_FTS_CANDIDATES: usize = 4_096;
121/// Path-indexed metadata cursor for one exact path-or-descendant fallback scope.
122const FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL: &str = "
123    SELECT text.path, text.content_hash, text.byte_count, text.line_count,
124           classification.classification
125    FROM file_texts AS text
126    LEFT JOIN file_content_classifications AS classification
127      ON classification.path = text.path
128    WHERE text.path = ?1 OR (text.path >= ?2 AND text.path < ?3)
129    ORDER BY text.path
130";
131/// Metadata cursor used when a fallback glob has no safe fixed prefix.
132const FILE_TEXT_FALLBACK_ALL_METADATA_SQL: &str = "
133    SELECT text.path, text.content_hash, text.byte_count, text.line_count,
134           classification.classification
135    FROM file_texts AS text
136    LEFT JOIN file_content_classifications AS classification
137      ON classification.path = text.path
138    ORDER BY text.path
139";
140/// Exact authoritative content hydration after service-owned admission.
141const FILE_TEXT_FALLBACK_CONTENT_SQL: &str = "SELECT content FROM file_texts WHERE path = ?1";
142/// Maximum UTF-8 bytes retained in one host-owned purpose task label.
143const MAX_PURPOSE_CURATION_TASK_BYTES: usize = 256;
144/// Domain separator for deterministic purpose-curation item work keys.
145const PURPOSE_CURATION_ITEM_KEY_DOMAIN: &str = "projectatlas:purpose-curation:item:v1";
146/// Domain separator for deterministic purpose-curation row-state tokens.
147const PURPOSE_CURATION_STATE_TOKEN_DOMAIN: &str = "projectatlas:purpose-curation:state:v1";
148/// Domain separator for deterministic purpose-curation batch work keys.
149const PURPOSE_CURATION_BATCH_KEY_DOMAIN: &str = "projectatlas:purpose-curation:batch:v1";
150/// Monotonic metadata revision for accepted authored-purpose mutations.
151const AUTHORED_PURPOSE_REVISION_KEY: &str = "purpose.authored_revision";
152/// Select create capability only for a path proven absent by preflight.
153fn writable_open_flags(state: SchemaState, database_exists: bool) -> OpenFlags {
154    match (state, database_exists) {
155        (SchemaState::Fresh, false) => {
156            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
157        }
158        (SchemaState::Fresh | SchemaState::Current | SchemaState::UpgradeRequired, true)
159        | (SchemaState::Current | SchemaState::UpgradeRequired, false) => {
160            OpenFlags::SQLITE_OPEN_READ_WRITE
161        }
162    }
163}
164
165/// Establish WAL only while creating or upgrading an admitted database.
166const fn writable_journal_policy(state: SchemaState) -> JournalModePolicy {
167    match state {
168        SchemaState::Current => JournalModePolicy::RequireWal,
169        SchemaState::Fresh | SchemaState::UpgradeRequired => JournalModePolicy::EnsureWal,
170    }
171}
172
173/// Database-layer error type.
174#[derive(Debug, Error)]
175pub enum DbError {
176    /// `SQLite` operation failed.
177    #[error("sqlite error: {0}")]
178    Sqlite(#[from] rusqlite::Error),
179    /// A live project database is located on a known unsupported filesystem.
180    #[error(
181        "database path {path:?} is on an unsupported filesystem (mount: {mount_point:?}, type: {filesystem_type:?}); live SQLite WAL requires supported local storage"
182    )]
183    DatabaseFilesystemUnsupported {
184        /// Database path rejected before writable access.
185        path: PathBuf,
186        /// Resolved mount point when one was safely available.
187        mount_point: Option<PathBuf>,
188        /// Normalized filesystem type when one was safely available.
189        filesystem_type: Option<String>,
190    },
191    /// Local WAL-safe filesystem placement could not be proved.
192    #[error(
193        "database path {path:?} has uncertain filesystem placement (mount: {mount_point:?}, type: {filesystem_type:?}): {reason}"
194    )]
195    DatabaseFilesystemUncertain {
196        /// Database path rejected before writable access.
197        path: PathBuf,
198        /// Resolved mount point when one was safely available.
199        mount_point: Option<PathBuf>,
200        /// Normalized filesystem type when one was safely available.
201        filesystem_type: Option<String>,
202        /// Bounded reason the local profile could not be established.
203        reason: String,
204    },
205    /// `SQLite` did not retain a required connection operating-profile value.
206    #[error("SQLite operating profile mismatch for {setting}: expected {expected}, found {found}")]
207    DatabaseOperatingProfile {
208        /// Connection or durable setting that did not match.
209        setting: &'static str,
210        /// Required value.
211        expected: String,
212        /// Observed value.
213        found: String,
214    },
215    /// A persisted or requested graph value violates the typed domain contract.
216    #[error("repository graph contract error: {0}")]
217    GraphContract(#[from] GraphContractError),
218    /// A telemetry identity violates its typed domain contract.
219    #[error("telemetry contract error: {0}")]
220    TelemetryContract(#[from] TelemetryContractError),
221    /// A requested worktree alias violates the public registry contract.
222    #[error("invalid worktree alias {alias:?}: {reason}")]
223    InvalidWorktreeAlias {
224        /// Rejected caller value.
225        alias: String,
226        /// Stable validation reason.
227        reason: &'static str,
228    },
229    /// No active worktree registration has the requested alias.
230    #[error("active worktree registration {alias:?} was not found")]
231    WorktreeRegistrationNotFound {
232        /// Requested normalized alias.
233        alias: String,
234    },
235    /// A registration identity is already owned by another active worktree.
236    #[error("worktree registration {field} conflicts with active value {value:?}")]
237    WorktreeRegistrationConflict {
238        /// Conflicting identity field.
239        field: &'static str,
240        /// Bounded caller-visible value.
241        value: String,
242    },
243    /// A legacy active registration would violate native identity uniqueness.
244    #[error(
245        "worktree registration migration found duplicate active native {field} identities in registrations {first_registration_id} and {second_registration_id}; repair one legacy row and retry"
246    )]
247    WorktreeRegistrationMigrationConflict {
248        /// Native identity column whose uniqueness would be violated.
249        field: &'static str,
250        /// Lowest stable registration ID in the collision.
251        first_registration_id: i64,
252        /// Highest stable registration ID in the collision.
253        second_registration_id: i64,
254    },
255    /// A legacy path projection cannot establish its original native identity.
256    #[error(
257        "worktree registration migration cannot establish native {field} identity for registration {registration_id}; repair the legacy row and retry"
258    )]
259    WorktreeRegistrationMigrationIdentityUnavailable {
260        /// Legacy path field whose native identity cannot be recovered safely.
261        field: &'static str,
262        /// Stable registration ID that needs repair.
263        registration_id: i64,
264    },
265    /// The bounded worktree-registration catalog is full.
266    #[error("worktree registration capacity {limit} is exhausted")]
267    WorktreeRegistrationCapacity {
268        /// Maximum active and retired registrations retained by one control atlas.
269        limit: usize,
270    },
271    /// A registration path is not an absolute caller-validated structural identity.
272    #[error("invalid worktree registration {field} path {path:?}")]
273    InvalidWorktreeRegistrationPath {
274        /// Path responsibility being validated.
275        field: &'static str,
276        /// Rejected normalized path.
277        path: String,
278    },
279    /// A persisted worktree registration row violates its typed contract.
280    #[error("invalid worktree registration row: {reason}")]
281    WorktreeRegistrationRow {
282        /// Stable validation failure.
283        reason: &'static str,
284    },
285    /// A worktree aggregate snapshot belongs to a different initialized atlas.
286    #[error("worktree telemetry project identity does not match registration {registration_id}")]
287    WorktreeTelemetryProjectMismatch {
288        /// Stable control-database registration identity.
289        registration_id: i64,
290    },
291    /// A bounded worktree telemetry snapshot resource ceiling was exceeded.
292    #[error("worktree telemetry snapshot {resource} exceeded limit {limit}: observed {observed}")]
293    WorktreeTelemetrySnapshotLimit {
294        /// Resource whose fixed bound was exceeded.
295        resource: &'static str,
296        /// Maximum admitted value.
297        limit: usize,
298        /// Observed rejected value.
299        observed: usize,
300    },
301    /// One telemetry runtime instance was reused across incompatible origins.
302    #[error("telemetry runtime instance has a conflicting worktree origin")]
303    WorktreeTelemetryOriginConflict,
304    /// A worktree hydration request violates its target-local safety contract.
305    #[error("invalid worktree hydration request: {reason}")]
306    WorktreeHydrationInvalid {
307        /// Stable rejection reason.
308        reason: &'static str,
309    },
310    /// A hydration destination already contains a database and cannot be replaced.
311    #[error("worktree hydration destination already exists: {path:?}")]
312    WorktreeHydrationDestinationExists {
313        /// Existing destination preserved without mutation.
314        path: PathBuf,
315    },
316    /// Worktree hydration could not create, clean, or activate its private candidate.
317    #[error("worktree hydration I/O failed for {path:?}: {source}")]
318    WorktreeHydrationIo {
319        /// Candidate or destination path involved in the failure.
320        path: PathBuf,
321        /// Underlying filesystem failure.
322        #[source]
323        source: std::io::Error,
324    },
325    /// Online backup could not make progress within its fixed busy retry budget.
326    #[error("worktree hydration backup remained busy after {attempts} attempts")]
327    WorktreeHydrationBackupBusy {
328        /// Consecutive busy or locked backup steps.
329        attempts: usize,
330    },
331    /// The candidate has not completed a post-hydration source reconciliation.
332    #[error(
333        "worktree hydration candidate is not reconciled: baseline generation {baseline}, found {found}"
334    )]
335    WorktreeHydrationNotReconciled {
336        /// Generation published by baseline rebinding.
337        baseline: IndexGeneration,
338        /// Current complete generation, or zero when unavailable.
339        found: IndexGeneration,
340    },
341    /// Persisted graph project identity differs from the selected identity.
342    #[error(
343        "repository graph project identity {found} does not match selected identity {expected}"
344    )]
345    GraphProjectIdentityMismatch {
346        /// Project identity selected by the caller.
347        expected: String,
348        /// Project identity stored in the database.
349        found: String,
350    },
351    /// A graph query requires one complete nonzero publication generation.
352    #[error("repository graph is unavailable without a complete published generation")]
353    GraphPublicationUnavailable,
354    /// A normalized graph row has an impossible column shape.
355    #[error("invalid {table} row: {reason}")]
356    GraphRowShape {
357        /// Owning normalized graph table.
358        table: &'static str,
359        /// Stable shape diagnostic.
360        reason: &'static str,
361    },
362    /// A derived graph snapshot violates its bounded portable contract.
363    #[error("invalid derived graph snapshot: {reason}")]
364    DerivedSnapshotInvalid {
365        /// Stable validation failure.
366        reason: &'static str,
367    },
368    /// A derived graph snapshot exceeded one declared resource ceiling.
369    #[error(
370        "derived graph snapshot {resource} exceeds the limit: found {found}, maximum {maximum}"
371    )]
372    DerivedSnapshotLimit {
373        /// Bounded resource that was exceeded.
374        resource: &'static str,
375        /// Observed amount.
376        found: u64,
377        /// Maximum admitted amount.
378        maximum: u64,
379    },
380    /// A private snapshot capture could not be created or cleaned up.
381    #[error("derived graph snapshot I/O failed for {path:?}: {source}")]
382    DerivedSnapshotIo {
383        /// Private temporary path involved in the operation.
384        path: PathBuf,
385        /// Underlying filesystem failure.
386        #[source]
387        source: std::io::Error,
388    },
389    /// A portable snapshot payload was not valid JSON.
390    #[error("derived graph snapshot JSON is invalid: {0}")]
391    DerivedSnapshotJson(#[from] serde_json::Error),
392    /// Persisted per-file symbol rows do not match their owning parser metadata.
393    #[error("invalid persisted symbol graph for {path:?}: {reason}")]
394    SymbolGraphRowShape {
395        /// Repository path whose persisted graph is inconsistent.
396        path: String,
397        /// Stable shape diagnostic.
398        reason: &'static str,
399    },
400    /// Equal scoped resolution digests retained different canonical witnesses.
401    #[error("resolution-key collision in {domain} for digest {digest:?}")]
402    ResolutionKeyCollision {
403        /// Closed resolution domain containing the conflict.
404        domain: &'static str,
405        /// Fixed digest shared by conflicting witnesses.
406        digest: [u8; 32],
407    },
408    /// A normalized binary graph field has the wrong width.
409    #[error("invalid {field} blob length {found}; expected {expected}")]
410    InvalidBlobLength {
411        /// Owning database field.
412        field: &'static str,
413        /// Required fixed byte width.
414        expected: usize,
415        /// Observed byte width.
416        found: usize,
417    },
418    /// An unsigned graph count cannot be represented by `SQLite`.
419    #[error("graph count for {field} exceeds SQLite integer range: {value}")]
420    GraphCountOverflow {
421        /// Owning database field.
422        field: &'static str,
423        /// Unsigned domain value that exceeded the database range.
424        value: u64,
425    },
426    /// Schema version is not supported.
427    #[error("unsupported schema version {found}, expected {expected}")]
428    SchemaVersion {
429        /// Version found in database.
430        found: i64,
431        /// Expected version.
432        expected: i64,
433    },
434    /// An existing database has no durable schema version.
435    #[error("existing database is missing schema_version metadata")]
436    SchemaVersionMissing,
437    /// A durable `SQLite` object does not match the supported schema contract.
438    #[error("incompatible schema object {object:?}: expected {expected}, found {found}")]
439    SchemaShape {
440        /// Table, index, or column whose shape is incompatible.
441        object: String,
442        /// Required `SQLite` object kind.
443        expected: String,
444        /// Observed `SQLite` object kind.
445        found: String,
446    },
447    /// `SQLite` integrity validation failed before migration.
448    #[error("database integrity check failed: {message}")]
449    IntegrityCheck {
450        /// Bounded `SQLite` integrity diagnostic.
451        message: String,
452    },
453    /// A migration did not reach the supported current schema.
454    #[error("schema migration did not reach expected version {expected}")]
455    SchemaPostcondition {
456        /// Version required after migration.
457        expected: i64,
458    },
459    /// A source-owned database has no durable project identity.
460    #[error("existing database is missing project_root metadata")]
461    ProjectRootMissing,
462    /// A source-owned database belongs to another project root.
463    #[error("database project root {found:?} does not match selected root {expected:?}")]
464    ProjectRootMismatch {
465        /// Canonical root selected by the caller.
466        expected: String,
467        /// Durable root recorded in `SQLite`.
468        found: String,
469        /// Lossless native identities when the admission proof had both roots.
470        identities: Option<Box<ProjectRootMismatchIdentities>>,
471    },
472    /// A native project-root identity or its lossless codec is invalid.
473    #[error("project-root identity error: {0}")]
474    ProjectRootIdentity(#[from] CoreError),
475    /// A current bound database has no lossless native project-root identity.
476    #[error("bound project database is missing canonical project-root identity")]
477    ProjectRootIdentityMissing,
478    /// Explicit legacy adoption requires the intact released schema-19 state.
479    #[error(
480        "legacy root adoption requires an intact schema-19 database at the selected root's .projectatlas/projectatlas.db, without native root identity"
481    )]
482    LegacyRootAdoptionUnavailable,
483    /// Adoption committed, but its database location could not be verified afterward.
484    #[error(
485        "legacy root adoption committed, but the selected database location changed or could not be verified; stop recovery and preserve both database locations and their WAL/SHM sidecars: {source}"
486    )]
487    LegacyRootAdoptionCommittedLocationChanged {
488        /// Post-commit location or opened-file identity failure; no rollback is claimed.
489        #[source]
490        source: Box<DbError>,
491    },
492    /// A root transition destination is not an absolute existing directory.
493    #[error("invalid project root transition destination {root:?}: {source}")]
494    ProjectRootDestinationInvalid {
495        /// Destination rejected before database preflight or mutation.
496        root: String,
497        /// Filesystem or input failure that made the destination invalid.
498        #[source]
499        source: std::io::Error,
500    },
501    /// A move or detach requires a previously bound project root.
502    #[error("project root transition requires an existing bound root")]
503    ProjectRootTransitionRequiresExistingRoot,
504    /// A move must select a destination different from the old root.
505    #[error("project root move destination {root:?} matches the existing root")]
506    ProjectRootTransitionRequiresDifferentRoot {
507        /// Root that was selected as both source and destination.
508        root: String,
509    },
510    /// A verified move cannot preserve identity while the old root still exists.
511    #[error("project root {root:?} still exists; use detach for an independent copy")]
512    ProjectRootStillPresent {
513        /// Recorded old root that remains accessible.
514        root: String,
515    },
516    /// Filesystem state could not prove that a move's old root is absent.
517    #[error("cannot prove project root {root:?} is absent: {source}")]
518    ProjectRootAbsenceUncertain {
519        /// Recorded old root whose state is uncertain.
520        root: String,
521        /// Filesystem failure that prevents an absence proof.
522        #[source]
523        source: std::io::Error,
524    },
525    /// Root or identity state changed after transition preflight.
526    #[error(
527        "project root transition state changed: root {expected_root:?} -> {found_root:?}, identity {expected_identity:?} -> {found_identity:?}"
528    )]
529    ProjectRootTransitionChanged {
530        /// Root captured by read-only preflight.
531        expected_root: Option<String>,
532        /// Root observed inside the write transaction.
533        found_root: Option<String>,
534        /// Identity captured by read-only preflight.
535        expected_identity: Option<String>,
536        /// Identity observed inside the write transaction.
537        found_identity: Option<String>,
538    },
539    /// A bound project database has no durable instance identity.
540    #[error("bound project database is missing project instance identity")]
541    ProjectInstanceIdentityMissing,
542    /// `SQLite` did not yield a usable nonzero project identity.
543    #[error("failed to generate a distinct nonzero project instance identity")]
544    ProjectInstanceIdentityGenerationFailed,
545    /// A transaction failed and the explicit rollback also failed.
546    #[error("{operation}; rollback also failed: {rollback}")]
547    TransactionRollback {
548        /// Primary operation failure that caused rollback.
549        #[source]
550        operation: Box<DbError>,
551        /// Secondary rollback failure retained for diagnosis.
552        rollback: rusqlite::Error,
553    },
554    /// Publication acquisition failed and its standard busy policy was not restored.
555    #[error(
556        "publication writer acquisition failed: {operation}; restoring the standard busy policy also failed: {restore}"
557    )]
558    PublicationAcquirePolicyRestore {
559        /// Writer-acquisition failure observed with fail-fast busy handling.
560        #[source]
561        operation: Box<rusqlite::Error>,
562        /// Failure restoring the ordinary connection busy policy.
563        restore: Box<rusqlite::Error>,
564    },
565    /// Invalid enum value read from the database.
566    #[error("invalid {field} value in database: {value}")]
567    InvalidEnum {
568        /// Field name.
569        field: &'static str,
570        /// Invalid value.
571        value: String,
572    },
573    /// A literal cannot safely use the complete-candidate FTS path.
574    #[error("literal token cannot use FTS acceleration: {reason}")]
575    FileTextFtsTokenUnsafe {
576        /// Stable rejection reason for service fallback selection.
577        reason: &'static str,
578    },
579    /// One FTS request exceeded the storage-owned candidate bound.
580    #[error("FTS candidate request {requested} exceeds the maximum {maximum}")]
581    FileTextFtsCandidateLimit {
582        /// Requested exact-verification candidates.
583        requested: usize,
584        /// Storage-owned hard maximum.
585        maximum: usize,
586    },
587    /// `SQLite` returned a non-finite lexical ranking score.
588    #[error("invalid FTS BM25 score for {path:?}")]
589    FileTextFtsScoreInvalid {
590        /// Candidate path whose score was invalid.
591        path: String,
592    },
593    /// Durable FTS synchronization metadata is absent or contradictory.
594    #[error("invalid FTS synchronization state: {reason}")]
595    FileTextFtsStateInvalid {
596        /// Stable reason that makes the acceleration state untrustworthy.
597        reason: &'static str,
598    },
599    /// Persisted text metadata disagrees with its authoritative UTF-8 content.
600    #[error("file text {path:?} has invalid {field}: recorded {recorded}, actual {actual}")]
601    FileTextMetadataMismatch {
602        /// Repository-relative path owning the invalid text row.
603        path: String,
604        /// Metadata field that disagrees with content.
605        field: &'static str,
606        /// Persisted or caller-supplied value.
607        recorded: usize,
608        /// Value derived from authoritative content.
609        actual: usize,
610    },
611    /// A bounded database read observed cancellation or its deadline.
612    #[error("{0}")]
613    IndexWork(#[from] IndexWorkFailure),
614    /// Count value from `SQLite` could not fit its owning unsigned domain type.
615    #[error("invalid count for {field}: {value}")]
616    InvalidCount {
617        /// Count field name.
618        field: &'static str,
619        /// Invalid database count.
620        value: i64,
621        /// Source conversion error.
622        source: TryFromIntError,
623    },
624    /// Integer metadata could not be parsed without losing its source error.
625    #[error("invalid integer metadata for {field}: {value:?}: {source}")]
626    InvalidInteger {
627        /// Metadata field name.
628        field: &'static str,
629        /// Invalid persisted value.
630        value: String,
631        /// Source parse failure.
632        source: ParseIntError,
633    },
634    /// Unsigned integer metadata could not advance without overflow.
635    #[error("integer metadata for {field} overflowed at {value}")]
636    IntegerMetadataOverflow {
637        /// Metadata key owning the monotonic value.
638        field: &'static str,
639        /// Largest persisted value that could not advance.
640        value: u64,
641    },
642    /// A caller supplied a path that is not in the current index.
643    #[error("path {path:?} is not indexed; run scan, fix the path, or choose an indexed path")]
644    PathNotIndexed {
645        /// Repository-relative path.
646        path: String,
647    },
648    /// One classification write/read batch exceeded its fixed path ceiling.
649    #[error("file classification batch requested {requested} paths; maximum is {maximum}")]
650    FileContentClassificationBatchTooLarge {
651        /// Unique paths requested.
652        requested: usize,
653        /// Storage-owned hard maximum.
654        maximum: usize,
655    },
656    /// One classification batch repeated an exact path.
657    #[error("file classification batch repeats path {path:?}")]
658    FileContentClassificationDuplicatePath {
659        /// Repeated repository-relative path.
660        path: String,
661    },
662    /// A current admitted file has no classification in the active transaction.
663    #[error("current file {path:?} has no content classification")]
664    FileContentClassificationMissing {
665        /// Current repository-relative file path.
666        path: String,
667    },
668    /// A classification row outlived current file ownership.
669    #[error("content classification for {path:?} does not belong to a current file")]
670    FileContentClassificationNotCurrent {
671        /// Stale or non-file repository-relative path.
672        path: String,
673    },
674    /// One classification/path page requested an invalid row limit.
675    #[error("file classification page requested {requested} rows; maximum is {maximum}")]
676    FileContentClassificationLimit {
677        /// Requested page rows.
678        requested: u32,
679        /// Storage-owned hard maximum.
680        maximum: u32,
681    },
682    /// A purpose-curation task label is blank, unsafe, or too large.
683    #[error("invalid purpose-curation task label: {reason}")]
684    PurposeCurationTaskInvalid {
685        /// Stable validation reason.
686        reason: &'static str,
687    },
688    /// A purpose-curation hydration request exceeds the bounded statement size.
689    #[error("purpose-curation batch requested {requested} paths; maximum is {maximum}")]
690    PurposeCurationBatchTooLarge {
691        /// Number of unique paths requested.
692        requested: usize,
693        /// Maximum paths admitted to one prepared set query.
694        maximum: usize,
695    },
696    /// A caller attempted to resolve a health finding that is not currently active.
697    #[error(
698        "health finding {finding_id:?} with category {category:?} and path {path:?} is not active; run health-check and use an exact finding id/path/category"
699    )]
700    HealthFindingNotActive {
701        /// Requested finding id.
702        finding_id: String,
703        /// Requested category.
704        category: String,
705        /// Requested primary path.
706        path: String,
707    },
708    /// A projection-only refresh no longer matches the established index contract.
709    #[error("index publication contract changed during projection refresh")]
710    PublicationContractChanged,
711    /// Prepared publication work was based on a generation that is no longer current.
712    #[error("index publication base generation changed: expected {expected}, found {found}")]
713    PublicationBaseGenerationChanged {
714        /// Complete generation used to prepare the publication batch.
715        expected: IndexGeneration,
716        /// Complete generation observed after reserving the writer transaction.
717        found: IndexGeneration,
718    },
719    /// A complete index generation cannot advance any further.
720    #[error("index publication generation overflowed")]
721    PublicationGenerationOverflow,
722    /// A full scan replacement has not removed its remaining absent projections.
723    #[error("index publication cannot complete before scan replacement finishes")]
724    ScanReplacementIncomplete,
725    /// The store already has an active read snapshot.
726    #[error("index read snapshot is already active on this store")]
727    IndexReadSnapshotActive,
728    /// A read-only store cannot locate its database for a separate telemetry write.
729    #[error("read-only store has no database path for telemetry persistence")]
730    TelemetryPathUnavailable,
731    /// One telemetry field exceeds its declared UTF-8 byte budget.
732    #[error("telemetry field {field} uses {bytes} UTF-8 bytes; limit is {limit}")]
733    TelemetryFieldTooLarge {
734        /// Stable field owner.
735        field: &'static str,
736        /// Observed UTF-8 byte count.
737        bytes: usize,
738        /// Maximum admitted UTF-8 byte count.
739        limit: usize,
740    },
741    /// A telemetry retention policy bound cannot make forward progress.
742    #[error("invalid telemetry retention limit for {field}: {value}")]
743    TelemetryLimitInvalid {
744        /// Stable retention-policy field.
745        field: &'static str,
746        /// Rejected policy value.
747        value: usize,
748    },
749    /// A telemetry counter cannot be represented exactly in `SQLite`.
750    #[error("telemetry integer overflow for {field}")]
751    TelemetryIntegerOverflow {
752        /// Stable counter owner.
753        field: &'static str,
754    },
755    /// A runtime attempted to reuse a sealed or expired instance.
756    #[error("telemetry usage instance is sealed or expired")]
757    TelemetryInstanceInactive,
758    /// Operating-system randomness was unavailable for optional telemetry identity creation.
759    #[error("telemetry runtime identity is unavailable")]
760    TelemetryIdentityUnavailable,
761    /// A runtime identity was reused with incompatible durable instance metadata.
762    #[error("telemetry runtime identity does not match its retained owner or caller label")]
763    TelemetryInstanceMismatch,
764    /// The bounded active-instance capacity is exhausted.
765    #[error("telemetry active-instance capacity is exhausted")]
766    TelemetryInstanceCapacity,
767    /// The bounded active-baseline capacity is exhausted.
768    #[error("telemetry modeled-baseline capacity is exhausted")]
769    TelemetryBaselineCapacity,
770    /// A compact modeled-baseline key collided with different witness material.
771    #[error("telemetry modeled-baseline key collided with different witness material")]
772    TelemetryBaselineCollision,
773}
774
775impl DbError {
776    /// Return a schema version with a complete migration route owned by this runtime.
777    #[must_use]
778    pub fn supported_schema_migration(&self) -> Option<(i64, i64, u32)> {
779        match self {
780            Self::SchemaVersion { found, expected } => schema::migration_steps_remaining(*found)
781                .filter(|steps| *steps > 0)
782                .map(|steps| (*found, *expected, steps)),
783            _ => None,
784        }
785    }
786
787    /// Return a schema version rejected by this runtime's migration inventory.
788    #[must_use]
789    pub fn unsupported_schema_version(&self) -> Option<(i64, i64)> {
790        match self {
791            Self::SchemaVersion { found, expected }
792                if schema::migration_steps_remaining(*found).is_none() =>
793            {
794                Some((*found, *expected))
795            }
796            _ => None,
797        }
798    }
799
800    /// Return whether a derived-index write could not proceed without implying
801    /// corruption, schema drift, or an identity-contract failure.
802    #[must_use]
803    pub fn is_write_unavailable(&self) -> bool {
804        match self {
805            Self::Sqlite(error) => matches!(
806                error.sqlite_error_code(),
807                Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked | ErrorCode::ReadOnly)
808            ),
809            _ => false,
810        }
811    }
812}
813
814/// Lossless native identities involved in a project-root mismatch.
815#[derive(Clone, Debug, Eq, PartialEq)]
816pub struct ProjectRootMismatchIdentities {
817    /// Canonical root selected by the caller.
818    pub expected: CanonicalProjectRoot,
819    /// Canonical root recorded by the opened index.
820    pub found: CanonicalProjectRoot,
821}
822
823/// Convenient result alias for database operations.
824pub type DbResult<T> = Result<T, DbError>;
825
826/// Maximum exact paths admitted to one bounded symbol hydration request.
827pub const MAX_SYMBOL_BATCH_PATHS: u32 = 64;
828/// Maximum symbol rows admitted to one bounded hydration request.
829pub const MAX_SYMBOL_BATCH_ROWS: u32 = 4_096;
830/// Maximum decoded symbol bytes admitted to one bounded hydration request.
831pub const MAX_SYMBOL_BATCH_DECODED_BYTES: u64 = 4 * 1_024 * 1_024;
832/// Paths bound per statement, below supported `SQLite` variable ceilings.
833const SYMBOL_BATCH_BIND_PATHS: usize = 48;
834
835/// One persisted symbol with the classification of its owning file.
836#[derive(Clone, Debug, Eq, PartialEq)]
837pub struct ClassifiedSymbol {
838    /// Persisted symbol detail.
839    pub symbol: CodeSymbol,
840    /// Registry-owned content role for the symbol's file.
841    pub classification: ContentClassification,
842}
843
844/// Typed database envelope for one exact-path symbol batch.
845#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846pub struct SymbolBatchReadBudget {
847    /// Maximum unique exact repository paths.
848    paths: u32,
849    /// Maximum decoded symbol rows.
850    rows: u32,
851    /// Maximum retained Rust row and owned string allocation bytes.
852    decoded_bytes: u64,
853}
854
855impl SymbolBatchReadBudget {
856    /// Construct one bounded exact-path symbol read envelope.
857    ///
858    /// # Errors
859    ///
860    /// Returns an error when a limit is zero or above its product ceiling.
861    pub fn new(paths: u32, rows: u32, decoded_bytes: u64) -> DbResult<Self> {
862        if paths == 0
863            || paths > MAX_SYMBOL_BATCH_PATHS
864            || rows == 0
865            || rows > MAX_SYMBOL_BATCH_ROWS
866            || decoded_bytes == 0
867            || decoded_bytes > MAX_SYMBOL_BATCH_DECODED_BYTES
868        {
869            return Err(GraphContractError::InvalidLimits {
870                reason: "symbol batch limit is zero or above the product ceiling",
871            }
872            .into());
873        }
874        Ok(Self {
875            paths,
876            rows,
877            decoded_bytes,
878        })
879    }
880
881    /// Return the exact-path ceiling.
882    #[must_use]
883    pub const fn paths(self) -> u32 {
884        self.paths
885    }
886
887    /// Return the decoded-row ceiling.
888    #[must_use]
889    pub const fn rows(self) -> u32 {
890        self.rows
891    }
892
893    /// Return the decoded-byte ceiling.
894    #[must_use]
895    pub const fn decoded_bytes(self) -> u64 {
896        self.decoded_bytes
897    }
898}
899
900/// Exact work retained by one bounded symbol batch read.
901#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
902pub struct SymbolBatchReadWork {
903    /// Unique exact paths supplied to `SQLite`.
904    pub requested_paths: u32,
905    /// Symbol rows retained after all bounds.
906    pub returned_rows: u32,
907    /// Retained Rust row and owned string allocation bytes after all bounds.
908    pub decoded_bytes: u64,
909}
910
911/// Bounded symbols and truncation state returned for exact paths.
912#[derive(Clone, Debug, Default, Eq, PartialEq)]
913pub struct SymbolBatchRead {
914    /// Deterministically ordered persisted symbols.
915    pub rows: Vec<CodeSymbol>,
916    /// Whether a path, row, or decoded-byte bound omitted rows.
917    pub truncated: bool,
918    /// First deterministic database limit that omitted symbol rows.
919    pub reached_limit: Option<SymbolBatchReadLimit>,
920    /// Exact work retained by this database read.
921    pub work: SymbolBatchReadWork,
922}
923
924/// Closed database limits that can truncate an exact-path symbol batch.
925#[derive(Clone, Copy, Debug, Eq, PartialEq)]
926pub enum SymbolBatchReadLimit {
927    /// More unique paths were requested than the batch admits.
928    Paths,
929    /// More symbol rows matched than the batch admits.
930    Rows,
931    /// The next symbol row crossed the decoded-byte envelope.
932    DecodedBytes,
933}
934
935/// Durable state of the multi-projection derived index publication.
936#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
937#[serde(rename_all = "snake_case")]
938pub enum IndexPublicationState {
939    /// A publisher may have changed only part of the derived index.
940    Updating,
941    /// Every projection completed under the recorded contract fingerprint.
942    Complete,
943}
944
945impl IndexPublicationState {
946    /// Return the stable `SQLite` representation.
947    const fn as_str(self) -> &'static str {
948        match self {
949            Self::Updating => "updating",
950            Self::Complete => "complete",
951        }
952    }
953
954    /// Parse the stable `SQLite` representation.
955    fn from_db(value: String) -> DbResult<Self> {
956        match value.as_str() {
957            "updating" => Ok(Self::Updating),
958            "complete" => Ok(Self::Complete),
959            _ => Err(DbError::InvalidEnum {
960                field: INDEX_PUBLICATION_STATE_KEY,
961                value,
962            }),
963        }
964    }
965}
966
967/// Persisted state needed to reject mixed or incompatible derived projections.
968#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
969pub struct IndexPublication {
970    /// Current publication state.
971    pub state: IndexPublicationState,
972    /// Contract fingerprint recorded by the last complete publication.
973    pub contract_fingerprint: Option<String>,
974    /// Monotonic generation of the last complete derived index.
975    pub generation: IndexGeneration,
976}
977
978/// Publication contract applied when one atomic writer commits.
979enum PublicationContract {
980    /// Establish or replace the complete derived-index contract.
981    Full(String),
982    /// Preserve the already established complete contract.
983    Projection(String),
984}
985
986/// Exclusive parent-owned atomic publication over all derived projections.
987pub struct IndexPublicationGuard<'store> {
988    /// Store whose connection owns the active `SQLite` write transaction.
989    store: &'store mut AtlasStore,
990    /// Contract behavior selected when the publication began.
991    contract: PublicationContract,
992    /// Generation visible before this publication began.
993    previous_generation: IndexGeneration,
994    /// Whether a full scan replacement still needs absent-projection cleanup.
995    scan_replacement_pending: bool,
996    /// Whether drop must roll the transaction back.
997    active: bool,
998}
999
1000/// `SQLite`-backed `ProjectAtlas` index store.
1001pub struct AtlasStore {
1002    /// Active database connection for index reads and writes.
1003    connection: Connection,
1004    /// Whether normal reads currently share one explicit `SQLite` snapshot.
1005    read_snapshot_active: Cell<bool>,
1006    /// Durable database path when the store is file-backed.
1007    database_path: Option<PathBuf>,
1008    /// WAL-safe filesystem identity retained for later ancillary connections.
1009    database_location: Option<DatabaseLocation>,
1010    /// Whether this connection is restricted to non-mutating queries.
1011    read_only: bool,
1012    /// Project root validated for this store, when the database records one.
1013    validated_project_root: Option<String>,
1014    /// Native root identity captured with the validated root binding.
1015    validated_project_root_identity: Option<CanonicalProjectRoot>,
1016    /// Project identity captured with the validated root binding.
1017    validated_project_instance_id: Option<ProjectInstanceId>,
1018    /// Bounded per-label instances used by direct library callers for this handle lifetime.
1019    library_usage_instances: RefCell<HashMap<String, Option<UsageInstanceId>>>,
1020}
1021
1022/// Caller-owned purpose mutation transaction with an explicit commit boundary.
1023///
1024/// Purpose adapters keep this guard alive while they revalidate saved-source
1025/// admission. Dropping it before [`Self::commit`] rolls the mutation back.
1026pub struct PurposeMutationTransaction<'connection> {
1027    /// Immediate transaction rolled back on drop until the caller accepts the mutation.
1028    transaction: rusqlite::Transaction<'connection>,
1029}
1030
1031impl PurposeMutationTransaction<'_> {
1032    /// Commit the admitted purpose mutation.
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns an error when `SQLite` cannot commit the transaction.
1037    pub fn commit(self) -> DbResult<()> {
1038        self.transaction.commit().map_err(Into::into)
1039    }
1040
1041    /// Roll back a rejected purpose mutation and report any storage failure.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns an error when `SQLite` cannot roll the transaction back.
1046    pub fn rollback(self) -> DbResult<()> {
1047        self.transaction.rollback().map_err(Into::into)
1048    }
1049}
1050
1051/// Root and project identity captured when one store binding was validated.
1052#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1053pub struct CapturedProjectBinding {
1054    /// Stable project identity captured at open or explicit root transition.
1055    pub project_instance_id: ProjectInstanceId,
1056    /// Lossless UTF-8 display of the local source root, when one exists.
1057    ///
1058    /// `None` is the typed unavailable state for native roots that cannot be
1059    /// represented as UTF-8. Identity-critical callers must use the native
1060    /// [`AtlasStore::project_root_identity`] value instead.
1061    pub project_root: Option<String>,
1062    /// Lossless native root identity captured with the project identity.
1063    #[serde(skip)]
1064    pub project_root_identity: CanonicalProjectRoot,
1065}
1066
1067/// Lightweight persisted import fact used by alias resolution.
1068#[derive(Clone, Debug, Eq, PartialEq)]
1069pub struct StoredImportRelation {
1070    /// Repository-relative source path that owns the import.
1071    pub path: String,
1072    /// Parser-selected source declaration or module.
1073    pub source_name: String,
1074    /// Original persisted import text.
1075    pub target_name: String,
1076    /// One-based source line.
1077    pub line: usize,
1078}
1079
1080/// One unapproved purpose row bound to deterministic curator work.
1081#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1082pub struct PurposeCurationCandidate {
1083    /// Current indexed node, purpose, and summary state.
1084    pub node: IndexedNode,
1085    /// Project/generation/task/path work identity used to coalesce duplicate work.
1086    pub work_key: String,
1087    /// Opaque token binding conditional apply to this exact unapproved purpose row.
1088    pub state_token: String,
1089}
1090
1091/// Bounded purpose-curation work selected from one project generation.
1092#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1093pub struct PurposeCurationBatch {
1094    /// Stable selected-project identity.
1095    pub project_instance_id: ProjectInstanceId,
1096    /// Active graph/index generation observed with the queue rows.
1097    pub active_generation: IndexGeneration,
1098    /// Host-supplied task label used to coalesce task-local work.
1099    pub task: String,
1100    /// Deterministic identity of the complete returned batch.
1101    pub work_key: String,
1102    /// Current missing or suggested purpose rows, ordered by path.
1103    pub items: Vec<PurposeCurationCandidate>,
1104}
1105
1106/// Result of applying a purpose through the stale-safe curator path.
1107#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1108#[serde(rename_all = "snake_case")]
1109pub enum PurposeConditionalApplyState {
1110    /// The current unapproved row matched and was approved atomically.
1111    Applied,
1112    /// Project, generation, task, path, or purpose row state changed after selection.
1113    Stale,
1114    /// The path now has accepted authored intent and was not overwritten.
1115    Accepted,
1116    /// The selected path is no longer active in the current index.
1117    PathUnavailable,
1118}
1119
1120/// One stale-safe purpose approval copied from a queue item.
1121#[derive(Clone, Debug, Eq, PartialEq)]
1122pub struct PurposeConditionalApplyRequest {
1123    /// Queue task label.
1124    pub task: String,
1125    /// Exact repository-relative path.
1126    pub path: String,
1127    /// Queue item work key.
1128    pub work_key: String,
1129    /// Queue item current-row state token.
1130    pub state_token: String,
1131    /// Agent-reviewed purpose to approve on an exact state match.
1132    pub purpose: String,
1133}
1134
1135/// Per-item outcome from one atomic conditional purpose-review batch.
1136#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1137pub struct PurposeConditionalApplyResult {
1138    /// Exact requested path.
1139    pub path: String,
1140    /// Applied or non-mutating stale/accepted/unavailable outcome.
1141    pub state: PurposeConditionalApplyState,
1142    /// Current purpose state observed or written inside the owning transaction.
1143    pub current_purpose: Option<Purpose>,
1144}
1145
1146/// Identity depth required while opening one current project binding.
1147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1148enum ProjectIdentityRequirement {
1149    /// Ordinary stores require a complete root and identity binding.
1150    Required,
1151    /// An explicit root transition owns identity creation or repair.
1152    TransitionOwned,
1153}
1154
1155impl ProjectIdentityRequirement {
1156    /// Whether a missing identity must fail the open.
1157    const fn is_required(self) -> bool {
1158        matches!(self, Self::Required)
1159    }
1160}
1161
1162impl Deref for IndexPublicationGuard<'_> {
1163    type Target = AtlasStore;
1164
1165    fn deref(&self) -> &Self::Target {
1166        self.store
1167    }
1168}
1169
1170impl DerefMut for IndexPublicationGuard<'_> {
1171    fn deref_mut(&mut self) -> &mut Self::Target {
1172        self.store
1173    }
1174}
1175
1176impl IndexPublicationGuard<'_> {
1177    /// Mark the current scan projection absent before bounded replacement batches.
1178    ///
1179    /// The caller must finish with [`Self::finish_scan_replacement`] before
1180    /// completing this publication. Dropping the guard rolls every partial
1181    /// replacement batch back with the parent transaction.
1182    ///
1183    /// # Errors
1184    ///
1185    /// Returns an error if the scan projection cannot be updated.
1186    pub fn begin_scan_replacement(&mut self) -> DbResult<()> {
1187        mark_all_scan_nodes_absent(&self.store.connection)?;
1188        self.scan_replacement_pending = true;
1189        Ok(())
1190    }
1191
1192    /// Upsert one bounded scan-node batch inside the parent publication.
1193    ///
1194    /// # Errors
1195    ///
1196    /// Returns an error if any node in the batch cannot be persisted.
1197    pub fn upsert_scan_node_batch(&mut self, nodes: &[Node]) -> DbResult<()> {
1198        upsert_nodes(&self.store.connection, nodes)
1199    }
1200
1201    /// Remove derived projections for nodes left absent after replacement.
1202    ///
1203    /// # Errors
1204    ///
1205    /// Returns an error if stale projections cannot be removed.
1206    pub fn finish_scan_replacement(&mut self) -> DbResult<()> {
1207        delete_absent_scan_projections(&self.store.connection)?;
1208        content_classification::validate_complete_file_content_classifications(
1209            &self.store.connection,
1210        )?;
1211        self.scan_replacement_pending = false;
1212        Ok(())
1213    }
1214
1215    /// Commit every derived projection and advance the complete generation
1216    /// exactly once.
1217    ///
1218    /// # Errors
1219    ///
1220    /// Returns an error if generation metadata is invalid, a projection-only
1221    /// refresh no longer matches its established contract, or commit fails.
1222    pub fn complete(mut self) -> DbResult<()> {
1223        if self.scan_replacement_pending {
1224            return Err(DbError::ScanReplacementIncomplete);
1225        }
1226        content_classification::validate_complete_file_content_classifications(
1227            &self.store.connection,
1228        )?;
1229        repository_graph::validate_complete_document_unresolved_reasons(&self.store.connection)?;
1230        let next_generation = self
1231            .previous_generation
1232            .checked_next()
1233            .ok_or(DbError::PublicationGenerationOverflow)?;
1234        match &self.contract {
1235            PublicationContract::Full(contract_fingerprint) => {
1236                set_metadata(
1237                    &self.store.connection,
1238                    INDEX_PUBLICATION_FINGERPRINT_KEY,
1239                    contract_fingerprint,
1240                )?;
1241            }
1242            PublicationContract::Projection(contract_fingerprint) => {
1243                let matches =
1244                    load_index_publication(&self.store.connection)?.is_some_and(|publication| {
1245                        publication.state == IndexPublicationState::Updating
1246                            && publication.contract_fingerprint.as_deref()
1247                                == Some(contract_fingerprint.as_str())
1248                            && publication.generation == self.previous_generation
1249                    });
1250                if !matches {
1251                    return Err(DbError::PublicationContractChanged);
1252                }
1253            }
1254        }
1255        set_metadata(
1256            &self.store.connection,
1257            INDEX_PUBLICATION_GENERATION_KEY,
1258            &next_generation.to_string(),
1259        )?;
1260        set_metadata(
1261            &self.store.connection,
1262            INDEX_PUBLICATION_STATE_KEY,
1263            IndexPublicationState::Complete.as_str(),
1264        )?;
1265        self.store.connection.execute_batch("COMMIT")?;
1266        self.active = false;
1267        Ok(())
1268    }
1269}
1270
1271impl Drop for IndexPublicationGuard<'_> {
1272    fn drop(&mut self) {
1273        if self.active {
1274            let _rollback_result = self.store.connection.execute_batch("ROLLBACK");
1275        }
1276    }
1277}
1278
1279/// UTF-8 source text persisted for indexed search.
1280#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1281pub struct IndexedFileText {
1282    /// Repository-relative file path using forward slashes.
1283    pub path: String,
1284    /// BLAKE3 content hash from the scanned file node.
1285    pub content_hash: Option<String>,
1286    /// UTF-8 byte count stored for telemetry.
1287    pub byte_count: usize,
1288    /// Number of text lines stored for context extraction.
1289    pub line_count: usize,
1290    /// Full UTF-8 source text used by indexed search.
1291    pub content: String,
1292}
1293
1294/// Bounded safe-token request for FTS candidate acceleration.
1295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1296pub struct FileTextFtsQuery<'query> {
1297    /// One ASCII-alphanumeric token that exact lexical matching will verify.
1298    pub literal_token: &'query str,
1299    /// Optional exact path-or-descendant scope.
1300    pub path_prefix: Option<&'query str>,
1301    /// Maximum candidates returned before reporting overflow.
1302    pub limit: usize,
1303}
1304
1305/// One FTS-ranked candidate carrying only authoritative persisted metadata.
1306#[derive(Clone, Debug, PartialEq)]
1307pub struct FileTextFtsCandidate {
1308    /// Repository-relative file path used for budgeted content hydration.
1309    pub path: String,
1310    /// BLAKE3 content hash from the authoritative persisted text row.
1311    pub content_hash: Option<String>,
1312    /// Persisted UTF-8 source byte count used for pre-hydration budgets.
1313    pub byte_count: usize,
1314    /// Persisted line count.
1315    pub line_count: usize,
1316    /// `SQLite` FTS5 BM25 score; lower values rank first.
1317    pub bm25: f64,
1318}
1319
1320/// Bounded FTS candidate page for service-owned exact verification.
1321#[derive(Clone, Debug, PartialEq)]
1322pub struct FileTextFtsPage {
1323    /// Candidates ordered by BM25 and repository path.
1324    pub candidates: Vec<FileTextFtsCandidate>,
1325    /// Whether at least one more candidate matched the bound query.
1326    pub overflow: bool,
1327}
1328
1329/// Persisted file metadata available before source-content decoding.
1330#[derive(Clone, Debug, Eq, PartialEq)]
1331pub struct FileTextMetadata {
1332    /// Repository-relative file path using forward slashes.
1333    pub path: String,
1334    /// BLAKE3 content hash from the scanned file node.
1335    pub content_hash: Option<String>,
1336    /// Persisted UTF-8 source byte count.
1337    pub byte_count: usize,
1338    /// Persisted line count.
1339    pub line_count: usize,
1340    /// Closed content role available before source decoding.
1341    pub classification: ContentClassification,
1342}
1343
1344/// Service decision made before one fallback row decodes source content.
1345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1346pub enum FileTextAdmission {
1347    /// Decode and visit this row's source content.
1348    Read,
1349    /// Continue without decoding this row's source content.
1350    Skip,
1351    /// Stop fallback iteration without decoding this row's source content.
1352    Stop,
1353}
1354
1355/// Content-free synchronization state for the rebuildable FTS projection.
1356#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1357pub struct FileTextFtsState {
1358    /// Authoritative persisted-text rows.
1359    pub source_rows: usize,
1360    /// Document rows currently represented by the FTS projection.
1361    pub indexed_rows: usize,
1362    /// Whether authoritative and projected document identities agree exactly.
1363    pub synchronized: bool,
1364}
1365
1366/// Agent-approved resolution for a deterministic health finding.
1367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1368pub struct HealthResolution {
1369    /// Stable health finding id.
1370    pub finding_id: String,
1371    /// Finding category.
1372    pub category: String,
1373    /// Primary path.
1374    pub path: String,
1375    /// Related path, when any.
1376    pub related_path: Option<String>,
1377    /// Agent rationale for suppressing future repeats.
1378    pub rationale: String,
1379}
1380
1381/// Bounded health query used by agent-facing adapters.
1382#[derive(Clone, Debug, Eq, PartialEq)]
1383pub struct HealthQuery {
1384    /// Pagination start index after filters are applied.
1385    pub start_index: usize,
1386    /// Maximum findings to return.
1387    pub limit: usize,
1388    /// Optional finding category filter.
1389    pub category: Option<String>,
1390    /// Optional severity filter.
1391    pub severity: Option<Severity>,
1392    /// Optional repository-relative path prefix filter.
1393    pub path_prefix: Option<String>,
1394    /// Return counts without finding rows.
1395    pub summary_only: bool,
1396    /// Health and purpose-curation scope.
1397    pub scope: HealthScope,
1398}
1399
1400/// Resolution ownership used by bounded health queries.
1401#[derive(Clone, Copy)]
1402enum HealthResolutionFilter<'a> {
1403    /// Caller-owned compatibility filter.
1404    Explicit(&'a [String]),
1405    /// Durable resolutions owned by the current project database.
1406    Stored,
1407}
1408
1409/// Scope controls for bounded health and purpose-curation queries.
1410#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1411pub enum HealthScope {
1412    /// Include all indexed paths.
1413    All,
1414    /// Include only source files and folders with source descendants.
1415    SourceOnly,
1416    /// Include all folders plus high-impact files.
1417    PurposeDefault,
1418    /// Include all folders, high-impact files, and non-source files.
1419    PurposeWithAssets,
1420    /// Include all folders, high-impact files, and all source files.
1421    PurposeWithSourceFiles,
1422    /// Include every indexed file and folder.
1423    PurposeStrict,
1424}
1425
1426impl HealthScope {
1427    /// Scope matching unfiltered health output.
1428    pub fn all() -> Self {
1429        Self::All
1430    }
1431
1432    /// Scope restricted to source-relevant paths.
1433    pub fn source_only() -> Self {
1434        Self::SourceOnly
1435    }
1436
1437    /// Default agent purpose curation scope: folders plus high-impact files.
1438    pub fn purpose_default() -> Self {
1439        Self::PurposeDefault
1440    }
1441
1442    /// Purpose curation scope including non-source asset files.
1443    pub fn purpose_with_assets() -> Self {
1444        Self::PurposeWithAssets
1445    }
1446
1447    /// Purpose curation scope including all source files.
1448    pub fn purpose_with_source_files() -> Self {
1449        Self::PurposeWithSourceFiles
1450    }
1451
1452    /// Strict purpose curation scope including every indexed path.
1453    pub fn purpose_strict() -> Self {
1454        Self::PurposeStrict
1455    }
1456
1457    /// Whether this scope should be reported as source-focused in agent payloads.
1458    pub fn is_source_focused(self) -> bool {
1459        self.source_only_filter()
1460    }
1461
1462    /// Whether this scope uses the folder-first high-impact purpose queue.
1463    pub fn is_purpose_queue(self) -> bool {
1464        self.high_impact_queue()
1465    }
1466
1467    /// Whether source relevance should be applied before queue-specific filters.
1468    fn source_only_filter(self) -> bool {
1469        matches!(
1470            self,
1471            Self::SourceOnly | Self::PurposeDefault | Self::PurposeWithSourceFiles
1472        )
1473    }
1474
1475    /// Whether the scope should use folder-first purpose queue selection.
1476    fn high_impact_queue(self) -> bool {
1477        matches!(
1478            self,
1479            Self::PurposeDefault
1480                | Self::PurposeWithAssets
1481                | Self::PurposeWithSourceFiles
1482                | Self::PurposeStrict
1483        )
1484    }
1485
1486    /// Whether non-source asset files should be included in queue selection.
1487    fn include_assets(self) -> bool {
1488        matches!(self, Self::PurposeWithAssets)
1489    }
1490
1491    /// Whether all source files should be included in queue selection.
1492    fn include_source_files(self) -> bool {
1493        matches!(self, Self::PurposeWithSourceFiles | Self::PurposeStrict)
1494    }
1495
1496    /// Whether all files should be included in queue selection.
1497    fn include_all_files(self) -> bool {
1498        matches!(self, Self::PurposeStrict)
1499    }
1500}
1501
1502/// Bounded health findings page returned by the database layer.
1503#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1504pub struct HealthFindingsPage {
1505    /// Findings after filters are applied.
1506    pub total: usize,
1507    /// Findings before filters are applied, after resolved findings are removed.
1508    pub unfiltered_total: usize,
1509    /// Findings returned in this page.
1510    pub returned: usize,
1511    /// Pagination start index used for this page.
1512    pub start_index: usize,
1513    /// Maximum findings requested for this page.
1514    pub limit: usize,
1515    /// Returned health finding rows.
1516    pub findings: Vec<HealthFinding>,
1517}
1518
1519/// Static metadata for one purpose lifecycle health category.
1520#[derive(Clone, Copy, Debug)]
1521struct PurposeHealthSpec {
1522    /// Stored purpose status that emits this health category.
1523    status: &'static str,
1524    /// Health finding category for the lifecycle status.
1525    category: &'static str,
1526    /// Health finding message for every row in this lifecycle category.
1527    message: &'static str,
1528    /// Agent recommendation for resolving this lifecycle category.
1529    recommendation: &'static str,
1530}
1531
1532/// Purpose lifecycle health categories that can be paged directly in `SQLite`.
1533const PURPOSE_HEALTH_SPECS: [PurposeHealthSpec; 3] = [
1534    PurposeHealthSpec {
1535        status: PurposeStatus::Missing.as_str(),
1536        category: CATEGORY_MISSING_PURPOSE,
1537        message: MESSAGE_MISSING_PURPOSE,
1538        recommendation: RECOMMENDATION_MISSING_PURPOSE_QUEUE,
1539    },
1540    PurposeHealthSpec {
1541        status: PurposeStatus::Suggested.as_str(),
1542        category: CATEGORY_SUGGESTED_PURPOSE_REVIEW,
1543        message: MESSAGE_SUGGESTED_PURPOSE_REVIEW,
1544        recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE,
1545    },
1546    PurposeHealthSpec {
1547        status: PurposeStatus::Stale.as_str(),
1548        category: CATEGORY_STALE_PURPOSE,
1549        message: MESSAGE_STALE_PURPOSE,
1550        recommendation: RECOMMENDATION_STALE_PURPOSE,
1551    },
1552];
1553
1554/// Run one standalone write only while the captured schema and project binding still match.
1555#[cfg(test)]
1556fn with_validated_write_transaction<T>(
1557    connection: &Connection,
1558    expected_root: Option<&str>,
1559    expected_identity: Option<ProjectInstanceId>,
1560    operation: impl FnOnce(&Connection) -> DbResult<T>,
1561) -> DbResult<T> {
1562    let transaction =
1563        rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1564    let result = schema::validate_active_binding(&transaction, expected_root, expected_identity)
1565        .and_then(|()| operation(&transaction));
1566    match result {
1567        Ok(value) => {
1568            transaction.commit()?;
1569            Ok(value)
1570        }
1571        Err(operation) => match transaction.rollback() {
1572            Ok(()) => Err(operation),
1573            Err(rollback) => Err(DbError::TransactionRollback {
1574                operation: Box::new(operation),
1575                rollback,
1576            }),
1577        },
1578    }
1579}
1580
1581/// Run one standalone write while the captured native root and project binding still match.
1582fn with_validated_native_write_transaction<T>(
1583    connection: &Connection,
1584    expected_root: Option<&CanonicalProjectRoot>,
1585    expected_identity: Option<ProjectInstanceId>,
1586    operation: impl FnOnce(&Connection) -> DbResult<T>,
1587) -> DbResult<T> {
1588    let transaction =
1589        rusqlite::Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
1590    let result =
1591        schema::validate_active_native_binding(&transaction, expected_root, expected_identity)
1592            .and_then(|()| operation(&transaction));
1593    match result {
1594        Ok(value) => {
1595            transaction.commit()?;
1596            Ok(value)
1597        }
1598        Err(operation) => match transaction.rollback() {
1599            Ok(()) => Err(operation),
1600            Err(rollback) => Err(DbError::TransactionRollback {
1601                operation: Box::new(operation),
1602                rollback,
1603            }),
1604        },
1605    }
1606}
1607
1608impl AtlasStore {
1609    /// Reject mutations while this connection owns an ordinary read snapshot.
1610    fn require_mutation_scope(&self) -> DbResult<()> {
1611        if self.read_snapshot_active.get() {
1612            Err(DbError::IndexReadSnapshotActive)
1613        } else {
1614            Ok(())
1615        }
1616    }
1617
1618    /// Open a nested-capable write scope after validating the active binding.
1619    fn validated_savepoint(&mut self) -> DbResult<rusqlite::Savepoint<'_>> {
1620        self.require_mutation_scope()?;
1621        let validate_binding = self.connection.is_autocommit();
1622        let expected_root_identity = self.validated_project_root_identity.clone();
1623        let expected_identity = self.validated_project_instance_id;
1624        let savepoint = self.connection.savepoint()?;
1625        if validate_binding {
1626            schema::validate_active_native_binding(
1627                &savepoint,
1628                expected_root_identity.as_ref(),
1629                expected_identity,
1630            )?;
1631        }
1632        Ok(savepoint)
1633    }
1634
1635    /// Run one atomic standalone write, reusing an already validated parent transaction.
1636    fn with_validated_write<T>(
1637        &self,
1638        operation: impl FnOnce(&Connection) -> DbResult<T>,
1639    ) -> DbResult<T> {
1640        self.require_mutation_scope()?;
1641        if !self.connection.is_autocommit() {
1642            return operation(&self.connection);
1643        }
1644        with_validated_native_write_transaction(
1645            &self.connection,
1646            self.validated_project_root_identity.as_ref(),
1647            self.validated_project_instance_id,
1648            operation,
1649        )
1650    }
1651
1652    /// Begin one purpose mutation whose caller owns final source revalidation.
1653    ///
1654    /// Purpose writes performed through this store join the returned immediate
1655    /// transaction. The caller must revalidate its saved-source witness before
1656    /// committing; an early return drops the guard and rolls the complete batch
1657    /// back.
1658    ///
1659    /// # Errors
1660    ///
1661    /// Returns an error when mutation is unavailable, the binding changed, or
1662    /// `SQLite` cannot start the transaction.
1663    pub fn begin_purpose_mutation(&self) -> DbResult<PurposeMutationTransaction<'_>> {
1664        self.require_mutation_scope()?;
1665        let transaction =
1666            rusqlite::Transaction::new_unchecked(&self.connection, TransactionBehavior::Immediate)?;
1667        schema::validate_active_native_binding(
1668            &transaction,
1669            self.validated_project_root_identity.as_ref(),
1670            self.validated_project_instance_id,
1671        )?;
1672        Ok(PurposeMutationTransaction { transaction })
1673    }
1674
1675    /// Run telemetry against this store's exact captured database binding.
1676    ///
1677    /// File-backed stores use a separate short-lived writable connection with
1678    /// an ancillary fail-fast busy budget. Read-only navigation stores release
1679    /// their read snapshot first. The connection is never discovered, attached,
1680    /// substituted, or shared with another project.
1681    fn with_telemetry_connection<T>(
1682        &self,
1683        operation: impl FnOnce(&Connection) -> DbResult<T>,
1684    ) -> DbResult<T> {
1685        if self.read_only {
1686            self.finish_index_read_snapshot()?;
1687        } else {
1688            self.require_mutation_scope()?;
1689        }
1690        let (Some(path), Some(location)) =
1691            (self.database_path.as_ref(), self.database_location.as_ref())
1692        else {
1693            if self.database_path.is_none() && self.database_location.is_none() {
1694                return operation(&self.connection);
1695            }
1696            return Err(DbError::TelemetryPathUnavailable);
1697        };
1698        let _ = schema::preflight(path, None)?;
1699        let connection = open_writable_connection(
1700            path,
1701            OpenFlags::SQLITE_OPEN_READ_WRITE,
1702            location,
1703            SQLITE_TELEMETRY_BUSY_TIMEOUT,
1704            JournalModePolicy::RequireWal,
1705        )?;
1706        operation(&connection)
1707    }
1708
1709    /// Open or create an index store.
1710    ///
1711    /// # Errors
1712    ///
1713    /// Returns an error if `SQLite` setup or schema validation fails.
1714    pub fn open(path: &Path) -> DbResult<Self> {
1715        Self::open_with_project_root(path, None)
1716    }
1717
1718    /// Open or create an index store owned by one canonical project root.
1719    ///
1720    /// Existing databases bound to another root are rejected before writable
1721    /// access. A genuinely fresh database records the supplied root in the
1722    /// same transaction that creates its schema.
1723    ///
1724    /// # Errors
1725    ///
1726    /// Returns an error if read-only compatibility preflight, root validation,
1727    /// transactional migration, or `SQLite` setup fails.
1728    pub fn open_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1729        let expected_identity = CanonicalProjectRoot::from_path(root)?;
1730        Self::open_with_binding_requirement(
1731            path,
1732            None,
1733            Some(&expected_identity),
1734            ProjectIdentityRequirement::Required,
1735        )
1736    }
1737
1738    /// Open with an optional source-owned project identity.
1739    fn open_with_project_root(path: &Path, expected_root: Option<&str>) -> DbResult<Self> {
1740        Self::open_with_binding_requirement(
1741            path,
1742            expected_root,
1743            None,
1744            ProjectIdentityRequirement::Required,
1745        )
1746    }
1747
1748    /// Open for an explicit root transition that owns identity repair.
1749    fn open_for_root_transition(path: &Path) -> DbResult<Self> {
1750        Self::open_with_binding_requirement(
1751            path,
1752            None,
1753            None,
1754            ProjectIdentityRequirement::TransitionOwned,
1755        )
1756    }
1757
1758    /// Open with the root and identity validation required by the caller.
1759    fn open_with_binding_requirement(
1760        path: &Path,
1761        expected_root: Option<&str>,
1762        expected_identity: Option<&CanonicalProjectRoot>,
1763        identity_requirement: ProjectIdentityRequirement,
1764    ) -> DbResult<Self> {
1765        let (preflight, location) = if let Some(expected_identity) = expected_identity {
1766            schema::preflight_for_project(path, expected_identity)?
1767        } else {
1768            schema::preflight(path, None)?
1769        };
1770        if expected_identity.is_none()
1771            && identity_requirement.is_required()
1772            && preflight.state == SchemaState::UpgradeRequired
1773            && schema::legacy_root_requires_native_authority(preflight.project_root.as_deref())
1774        {
1775            // Rootless predecessor migration has no native caller authority
1776            // with which to identify a lossy legacy display. Refuse before
1777            // opening a writable connection or creating WAL state.
1778            return Err(DbError::ProjectRootIdentityMissing);
1779        }
1780        let validated_project_root = expected_identity
1781            .and_then(|identity| identity.display_string().ok())
1782            .or_else(|| expected_root.map(str::to_owned))
1783            .or_else(|| {
1784                expected_identity
1785                    .is_none()
1786                    .then(|| preflight.project_root.clone())
1787                    .flatten()
1788            });
1789        if preflight.state == SchemaState::Current
1790            && expected_identity.is_some()
1791            && identity_requirement.is_required()
1792            && preflight.project_instance_id.is_none()
1793        {
1794            return Err(DbError::ProjectInstanceIdentityMissing);
1795        }
1796        let connection = open_writable_connection(
1797            path,
1798            writable_open_flags(preflight.state, location.database_exists),
1799            &location,
1800            SQLITE_BUSY_TIMEOUT,
1801            writable_journal_policy(preflight.state),
1802        )?;
1803        let transition_transaction = preflight.state == SchemaState::UpgradeRequired
1804            && identity_requirement == ProjectIdentityRequirement::TransitionOwned;
1805        if transition_transaction {
1806            connection.execute_batch("BEGIN IMMEDIATE")?;
1807        }
1808        let opened = (|| {
1809            if preflight.state == SchemaState::Current
1810                && let Some(identity) = expected_identity
1811            {
1812                project_identity::ensure_project_root_identity(&connection, identity)?;
1813            }
1814            let validated_project_instance_id = if preflight.state == SchemaState::Current {
1815                if let Some(expected_identity) = expected_identity {
1816                    schema::revalidate_current_native_binding(
1817                        &connection,
1818                        expected_identity,
1819                        identity_requirement.is_required(),
1820                    )?
1821                } else if identity_requirement == ProjectIdentityRequirement::TransitionOwned {
1822                    // Move/Detach revalidate the recorded native identity and
1823                    // transition state in apply_root_transition. Their recorded
1824                    // root may intentionally be absent, so do not run the
1825                    // existing-root admission proof here.
1826                    project_identity::load_project_identity(&connection)?
1827                } else if let Some(stored_identity) =
1828                    project_identity::load_project_root_identity(&connection)?
1829                {
1830                    schema::revalidate_current_native_binding(
1831                        &connection,
1832                        &stored_identity,
1833                        identity_requirement.is_required(),
1834                    )?
1835                } else {
1836                    let stored_instance_id = project_identity::load_project_identity(&connection)?;
1837                    schema::validate_binding_completeness(
1838                        validated_project_root.as_deref(),
1839                        stored_instance_id,
1840                        identity_requirement.is_required(),
1841                    )?;
1842                    if stored_instance_id.is_some() {
1843                        return Err(DbError::ProjectRootIdentityMissing);
1844                    }
1845                    None
1846                }
1847            } else {
1848                if let Some(expected_identity) = expected_identity {
1849                    let expected_display = expected_identity.display_string().ok();
1850                    schema::initialize_with_project_root(
1851                        &connection,
1852                        expected_display.as_deref(),
1853                        Some(expected_identity),
1854                    )?;
1855                } else {
1856                    let initialization_root = expected_root.or(preflight.project_root.as_deref());
1857                    if transition_transaction {
1858                        schema::initialize_with_project_root_in_transaction(
1859                            &connection,
1860                            initialization_root,
1861                            None,
1862                        )?;
1863                    } else {
1864                        schema::initialize_with_project_root(
1865                            &connection,
1866                            initialization_root,
1867                            None,
1868                        )?;
1869                    }
1870                }
1871                project_identity::load_project_identity(&connection)?
1872            };
1873            let validated_project_root_identity =
1874                project_identity::load_project_root_identity(&connection)?;
1875            if identity_requirement.is_required()
1876                && (validated_project_root.is_some() || validated_project_root_identity.is_some())
1877                && validated_project_instance_id.is_none()
1878            {
1879                return Err(DbError::ProjectInstanceIdentityMissing);
1880            }
1881            let database_location = if location.database_exists {
1882                location
1883            } else {
1884                sqlite_profile::inspect_database_location(path)?
1885            };
1886            Ok((
1887                database_location,
1888                validated_project_root,
1889                validated_project_root_identity,
1890                validated_project_instance_id,
1891            ))
1892        })();
1893        if transition_transaction {
1894            match opened {
1895                Ok((
1896                    database_location,
1897                    validated_project_root,
1898                    validated_project_root_identity,
1899                    validated_project_instance_id,
1900                )) => Ok(Self {
1901                    connection,
1902                    read_snapshot_active: Cell::new(false),
1903                    database_path: Some(path.to_path_buf()),
1904                    database_location: Some(database_location),
1905                    read_only: false,
1906                    validated_project_root,
1907                    validated_project_root_identity,
1908                    validated_project_instance_id,
1909                    library_usage_instances: RefCell::new(HashMap::new()),
1910                }),
1911                Err(error) => Err(schema::rollback_after_error(&connection, error)),
1912            }
1913        } else {
1914            let (
1915                database_location,
1916                validated_project_root,
1917                validated_project_root_identity,
1918                validated_project_instance_id,
1919            ) = opened?;
1920            Ok(Self {
1921                connection,
1922                read_snapshot_active: Cell::new(false),
1923                database_path: Some(path.to_path_buf()),
1924                database_location: Some(database_location),
1925                read_only: false,
1926                validated_project_root,
1927                validated_project_root_identity,
1928                validated_project_instance_id,
1929                library_usage_instances: RefCell::new(HashMap::new()),
1930            })
1931        }
1932    }
1933
1934    /// Open an existing index without creating, migrating, or backfilling it.
1935    ///
1936    /// # Errors
1937    ///
1938    /// Returns an error if the database cannot be opened read-only or its
1939    /// schema version is not exactly supported by this runtime.
1940    pub fn open_read_only(path: &Path) -> DbResult<Self> {
1941        Self::open_read_only_with_project_root(path, None)
1942    }
1943
1944    /// Open one current read snapshot owned by a canonical project root.
1945    ///
1946    /// # Errors
1947    ///
1948    /// Returns an error if the database is incompatible, belongs to another
1949    /// root, or cannot be opened without database mutation.
1950    pub fn open_read_only_for_project(path: &Path, root: &Path) -> DbResult<Self> {
1951        let expected_identity = CanonicalProjectRoot::from_path(root)?;
1952        Self::open_read_only_with_project_root(path, Some(&expected_identity))
1953    }
1954
1955    /// Return whether this store is restricted to non-mutating queries.
1956    #[must_use]
1957    pub const fn is_read_only(&self) -> bool {
1958        self.read_only
1959    }
1960
1961    /// Return whether this store currently owns an open read snapshot.
1962    #[must_use]
1963    pub fn has_active_read_snapshot(&self) -> bool {
1964        self.read_snapshot_active.get()
1965    }
1966
1967    /// Open a current read snapshot with optional project identity validation.
1968    fn open_read_only_with_project_root(
1969        path: &Path,
1970        expected_identity: Option<&CanonicalProjectRoot>,
1971    ) -> DbResult<Self> {
1972        let (connection, preflight) = schema::open_current_read_only(path, None)?;
1973        let validated_project_instance_id = project_identity::load_project_identity(&connection)?;
1974        let validated_project_root_identity =
1975            project_identity::load_project_root_identity(&connection)?;
1976        if expected_identity.is_some() && validated_project_root_identity.is_none() {
1977            return Err(DbError::ProjectRootIdentityMissing);
1978        }
1979        if let (Some(expected), Some(found)) =
1980            (expected_identity, validated_project_root_identity.as_ref())
1981        {
1982            project_identity::prove_existing_root_equivalence(expected.as_path(), found.as_path())?;
1983        }
1984        if validated_project_root_identity.is_none() {
1985            schema::validate_binding_completeness(
1986                preflight.project_root.as_deref(),
1987                validated_project_instance_id,
1988                true,
1989            )?;
1990        } else if validated_project_instance_id.is_none() {
1991            return Err(DbError::ProjectInstanceIdentityMissing);
1992        }
1993        let validated_project_root = match validated_project_root_identity.as_ref() {
1994            Some(identity) => identity.display_string().ok(),
1995            None => preflight.project_root,
1996        };
1997        let database_location = sqlite_profile::inspect_database_location(path)?;
1998        Ok(Self {
1999            connection,
2000            read_snapshot_active: Cell::new(true),
2001            database_path: Some(path.to_path_buf()),
2002            database_location: Some(database_location),
2003            read_only: true,
2004            validated_project_root,
2005            validated_project_root_identity,
2006            validated_project_instance_id,
2007            library_usage_instances: RefCell::new(HashMap::new()),
2008        })
2009    }
2010
2011    /// Open an in-memory store for tests.
2012    ///
2013    /// # Errors
2014    ///
2015    /// Returns an error if schema setup fails.
2016    pub fn in_memory() -> DbResult<Self> {
2017        let store = Self {
2018            connection: Connection::open_in_memory()?,
2019            read_snapshot_active: Cell::new(false),
2020            database_path: None,
2021            database_location: None,
2022            read_only: false,
2023            validated_project_root: None,
2024            validated_project_root_identity: None,
2025            validated_project_instance_id: None,
2026            library_usage_instances: RefCell::new(HashMap::new()),
2027        };
2028        schema::initialize(&store.connection, None)?;
2029        Ok(store)
2030    }
2031
2032    /// Validate, initialize, or migrate the schema through the storage owner.
2033    ///
2034    /// # Errors
2035    ///
2036    /// Returns an error when schema compatibility, integrity, or migration fails.
2037    pub fn initialize_schema(&self) -> DbResult<()> {
2038        schema::initialize(&self.connection, None)
2039    }
2040
2041    /// Upsert a full scan result and mark previously seen missing paths absent.
2042    ///
2043    /// # Errors
2044    ///
2045    /// Returns an error if persistence fails.
2046    pub fn replace_scan(&mut self, nodes: &[Node]) -> DbResult<()> {
2047        let savepoint = self.validated_savepoint()?;
2048        mark_all_scan_nodes_absent(&savepoint)?;
2049        upsert_nodes(&savepoint, nodes)?;
2050        delete_absent_scan_projections(&savepoint)?;
2051        savepoint.commit()?;
2052        Ok(())
2053    }
2054
2055    /// Upsert a partial scan result without marking unrelated paths absent.
2056    ///
2057    /// # Errors
2058    ///
2059    /// Returns an error if persistence fails.
2060    pub fn upsert_scan_nodes(&mut self, nodes: &[Node]) -> DbResult<()> {
2061        let savepoint = self.validated_savepoint()?;
2062        upsert_nodes(&savepoint, nodes)?;
2063        savepoint.commit()?;
2064        Ok(())
2065    }
2066
2067    /// Mark paths and their descendants absent after filesystem delete events.
2068    ///
2069    /// # Errors
2070    ///
2071    /// Returns an error if persistence fails.
2072    pub fn mark_paths_absent(&mut self, paths: &[String]) -> DbResult<()> {
2073        let savepoint = self.validated_savepoint()?;
2074        let fts_revision = begin_file_text_fts_update(&savepoint)?;
2075        {
2076            let mut mark_nodes = savepoint.prepare_cached(
2077                "UPDATE nodes SET exists_now = 0 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2078            )?;
2079            let mut delete_classifications = savepoint.prepare_cached(
2080                "DELETE FROM file_content_classifications
2081                  WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2082            )?;
2083            let mut delete_relations = savepoint.prepare_cached(
2084                "DELETE FROM symbol_relations WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2085            )?;
2086            let mut delete_symbols = savepoint.prepare_cached(
2087                "DELETE FROM symbols WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2088            )?;
2089            let mut delete_parse_metadata = savepoint.prepare_cached(
2090                "DELETE FROM source_parse_metadata WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2091            )?;
2092            let mut delete_text_fts = savepoint.prepare_cached(
2093                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2094                 SELECT 'delete', rowid, content FROM file_texts \
2095                 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2096            )?;
2097            let mut delete_text = savepoint.prepare_cached(
2098                "DELETE FROM file_texts WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'",
2099            )?;
2100            for path in paths {
2101                if path == "." || path.is_empty() {
2102                    continue;
2103                }
2104                let descendant_pattern = sqlite_descendant_pattern(path);
2105                delete_classifications.execute(params![path, descendant_pattern])?;
2106                mark_nodes.execute(params![path, descendant_pattern])?;
2107                delete_relations.execute(params![path, descendant_pattern])?;
2108                delete_symbols.execute(params![path, descendant_pattern])?;
2109                delete_parse_metadata.execute(params![path, descendant_pattern])?;
2110                delete_text_fts.execute(params![path, descendant_pattern])?;
2111                delete_text.execute(params![path, descendant_pattern])?;
2112            }
2113        }
2114        complete_file_text_fts_update(&savepoint, fts_revision)?;
2115        savepoint.commit()?;
2116        Ok(())
2117    }
2118
2119    /// Replace indexed text for scanned file paths.
2120    ///
2121    /// `paths` should contain every file path considered by the scan batch.
2122    /// Existing indexed text for those paths is cleared first so binary,
2123    /// deleted, or no-longer-UTF-8 files cannot leave stale searchable content.
2124    ///
2125    /// # Errors
2126    ///
2127    /// Returns an error if persistence fails.
2128    pub fn replace_file_texts_for_paths<'text>(
2129        &mut self,
2130        paths: &[String],
2131        texts: impl IntoIterator<Item = &'text IndexedFileText>,
2132    ) -> DbResult<()> {
2133        let texts = texts.into_iter().collect::<Vec<_>>();
2134        for text in &texts {
2135            validate_indexed_file_text(text)?;
2136        }
2137        let savepoint = self.validated_savepoint()?;
2138        let fts_revision = begin_file_text_fts_update(&savepoint)?;
2139        {
2140            let mut delete_fts = savepoint.prepare_cached(
2141                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2142                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2143            )?;
2144            let mut delete = savepoint.prepare_cached("DELETE FROM file_texts WHERE path = ?1")?;
2145            for path in paths {
2146                delete_fts.execute([path])?;
2147                delete.execute([path])?;
2148            }
2149        }
2150        {
2151            let mut delete_current_fts = savepoint.prepare_cached(
2152                "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
2153                 SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
2154            )?;
2155            let mut upsert = savepoint.prepare_cached(
2156                "
2157                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content, updated_at)
2158                VALUES(?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
2159                ON CONFLICT(path) DO UPDATE SET
2160                    content_hash = excluded.content_hash,
2161                    byte_count = excluded.byte_count,
2162                    line_count = excluded.line_count,
2163                    content = excluded.content,
2164                    updated_at = CURRENT_TIMESTAMP
2165                ",
2166            )?;
2167            let mut index = savepoint.prepare_cached(
2168                "INSERT INTO file_text_fts(rowid, content) \
2169                 SELECT rowid, content FROM file_texts WHERE path = ?1",
2170            )?;
2171            for text in texts {
2172                delete_current_fts.execute([&text.path])?;
2173                upsert.execute(params![
2174                    text.path,
2175                    text.content_hash.as_deref(),
2176                    usize_to_i64(text.byte_count),
2177                    usize_to_i64(text.line_count),
2178                    text.content
2179                ])?;
2180                index.execute([&text.path])?;
2181            }
2182        }
2183        complete_file_text_fts_update(&savepoint, fts_revision)?;
2184        savepoint.commit()?;
2185        Ok(())
2186    }
2187
2188    /// Load one indexed text row by repository path.
2189    ///
2190    /// # Errors
2191    ///
2192    /// Returns an error if reading fails or stored counts are invalid.
2193    pub fn load_file_text(&self, path: &str) -> DbResult<Option<IndexedFileText>> {
2194        let mut statement = self.connection.prepare(
2195            "
2196            SELECT path, content_hash, byte_count, line_count, content
2197            FROM file_texts
2198            WHERE path = ?1
2199            ",
2200        )?;
2201        let mut rows = statement.query([path])?;
2202        rows.next()?.map(file_text_from_row).transpose()
2203    }
2204
2205    /// Load a bounded FTS candidate superset for service-owned exact verification.
2206    ///
2207    /// The request accepts only one safe ASCII-alphanumeric token. The MATCH
2208    /// expression is bound as data. Only authoritative metadata is returned;
2209    /// callers hydrate selected paths with [`Self::load_file_text`] after
2210    /// applying their file, byte, and time budgets.
2211    ///
2212    /// # Errors
2213    ///
2214    /// Returns an error when the token shape or limit is unsafe, bounded work
2215    /// is canceled or reaches its deadline, or persisted rows are invalid.
2216    pub fn query_file_text_fts_candidates(
2217        &self,
2218        query: &FileTextFtsQuery<'_>,
2219        control: Option<&IndexWorkControl>,
2220    ) -> DbResult<FileTextFtsPage> {
2221        validate_file_text_fts_token(query.literal_token)?;
2222        if query.limit > MAX_FILE_TEXT_FTS_CANDIDATES {
2223            return Err(DbError::FileTextFtsCandidateLimit {
2224                requested: query.limit,
2225                maximum: MAX_FILE_TEXT_FTS_CANDIDATES,
2226            });
2227        }
2228        let match_expression = format!("\"{}\"", query.literal_token);
2229        let row_limit = usize_to_i64(query.limit.saturating_add(1));
2230        with_file_text_progress(&self.connection, control, || {
2231            let mut candidates = Vec::with_capacity(query.limit.saturating_add(1));
2232            if let Some(path_prefix) = normalized_file_text_path_prefix(query.path_prefix) {
2233                let descendant_pattern = sqlite_descendant_pattern(path_prefix);
2234                let mut statement = self.connection.prepare_cached(
2235                    "
2236                    SELECT
2237                        f.path,
2238                        f.content_hash,
2239                        f.byte_count,
2240                        f.line_count,
2241                        bm25(file_text_fts)
2242                    FROM file_text_fts
2243                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2244                    WHERE file_text_fts MATCH ?1
2245                      AND (f.path = ?2 OR f.path LIKE ?3 ESCAPE '\\')
2246                    ORDER BY bm25(file_text_fts), f.path
2247                    LIMIT ?4
2248                    ",
2249                )?;
2250                let mut rows = statement.query(params![
2251                    match_expression,
2252                    path_prefix,
2253                    descendant_pattern,
2254                    row_limit,
2255                ])?;
2256                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2257            } else {
2258                let mut statement = self.connection.prepare_cached(
2259                    "
2260                    SELECT
2261                        f.path,
2262                        f.content_hash,
2263                        f.byte_count,
2264                        f.line_count,
2265                        bm25(file_text_fts)
2266                    FROM file_text_fts
2267                    JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
2268                    WHERE file_text_fts MATCH ?1
2269                    ORDER BY bm25(file_text_fts), f.path
2270                    LIMIT ?2
2271                    ",
2272                )?;
2273                let mut rows = statement.query(params![match_expression, row_limit])?;
2274                collect_file_text_fts_candidates(&mut rows, control, &mut candidates)?;
2275            }
2276            let overflow = candidates.len() > query.limit;
2277            candidates.truncate(query.limit);
2278            Ok(FileTextFtsPage {
2279                candidates,
2280                overflow,
2281            })
2282        })
2283    }
2284
2285    /// Visit correctness-authoritative persisted text with predecode admission.
2286    ///
2287    /// `admit` receives path and byte metadata before the source `String` is
2288    /// decoded from `SQLite`. Returning [`FileTextAdmission::Skip`] or
2289    /// [`FileTextAdmission::Stop`] therefore avoids allocating excluded source
2290    /// content. Returning `false` from `visitor` stops after an admitted row.
2291    ///
2292    /// # Errors
2293    ///
2294    /// Returns an error when bounded work is canceled or reaches its deadline,
2295    /// persisted counts are invalid, or either callback returns an error.
2296    pub fn visit_file_texts_for_fallback<A, V>(
2297        &self,
2298        path_prefix: Option<&str>,
2299        control: Option<&IndexWorkControl>,
2300        mut admit: A,
2301        mut visitor: V,
2302    ) -> DbResult<()>
2303    where
2304        A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
2305        V: FnMut(IndexedFileText) -> DbResult<bool>,
2306    {
2307        with_file_text_progress(&self.connection, control, || {
2308            let mut content_statement = self
2309                .connection
2310                .prepare_cached(FILE_TEXT_FALLBACK_CONTENT_SQL)?;
2311            if let Some(path_prefix) = normalized_file_text_path_prefix(path_prefix) {
2312                let (descendant_start, descendant_end) = file_text_descendant_range(path_prefix);
2313                let mut statement = self
2314                    .connection
2315                    .prepare_cached(FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL)?;
2316                let mut rows =
2317                    statement.query(params![path_prefix, descendant_start, descendant_end])?;
2318                visit_file_text_fallback_rows(
2319                    &mut rows,
2320                    &mut content_statement,
2321                    control,
2322                    &mut admit,
2323                    &mut visitor,
2324                )
2325            } else {
2326                let mut statement = self
2327                    .connection
2328                    .prepare_cached(FILE_TEXT_FALLBACK_ALL_METADATA_SQL)?;
2329                let mut rows = statement.query([])?;
2330                visit_file_text_fallback_rows(
2331                    &mut rows,
2332                    &mut content_statement,
2333                    control,
2334                    &mut admit,
2335                    &mut visitor,
2336                )
2337            }
2338        })
2339    }
2340
2341    /// Return whether the transactional FTS projection matches authoritative text.
2342    ///
2343    /// This constant-work readiness check is suitable for the search hot path.
2344    /// Explicit settings diagnostics may additionally count projection rows.
2345    ///
2346    /// # Errors
2347    ///
2348    /// Returns an error when durable revision metadata is malformed or partial.
2349    pub fn file_text_fts_ready(&self) -> DbResult<bool> {
2350        Ok(load_file_text_fts_revisions(&self.connection)?
2351            .is_some_and(|(source, projection)| source == projection))
2352    }
2353
2354    /// Report content-free FTS synchronization state for settings/readiness.
2355    ///
2356    /// # Errors
2357    ///
2358    /// Returns an error when authoritative or projection row state cannot be
2359    /// inspected exactly.
2360    pub fn file_text_fts_state(&self) -> DbResult<FileTextFtsState> {
2361        let revision_synchronized = self.file_text_fts_ready()?;
2362        let (source_rows, indexed_rows, synchronized) = self.connection.query_row(
2363            "
2364            SELECT
2365                (SELECT COUNT(*) FROM file_texts),
2366                (SELECT COUNT(*) FROM file_text_fts_docsize),
2367                NOT EXISTS(
2368                    SELECT 1
2369                    FROM file_texts AS f
2370                    LEFT JOIN file_text_fts_docsize AS d ON d.id = f.rowid
2371                    WHERE d.id IS NULL
2372                )
2373                AND NOT EXISTS(
2374                    SELECT 1
2375                    FROM file_text_fts_docsize AS d
2376                    LEFT JOIN file_texts AS f ON f.rowid = d.id
2377                    WHERE f.rowid IS NULL
2378                )
2379            ",
2380            [],
2381            |row| {
2382                Ok((
2383                    row.get::<_, i64>(0)?,
2384                    row.get::<_, i64>(1)?,
2385                    row.get::<_, i64>(2)? != 0,
2386                ))
2387            },
2388        )?;
2389        Ok(FileTextFtsState {
2390            source_rows: count_to_usize("file_texts", source_rows)?,
2391            indexed_rows: count_to_usize("file_text_fts", indexed_rows)?,
2392            synchronized: revision_synchronized && synchronized,
2393        })
2394    }
2395
2396    /// Load indexed text rows for search.
2397    ///
2398    /// When `literal_pattern` is supplied, `SQLite` prefilters candidate files
2399    /// with a substring search before the service performs line-level matching.
2400    /// Regex and fuzzy searches pass `None` and still use the persisted text
2401    /// index instead of reopening source files from disk.
2402    ///
2403    /// # Errors
2404    ///
2405    /// Returns an error if reading fails or stored counts are invalid.
2406    pub fn load_file_texts_for_search(
2407        &self,
2408        literal_pattern: Option<&str>,
2409        case_sensitive: bool,
2410    ) -> DbResult<Vec<IndexedFileText>> {
2411        let mut texts = Vec::new();
2412        self.visit_file_texts_for_search(literal_pattern, case_sensitive, |text| {
2413            texts.push(text);
2414            Ok(true)
2415        })?;
2416        Ok(texts)
2417    }
2418
2419    /// Visit indexed text rows for search without materializing all rows.
2420    ///
2421    /// When `literal_pattern` is supplied, `SQLite` prefilters candidate files
2422    /// with a substring search before the service performs line-level matching.
2423    /// Returning `false` from `visitor` stops iteration early.
2424    ///
2425    /// # Errors
2426    ///
2427    /// Returns an error if reading fails, stored counts are invalid, or the
2428    /// visitor returns an error.
2429    pub fn visit_file_texts_for_search<F>(
2430        &self,
2431        literal_pattern: Option<&str>,
2432        case_sensitive: bool,
2433        mut visitor: F,
2434    ) -> DbResult<()>
2435    where
2436        F: FnMut(IndexedFileText) -> DbResult<bool>,
2437    {
2438        if let Some(pattern) = literal_pattern.filter(|pattern| !pattern.is_empty()) {
2439            if case_sensitive {
2440                let mut statement = self.connection.prepare(
2441                    "
2442                    SELECT path, content_hash, byte_count, line_count, content
2443                    FROM file_texts
2444                    WHERE instr(content, ?1) > 0
2445                    ORDER BY path
2446                    ",
2447                )?;
2448                let mut rows = statement.query([pattern])?;
2449                while let Some(row) = rows.next()? {
2450                    if !visitor(file_text_from_row(row)?)? {
2451                        return Ok(());
2452                    }
2453                }
2454            } else {
2455                let pattern = pattern.to_ascii_lowercase();
2456                let mut statement = self.connection.prepare(
2457                    "
2458                    SELECT path, content_hash, byte_count, line_count, content
2459                    FROM file_texts
2460                    WHERE instr(lower(content), ?1) > 0
2461                    ORDER BY path
2462                    ",
2463                )?;
2464                let mut rows = statement.query([pattern])?;
2465                while let Some(row) = rows.next()? {
2466                    if !visitor(file_text_from_row(row)?)? {
2467                        return Ok(());
2468                    }
2469                }
2470            }
2471        } else {
2472            let mut statement = self.connection.prepare(
2473                "
2474                SELECT path, content_hash, byte_count, line_count, content
2475                FROM file_texts
2476                ORDER BY path
2477                ",
2478            )?;
2479            let mut rows = statement.query([])?;
2480            while let Some(row) = rows.next()? {
2481                if !visitor(file_text_from_row(row)?)? {
2482                    return Ok(());
2483                }
2484            }
2485        }
2486        Ok(())
2487    }
2488
2489    /// Count files with persisted UTF-8 text for indexed search.
2490    ///
2491    /// # Errors
2492    ///
2493    /// Returns an error if reading fails.
2494    pub fn file_text_count(&self) -> DbResult<usize> {
2495        let count = self
2496            .connection
2497            .query_row("SELECT COUNT(*) FROM file_texts", [], |row| {
2498                row.get::<_, i64>(0)
2499            })?;
2500        count_to_usize("file_texts", count)
2501    }
2502
2503    /// Sum persisted UTF-8 source bytes used by indexed search.
2504    ///
2505    /// # Errors
2506    ///
2507    /// Returns an error if reading fails.
2508    pub fn file_text_byte_count(&self) -> DbResult<usize> {
2509        let count = self.connection.query_row(
2510            "SELECT COALESCE(SUM(byte_count), 0) FROM file_texts",
2511            [],
2512            |row| row.get::<_, i64>(0),
2513        )?;
2514        count_to_usize("file_text_bytes", count)
2515    }
2516
2517    /// Persist the canonical filesystem root for indexed repository files.
2518    ///
2519    /// # Errors
2520    ///
2521    /// Returns an error if persistence fails.
2522    pub fn set_project_root(&mut self, root: &Path) -> DbResult<()> {
2523        let identity = CanonicalProjectRoot::from_path(root)?;
2524        let value = identity.display_string().ok();
2525        let previous_identity = self.validated_project_instance_id;
2526        let savepoint = self.validated_savepoint()?;
2527        let found = savepoint
2528            .query_row(
2529                "SELECT value FROM metadata WHERE key = ?1",
2530                [PROJECT_ROOT_KEY],
2531                |row| row.get::<_, String>(0),
2532            )
2533            .optional()?;
2534        let found_identity = project_identity::load_project_root_identity(&savepoint)?;
2535        if found.is_none() && found_identity.is_none() {
2536            project_identity::set_project_root_identity(&savepoint, &identity)?;
2537            project_identity::set_project_root_metadata(&savepoint, &identity)?;
2538        } else {
2539            project_identity::ensure_project_root_identity_in_transaction(&savepoint, &identity)?;
2540        }
2541        let (project_identity, _) = project_identity::ensure_project_identity(&savepoint)?;
2542        let identity_changed = previous_identity != Some(project_identity);
2543        savepoint.commit()?;
2544        self.validated_project_root = value;
2545        self.validated_project_root_identity = Some(identity);
2546        self.validated_project_instance_id = Some(project_identity);
2547        if identity_changed {
2548            self.library_usage_instances.get_mut().clear();
2549        }
2550        Ok(())
2551    }
2552
2553    /// Load the canonical filesystem root for indexed repository files.
2554    ///
2555    /// # Errors
2556    ///
2557    /// Returns an error if reading fails.
2558    pub fn project_root(&self) -> DbResult<Option<String>> {
2559        self.connection
2560            .query_row(
2561                "SELECT value FROM metadata WHERE key = ?1",
2562                [PROJECT_ROOT_KEY],
2563                |row| row.get::<_, String>(0),
2564            )
2565            .optional()
2566            .map_err(DbError::from)
2567    }
2568
2569    /// Return the project binding captured when this store was validated.
2570    ///
2571    /// This accessor performs no database read. It is intended for services
2572    /// that must bind a result to the exact root and identity selected at open.
2573    ///
2574    /// # Errors
2575    ///
2576    /// Returns an error when the store has no complete project binding.
2577    pub fn captured_project_binding(&self) -> DbResult<CapturedProjectBinding> {
2578        Ok(CapturedProjectBinding {
2579            project_instance_id: self
2580                .validated_project_instance_id
2581                .ok_or(DbError::ProjectInstanceIdentityMissing)?,
2582            project_root: self.validated_project_root.clone(),
2583            project_root_identity: self
2584                .validated_project_root_identity
2585                .clone()
2586                .ok_or(DbError::ProjectRootIdentityMissing)?,
2587        })
2588    }
2589
2590    /// Load the authoritative native project-root identity.
2591    ///
2592    /// # Errors
2593    ///
2594    /// Returns an error when the identity row cannot be read or its versioned
2595    /// codec payload is invalid.
2596    pub fn project_root_identity(&self) -> DbResult<Option<CanonicalProjectRoot>> {
2597        project_identity::load_project_root_identity(&self.connection)
2598    }
2599
2600    /// Return whether a selected canonical root resolves to this store's
2601    /// captured native root identity.
2602    ///
2603    /// The comparison re-canonicalizes both existing roots through the
2604    /// admission proof, so case-only spelling changes on case-insensitive
2605    /// filesystems are accepted while distinct case-sensitive roots remain
2606    /// different. An unavailable or unresolvable persisted root fails closed.
2607    #[must_use]
2608    pub fn project_root_identity_matches(&self, selected: &CanonicalProjectRoot) -> bool {
2609        self.validated_project_root_identity
2610            .as_ref()
2611            .is_some_and(|persisted| {
2612                project_identity::prove_existing_root_equivalence(
2613                    selected.as_path(),
2614                    persisted.as_path(),
2615                )
2616                .is_ok()
2617            })
2618    }
2619
2620    /// Revalidate the captured binding against a fresh database snapshot.
2621    ///
2622    /// File-backed stores open an independent read-only snapshot so a report
2623    /// connection pinned to an older WAL end mark cannot hide a concurrent
2624    /// same-root identity rotation. In-memory stores validate their only
2625    /// connection because no external database binding exists.
2626    ///
2627    /// # Errors
2628    ///
2629    /// Returns an error when the database, root, identity, schema, or
2630    /// filesystem binding no longer matches the state captured at open.
2631    pub fn revalidate_captured_project_binding(&self) -> DbResult<()> {
2632        let binding = self.captured_project_binding()?;
2633        let Some(path) = self.database_path.as_deref() else {
2634            return schema::validate_active_native_binding(
2635                &self.connection,
2636                Some(&binding.project_root_identity),
2637                Some(binding.project_instance_id),
2638            );
2639        };
2640        let (connection, _) = schema::open_current_read_only(path, None)?;
2641        schema::validate_active_native_binding(
2642            &connection,
2643            Some(&binding.project_root_identity),
2644            Some(binding.project_instance_id),
2645        )
2646    }
2647
2648    /// Read the selected project identity and current purpose-work generation.
2649    fn purpose_curation_context(
2650        &self,
2651        connection: &Connection,
2652    ) -> DbResult<(ProjectInstanceId, IndexGeneration)> {
2653        let selected = self
2654            .validated_project_instance_id
2655            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2656        let current = project_identity::load_project_identity(connection)?
2657            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
2658        if current != selected {
2659            return Err(DbError::GraphProjectIdentityMismatch {
2660                expected: selected.to_string(),
2661                found: current.to_string(),
2662            });
2663        }
2664        let generation =
2665            project_identity::load_graph_generation(connection)?.unwrap_or(IndexGeneration::ZERO);
2666        Ok((current, generation))
2667    }
2668
2669    /// Begin one exclusive full derived-index publication.
2670    ///
2671    /// Every nested projection write remains inside the returned guard's
2672    /// `SQLite` transaction. Other connections keep the prior complete
2673    /// generation queryable until [`IndexPublicationGuard::complete`] commits.
2674    ///
2675    /// # Errors
2676    ///
2677    /// Returns an error if the exclusive write transaction cannot begin.
2678    pub fn begin_index_publication(
2679        &mut self,
2680        contract_fingerprint: &str,
2681    ) -> DbResult<IndexPublicationGuard<'_>> {
2682        let base_generation = self
2683            .index_publication()?
2684            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2685        self.begin_index_publication_from(contract_fingerprint, base_generation)
2686    }
2687
2688    /// Begin one exclusive full derived-index publication only when its
2689    /// prepared base generation is still current.
2690    ///
2691    /// [`IndexGeneration::ZERO`] matches only an uninitialized store. The
2692    /// generation comparison occurs after `BEGIN IMMEDIATE` and before any
2693    /// publication metadata or projection row is changed.
2694    ///
2695    /// # Errors
2696    ///
2697    /// Returns an error if the exclusive write transaction cannot begin or
2698    /// another publisher completed a newer generation after work was prepared.
2699    pub fn begin_index_publication_from(
2700        &mut self,
2701        contract_fingerprint: &str,
2702        expected_base_generation: IndexGeneration,
2703    ) -> DbResult<IndexPublicationGuard<'_>> {
2704        self.begin_publication(
2705            PublicationContract::Full(contract_fingerprint.to_string()),
2706            Some(expected_base_generation),
2707        )
2708    }
2709
2710    /// Check publication-writer availability without changing durable state.
2711    ///
2712    /// The probe uses the same fail-fast acquisition policy as a real
2713    /// publication and immediately rolls back a successful transaction. It
2714    /// lets callers reject a contended writer before doing expensive staging,
2715    /// while preserving the complete generation for every connection.
2716    ///
2717    /// # Errors
2718    ///
2719    /// Returns an error when the exclusive writer cannot be acquired or the
2720    /// probe transaction cannot be rolled back.
2721    pub fn probe_index_publication_writer(&mut self) -> DbResult<()> {
2722        begin_immediate_publication(&self.connection)?;
2723        self.connection.execute_batch("ROLLBACK")?;
2724        Ok(())
2725    }
2726
2727    /// Begin one exclusive symbol/projection refresh without replacing the
2728    /// established full-index contract.
2729    ///
2730    /// # Errors
2731    ///
2732    /// Returns an error if the index is incomplete, the established contract
2733    /// differs, or the exclusive write transaction cannot begin.
2734    pub fn begin_index_projection_refresh(
2735        &mut self,
2736        contract_fingerprint: &str,
2737    ) -> DbResult<IndexPublicationGuard<'_>> {
2738        let base_generation = self
2739            .index_publication()?
2740            .map_or(IndexGeneration::ZERO, |publication| publication.generation);
2741        self.begin_index_projection_refresh_from(contract_fingerprint, base_generation)
2742    }
2743
2744    /// Begin one exclusive symbol/projection refresh only when its prepared
2745    /// base generation is still current.
2746    ///
2747    /// # Errors
2748    ///
2749    /// Returns an error if the index is incomplete, the established contract
2750    /// differs, the base generation changed, or the exclusive write
2751    /// transaction cannot begin.
2752    pub fn begin_index_projection_refresh_from(
2753        &mut self,
2754        contract_fingerprint: &str,
2755        expected_base_generation: IndexGeneration,
2756    ) -> DbResult<IndexPublicationGuard<'_>> {
2757        self.begin_publication(
2758            PublicationContract::Projection(contract_fingerprint.to_string()),
2759            Some(expected_base_generation),
2760        )
2761    }
2762
2763    /// Begin one parent-owned atomic publication transaction.
2764    fn begin_publication(
2765        &mut self,
2766        contract: PublicationContract,
2767        expected_base_generation: Option<IndexGeneration>,
2768    ) -> DbResult<IndexPublicationGuard<'_>> {
2769        begin_immediate_publication(&self.connection)?;
2770        let setup = (|| {
2771            schema::validate_active_native_binding(
2772                &self.connection,
2773                self.validated_project_root_identity.as_ref(),
2774                self.validated_project_instance_id,
2775            )?;
2776            let previous = load_index_publication(&self.connection)?;
2777            if let Some(expected) = expected_base_generation {
2778                let base_matches = match previous.as_ref() {
2779                    None => expected == IndexGeneration::ZERO,
2780                    Some(publication) => {
2781                        publication.state == IndexPublicationState::Complete
2782                            && publication.generation != IndexGeneration::ZERO
2783                            && publication.generation == expected
2784                    }
2785                };
2786                if !base_matches {
2787                    return Err(DbError::PublicationBaseGenerationChanged {
2788                        expected,
2789                        found: previous
2790                            .as_ref()
2791                            .map_or(IndexGeneration::ZERO, |publication| publication.generation),
2792                    });
2793                }
2794            }
2795            if let PublicationContract::Projection(expected) = &contract {
2796                let matches = previous.as_ref().is_some_and(|publication| {
2797                    publication.state == IndexPublicationState::Complete
2798                        && publication.contract_fingerprint.as_deref() == Some(expected.as_str())
2799                });
2800                if !matches {
2801                    return Err(DbError::PublicationContractChanged);
2802                }
2803            }
2804            set_metadata(
2805                &self.connection,
2806                INDEX_PUBLICATION_STATE_KEY,
2807                IndexPublicationState::Updating.as_str(),
2808            )?;
2809            Ok(previous.map_or(IndexGeneration::ZERO, |publication| publication.generation))
2810        })();
2811        let previous_generation = match setup {
2812            Ok(generation) => generation,
2813            Err(error) => {
2814                self.connection.execute_batch("ROLLBACK")?;
2815                return Err(error);
2816            }
2817        };
2818        Ok(IndexPublicationGuard {
2819            store: self,
2820            contract,
2821            previous_generation,
2822            scan_replacement_pending: false,
2823            active: true,
2824        })
2825    }
2826
2827    /// Start one stable read snapshot for freshness verification and every
2828    /// subsequent query used to construct the response.
2829    ///
2830    /// # Errors
2831    ///
2832    /// Returns an error if a snapshot is already active or `SQLite` cannot
2833    /// begin the transaction.
2834    pub fn begin_index_read_snapshot(&self) -> DbResult<()> {
2835        if self.read_snapshot_active.replace(true) {
2836            return Err(DbError::IndexReadSnapshotActive);
2837        }
2838        if let Err(error) = self.connection.execute_batch("BEGIN DEFERRED") {
2839            self.read_snapshot_active.set(false);
2840            return Err(error.into());
2841        }
2842        Ok(())
2843    }
2844
2845    /// Finish an active read snapshot before an optional telemetry write.
2846    ///
2847    /// This method is a no-op for stores that were not opened for a normal
2848    /// freshness-verified read.
2849    ///
2850    /// # Errors
2851    ///
2852    /// Returns an error if `SQLite` cannot finish the read transaction.
2853    pub fn finish_index_read_snapshot(&self) -> DbResult<()> {
2854        if !self.read_snapshot_active.get() {
2855            return Ok(());
2856        }
2857        self.connection.execute_batch("COMMIT")?;
2858        self.read_snapshot_active.set(false);
2859        Ok(())
2860    }
2861
2862    /// Load the current derived-index publication state when initialized.
2863    ///
2864    /// # Errors
2865    ///
2866    /// Returns an error if metadata is invalid or cannot be read.
2867    pub fn index_publication(&self) -> DbResult<Option<IndexPublication>> {
2868        load_index_publication(&self.connection)
2869    }
2870
2871    /// Replace the symbol graph for a file path.
2872    ///
2873    /// # Errors
2874    ///
2875    /// Returns an error if persistence fails.
2876    pub fn replace_symbol_graph(&mut self, graph: &SymbolGraph) -> DbResult<()> {
2877        let metadata = SourceParseMetadata::from_graph(graph);
2878        self.replace_symbol_graph_with_metadata(graph, &metadata)
2879    }
2880
2881    /// Replace one file's symbol graph while preserving independent source parse metadata.
2882    ///
2883    /// This permits a grammar-backed source parse to coexist with conservative fallback
2884    /// symbol and relationship facts without relabeling those facts as grammar-native.
2885    ///
2886    /// # Errors
2887    ///
2888    /// Returns an error if metadata identity/counts differ from the graph or persistence fails.
2889    pub fn replace_symbol_graph_with_metadata(
2890        &mut self,
2891        graph: &SymbolGraph,
2892        metadata: &SourceParseMetadata,
2893    ) -> DbResult<()> {
2894        if metadata.path != graph.path
2895            || metadata.language != graph.language
2896            || metadata.symbol_count != graph.symbols.len()
2897            || metadata.relation_count != graph.relations.len()
2898        {
2899            return Err(DbError::SymbolGraphRowShape {
2900                path: graph.path.clone(),
2901                reason: "source parse metadata identity or fact counts differ from the graph",
2902            });
2903        }
2904        let savepoint = self.validated_savepoint()?;
2905        let node_id = {
2906            let mut delete_symbols =
2907                savepoint.prepare_cached("DELETE FROM symbols WHERE path = ?1")?;
2908            let mut delete_relations =
2909                savepoint.prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?;
2910            delete_symbols.execute([&graph.path])?;
2911            delete_relations.execute([&graph.path])?;
2912
2913            let mut upsert_metadata = savepoint.prepare_cached(
2914                "
2915                INSERT INTO source_parse_metadata(
2916                    path,
2917                    language,
2918                    source_parser,
2919                    fact_parser,
2920                    symbol_count,
2921                    relation_count,
2922                    updated_at
2923                )
2924                VALUES(?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
2925                ON CONFLICT(path) DO UPDATE SET
2926                    language = excluded.language,
2927                    source_parser = excluded.source_parser,
2928                    fact_parser = excluded.fact_parser,
2929                    symbol_count = excluded.symbol_count,
2930                    relation_count = excluded.relation_count,
2931                    updated_at = CURRENT_TIMESTAMP
2932                ",
2933            )?;
2934            upsert_metadata.execute(params![
2935                metadata.path,
2936                metadata.language.as_deref(),
2937                metadata.parser.to_string(),
2938                graph.parser.to_string(),
2939                usize_to_i64(metadata.symbol_count),
2940                usize_to_i64(metadata.relation_count),
2941            ])?;
2942            let mut select_node = savepoint
2943                .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?;
2944            let node_id = select_node
2945                .query_row([&graph.path], |row| row.get::<_, i64>(0))
2946                .optional()?;
2947
2948            let mut insert_symbol = savepoint.prepare_cached(
2949                "
2950                INSERT INTO symbols(
2951                    path,
2952                    language,
2953                    name,
2954                    kind,
2955                    signature,
2956                    exported,
2957                    documentation,
2958                    line_start,
2959                    line_end,
2960                    source_byte_start,
2961                    source_byte_end,
2962                    source_column_start,
2963                    source_column_end,
2964                    parent,
2965                    parser,
2966                    detail
2967                )
2968                VALUES(
2969                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
2970                    ?13, ?14, ?15, ?16
2971                )
2972                ",
2973            )?;
2974            for symbol in &graph.symbols {
2975                let selector = symbol_source_selector_values(symbol)?;
2976                insert_symbol.execute(params![
2977                    symbol.path,
2978                    symbol.language.as_deref(),
2979                    symbol.name,
2980                    symbol.kind.to_string(),
2981                    symbol.signature,
2982                    symbol.exported,
2983                    symbol.documentation.as_deref(),
2984                    usize_to_i64(symbol.line_start),
2985                    usize_to_i64(symbol.line_end),
2986                    selector[0],
2987                    selector[1],
2988                    selector[2],
2989                    selector[3],
2990                    symbol.parent.as_deref(),
2991                    symbol.parser.to_string(),
2992                    symbol.detail.as_deref(),
2993                ])?;
2994            }
2995
2996            let mut insert_relation = savepoint.prepare_cached(
2997                "
2998                INSERT INTO symbol_relations(
2999                    path,
3000                    source_name,
3001                    target_name,
3002                    kind,
3003                    line,
3004                    context,
3005                    parser
3006                )
3007                VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)
3008                ",
3009            )?;
3010            for relation in &graph.relations {
3011                insert_relation.execute(params![
3012                    relation.path,
3013                    relation.source_name,
3014                    relation.target_name,
3015                    relation.kind.to_string(),
3016                    usize_to_i64(relation.line),
3017                    relation.context,
3018                    relation.parser.to_string(),
3019                ])?;
3020            }
3021            node_id
3022        };
3023        if let Some(node_id) = node_id {
3024            replace_symbol_search_summary(
3025                &savepoint,
3026                node_id,
3027                symbol_search_summary(graph).as_deref(),
3028            )?;
3029        }
3030        savepoint.commit()?;
3031        Ok(())
3032    }
3033
3034    /// Clear source-derived intelligence for one live file path.
3035    ///
3036    /// This removes symbols, relations, and the node-level content summary so
3037    /// skipped or failed parser work cannot leave stale source facts visible.
3038    ///
3039    /// # Errors
3040    ///
3041    /// Returns an error if the path does not exist or persistence fails.
3042    pub fn clear_source_index_for_path(&self, path: &str) -> DbResult<()> {
3043        self.with_validated_write(|connection| {
3044            let node_id = self.node_id_for_path(path)?;
3045            connection
3046                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3047                .execute([path])?;
3048            connection
3049                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3050                .execute([path])?;
3051            connection
3052                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3053                .execute([path])?;
3054            connection
3055                .prepare_cached(
3056                    "
3057            DELETE FROM summaries
3058            WHERE node_id = ?1
3059              AND (
3060                    (summary_level = 'node' AND subject = '')
3061                    OR (summary_level = 'search' AND subject = 'symbols')
3062                  )
3063            ",
3064                )?
3065                .execute([node_id])?;
3066            Ok(())
3067        })
3068    }
3069
3070    /// Clear symbols and relations for one live file path while preserving node summaries.
3071    ///
3072    /// # Errors
3073    ///
3074    /// Returns an error if persistence fails.
3075    pub fn clear_symbol_graph_for_path(&self, path: &str) -> DbResult<()> {
3076        self.with_validated_write(|connection| {
3077            let node_id = self.node_id_for_path(path)?;
3078            connection
3079                .prepare_cached("DELETE FROM symbols WHERE path = ?1")?
3080                .execute([path])?;
3081            connection
3082                .prepare_cached("DELETE FROM symbol_relations WHERE path = ?1")?
3083                .execute([path])?;
3084            connection
3085                .prepare_cached("DELETE FROM source_parse_metadata WHERE path = ?1")?
3086                .execute([path])?;
3087            connection
3088                .prepare_cached(
3089                    "
3090            DELETE FROM summaries
3091            WHERE node_id = ?1
3092              AND summary_level = 'search'
3093              AND subject = 'symbols'
3094            ",
3095                )?
3096                .execute([node_id])?;
3097            Ok(())
3098        })
3099    }
3100
3101    /// Persist an observed one-line summary for an indexed node.
3102    ///
3103    /// # Errors
3104    ///
3105    /// Returns an error if the path does not exist or persistence fails.
3106    pub fn set_node_summary(&self, path: &str, summary: &str) -> DbResult<()> {
3107        self.with_validated_write(|connection| {
3108            let node_id = self.node_id_for_path(path)?;
3109            connection
3110                .prepare_cached(
3111                    "
3112            INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
3113            VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
3114            ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
3115                summary_level = 'node',
3116                subject = '',
3117                summary = excluded.summary,
3118                updated_at = CURRENT_TIMESTAMP
3119            ",
3120                )?
3121                .execute(params![node_id, summary])?;
3122            Ok(())
3123        })
3124    }
3125
3126    /// Remove the observed node-level summary for an indexed node.
3127    ///
3128    /// # Errors
3129    ///
3130    /// Returns an error if the path does not exist or persistence fails.
3131    pub fn clear_node_summary(&self, path: &str) -> DbResult<()> {
3132        self.with_validated_write(|connection| {
3133            let node_id = self.node_id_for_path(path)?;
3134            connection
3135                .prepare_cached(
3136                    "
3137            DELETE FROM summaries
3138            WHERE node_id = ?1
3139              AND summary_level = 'node'
3140              AND subject = ''
3141            ",
3142                )?
3143                .execute([node_id])?;
3144            Ok(())
3145        })
3146    }
3147
3148    /// Load symbols filtered by optional file path and query.
3149    ///
3150    /// # Errors
3151    ///
3152    /// Returns an error if reading fails.
3153    pub fn load_symbols(
3154        &self,
3155        file: Option<&str>,
3156        query: Option<&str>,
3157        limit: usize,
3158    ) -> DbResult<Vec<CodeSymbol>> {
3159        let max_rows = usize_to_i64(limit.max(1));
3160        match (file, query) {
3161            (Some(file), Some(query)) => self.query_symbols(
3162                "
3163                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3164                       source_byte_start, source_byte_end, source_column_start, source_column_end
3165                FROM symbols
3166                WHERE path = ?1 AND (name LIKE ?2 OR signature LIKE ?2 OR documentation LIKE ?2)
3167                ORDER BY path, line_start, name
3168                LIMIT ?3
3169                ",
3170                params![file, like_query(query), max_rows],
3171            ),
3172            (Some(file), None) => self.query_symbols(
3173                "
3174                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3175                       source_byte_start, source_byte_end, source_column_start, source_column_end
3176                FROM symbols
3177                WHERE path = ?1
3178                ORDER BY path, line_start, name
3179                LIMIT ?2
3180                ",
3181                params![file, max_rows],
3182            ),
3183            (None, Some(query)) => self.query_symbols(
3184                "
3185                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3186                       source_byte_start, source_byte_end, source_column_start, source_column_end
3187                FROM symbols
3188                WHERE name LIKE ?1 OR signature LIKE ?1 OR documentation LIKE ?1 OR path LIKE ?1
3189                ORDER BY path, line_start, name
3190                LIMIT ?2
3191                ",
3192                params![like_query(query), max_rows],
3193            ),
3194            (None, None) => self.query_symbols(
3195                "
3196                SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3197                       source_byte_start, source_byte_end, source_column_start, source_column_end
3198                FROM symbols
3199                ORDER BY path, line_start, name
3200                LIMIT ?1
3201                ",
3202                params![max_rows],
3203            ),
3204        }
3205    }
3206
3207    /// Load symbols with their owning file classification and optional content selection.
3208    ///
3209    /// Omitted selection retains the legacy symbol candidate universe and order. Explicit
3210    /// selection is applied by `SQLite` before the row limit.
3211    ///
3212    /// # Errors
3213    ///
3214    /// Returns an error if a classification is missing or corrupt, or reading fails.
3215    pub fn load_classified_symbols(
3216        &self,
3217        file: Option<&str>,
3218        query: Option<&str>,
3219        selection: ContentSelection,
3220        limit: usize,
3221    ) -> DbResult<Vec<ClassifiedSymbol>> {
3222        let (sql, bindings) = classified_symbols_sql(file, query, selection, limit);
3223        let mut statement = self.connection.prepare(&sql)?;
3224        let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3225        let mut symbols = Vec::new();
3226        while let Some(row) = rows.next()? {
3227            symbols.push(classified_symbol_from_row(row)?);
3228        }
3229        Ok(symbols)
3230    }
3231
3232    /// Load one bounded deterministic symbol set for exact repository paths.
3233    ///
3234    /// The request uses indexed `symbols.path` predicates, chunks bindings
3235    /// below the `SQLite` variable ceiling, and preserves the store's active
3236    /// read snapshot. The service remains responsible for matching exact
3237    /// declaration identities within these admitted owning paths.
3238    ///
3239    /// # Errors
3240    ///
3241    /// Returns an error when a path/budget is invalid, cancellation or the
3242    /// request deadline fires, a row is corrupt, or `SQLite` iteration fails.
3243    pub fn load_symbols_for_paths_bounded(
3244        &self,
3245        paths: &[String],
3246        budget: SymbolBatchReadBudget,
3247        control: Option<&IndexWorkControl>,
3248    ) -> DbResult<SymbolBatchRead> {
3249        let path_limit =
3250            usize::try_from(budget.paths()).map_err(|source| DbError::InvalidCount {
3251                field: "symbol batch paths",
3252                value: i64::from(budget.paths()),
3253                source,
3254            })?;
3255        let mut exact_paths = BTreeSet::new();
3256        let mut reached_limit = None;
3257        for path in paths {
3258            if let Some(control) = control {
3259                control.check(IndexWorkStage::RepositoryTraversal)?;
3260            }
3261            exact_paths.insert(path.clone());
3262            if exact_paths.len() > path_limit {
3263                exact_paths.pop_last();
3264                reached_limit.get_or_insert(SymbolBatchReadLimit::Paths);
3265            }
3266        }
3267        if exact_paths.is_empty() {
3268            return Ok(SymbolBatchRead::default());
3269        }
3270        let exact_paths = exact_paths.into_iter().collect::<Vec<_>>();
3271        let row_limit = usize::try_from(budget.rows()).map_err(|source| DbError::InvalidCount {
3272            field: "symbol batch rows",
3273            value: i64::from(budget.rows()),
3274            source,
3275        })?;
3276        let mut symbols = Vec::new();
3277        let mut decoded_bytes = 0_u64;
3278        'chunks: for chunk in exact_paths.chunks(SYMBOL_BATCH_BIND_PATHS) {
3279            if let Some(control) = control {
3280                control.check(IndexWorkStage::RepositoryTraversal)?;
3281            }
3282            let remaining_rows = row_limit.saturating_sub(symbols.len());
3283            if remaining_rows == 0 {
3284                reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3285                break;
3286            }
3287            let placeholders = numbered_placeholders(1, chunk.len());
3288            let limit_parameter = chunk.len().saturating_add(1);
3289            let sql = format!(
3290                "
3291                SELECT
3292                    path,
3293                    language,
3294                    name,
3295                    kind,
3296                    signature,
3297                    line_start,
3298                    line_end,
3299                    parent,
3300                    parser,
3301                    detail,
3302                    exported,
3303                    documentation,
3304                    source_byte_start,
3305                    source_byte_end,
3306                    source_column_start,
3307                    source_column_end,
3308                    length(CAST(path AS BLOB))
3309                        + COALESCE(length(CAST(language AS BLOB)), 0)
3310                        + length(CAST(name AS BLOB))
3311                        + length(CAST(signature AS BLOB))
3312                        + COALESCE(length(CAST(parent AS BLOB)), 0)
3313                        + COALESCE(length(CAST(detail AS BLOB)), 0)
3314                        + COALESCE(length(CAST(documentation AS BLOB)), 0)
3315                FROM symbols
3316                WHERE path IN ({placeholders})
3317                ORDER BY path, line_start, name
3318                LIMIT ?{limit_parameter}
3319                "
3320            );
3321            let mut bindings = chunk.iter().cloned().map(Value::Text).collect::<Vec<_>>();
3322            bindings.push(Value::Integer(usize_to_i64(
3323                remaining_rows.saturating_add(1),
3324            )));
3325            with_sqlite_read_progress(
3326                &self.connection,
3327                control,
3328                IndexWorkStage::RepositoryTraversal,
3329                || {
3330                    let mut statement = self.connection.prepare(&sql)?;
3331                    let mut rows = statement.query(params_from_iter(bindings.iter()))?;
3332                    while let Some(row) = rows.next()? {
3333                        if let Some(control) = control {
3334                            control.check(IndexWorkStage::RepositoryTraversal)?;
3335                        }
3336                        if symbols.len() >= row_limit {
3337                            reached_limit.get_or_insert(SymbolBatchReadLimit::Rows);
3338                            break;
3339                        }
3340                        let preflight_bytes = code_symbol_preflight_bytes(row)?;
3341                        if decoded_bytes.saturating_add(preflight_bytes) > budget.decoded_bytes() {
3342                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3343                            break;
3344                        }
3345                        let symbol = code_symbol_from_row(row)?;
3346                        let row_bytes = code_symbol_decoded_bytes(&symbol)?;
3347                        if decoded_bytes.saturating_add(row_bytes) > budget.decoded_bytes() {
3348                            reached_limit.get_or_insert(SymbolBatchReadLimit::DecodedBytes);
3349                            break;
3350                        }
3351                        decoded_bytes = decoded_bytes.saturating_add(row_bytes);
3352                        symbols.push(symbol);
3353                    }
3354                    Ok(())
3355                },
3356            )?;
3357            if matches!(
3358                reached_limit,
3359                Some(SymbolBatchReadLimit::Rows | SymbolBatchReadLimit::DecodedBytes)
3360            ) {
3361                break 'chunks;
3362            }
3363        }
3364        symbols.sort_by(|left, right| {
3365            (&left.path, left.line_start, &left.name, &left.signature).cmp(&(
3366                &right.path,
3367                right.line_start,
3368                &right.name,
3369                &right.signature,
3370            ))
3371        });
3372        let symbols = symbols.into_boxed_slice().into_vec();
3373        Ok(SymbolBatchRead {
3374            work: SymbolBatchReadWork {
3375                requested_paths: u32::try_from(exact_paths.len()).unwrap_or(u32::MAX),
3376                returned_rows: u32::try_from(symbols.len()).unwrap_or(u32::MAX),
3377                decoded_bytes,
3378            },
3379            rows: symbols,
3380            truncated: reached_limit.is_some(),
3381            reached_limit,
3382        })
3383    }
3384
3385    /// Load symbols for a file and one or more exact kinds.
3386    ///
3387    /// # Errors
3388    ///
3389    /// Returns an error if reading fails.
3390    pub fn load_symbols_by_kinds(
3391        &self,
3392        file: &str,
3393        kinds: &[SymbolKind],
3394        limit: usize,
3395    ) -> DbResult<Vec<CodeSymbol>> {
3396        if kinds.is_empty() {
3397            return Ok(Vec::new());
3398        }
3399        let max_rows = usize_to_i64(limit.max(1));
3400        let placeholders = numbered_placeholders(2, kinds.len());
3401        let sql = format!(
3402            "
3403            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3404                   source_byte_start, source_byte_end, source_column_start, source_column_end
3405            FROM symbols INDEXED BY idx_symbols_path
3406            WHERE path = ?1 AND kind IN ({placeholders})
3407            ORDER BY path, line_start, name
3408            LIMIT {max_rows}
3409            "
3410        );
3411        let mut values = Vec::with_capacity(kinds.len() + 1);
3412        values.push(file.to_string());
3413        values.extend(kinds.iter().map(ToString::to_string));
3414        self.query_symbols(&sql, params_from_iter(values.iter()))
3415    }
3416
3417    /// Count symbols for a file and one or more exact kinds.
3418    ///
3419    /// # Errors
3420    ///
3421    /// Returns an error if reading fails.
3422    pub fn count_symbols_by_kinds(&self, file: &str, kinds: &[SymbolKind]) -> DbResult<usize> {
3423        if kinds.is_empty() {
3424            return Ok(0);
3425        }
3426        let placeholders = numbered_placeholders(2, kinds.len());
3427        let sql = format!(
3428            "SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
3429             WHERE path = ?1 AND kind IN ({placeholders})"
3430        );
3431        let mut values = Vec::with_capacity(kinds.len() + 1);
3432        values.push(file.to_string());
3433        values.extend(kinds.iter().map(ToString::to_string));
3434        let count = self
3435            .connection
3436            .query_row(&sql, params_from_iter(values.iter()), |row| {
3437                row.get::<_, i64>(0)
3438            })?;
3439        Ok(i64_to_usize(count))
3440    }
3441
3442    /// Count indexed symbols grouped by exact name.
3443    ///
3444    /// # Errors
3445    ///
3446    /// Returns an error if reading fails.
3447    pub fn symbol_name_counts(&self, names: &[String]) -> DbResult<HashMap<String, usize>> {
3448        if names.is_empty() {
3449            return Ok(HashMap::new());
3450        }
3451        let placeholders = numbered_placeholders(1, names.len());
3452        let sql = format!(
3453            "SELECT name, COUNT(*) FROM symbols WHERE name IN ({placeholders}) GROUP BY name"
3454        );
3455        let mut statement = self.connection.prepare(&sql)?;
3456        let rows = statement.query_map(params_from_iter(names.iter()), |row| {
3457            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
3458        })?;
3459        let mut counts = HashMap::new();
3460        for row in rows {
3461            let (name, count) = row?;
3462            counts.insert(name, i64_to_usize(count));
3463        }
3464        Ok(counts)
3465    }
3466
3467    /// Load symbols with exact names.
3468    ///
3469    /// # Errors
3470    ///
3471    /// Returns an error if reading fails.
3472    pub fn load_symbols_by_names(&self, names: &[String]) -> DbResult<Vec<CodeSymbol>> {
3473        if names.is_empty() {
3474            return Ok(Vec::new());
3475        }
3476        let placeholders = numbered_placeholders(1, names.len());
3477        let sql = format!(
3478            "
3479            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3480                   source_byte_start, source_byte_end, source_column_start, source_column_end
3481            FROM symbols
3482            WHERE name IN ({placeholders})
3483            ORDER BY path, line_start, name
3484            "
3485        );
3486        self.query_symbols(&sql, params_from_iter(names.iter()))
3487    }
3488
3489    /// Load exported symbol names for one file.
3490    ///
3491    /// # Errors
3492    ///
3493    /// Returns an error if reading fails.
3494    pub fn load_exported_symbol_names_for_path(
3495        &self,
3496        file: &str,
3497        limit: usize,
3498    ) -> DbResult<Vec<String>> {
3499        let max_rows = usize_to_i64(limit.max(1));
3500        let mut statement = self.connection.prepare(
3501            "
3502            SELECT DISTINCT name
3503            FROM symbols
3504            WHERE path = ?1 AND exported = 1
3505            ORDER BY name
3506            LIMIT ?2
3507            ",
3508        )?;
3509        let rows = statement.query_map(params![file, max_rows], |row| row.get::<_, String>(0))?;
3510        let mut names = Vec::new();
3511        for row in rows {
3512            names.push(row?);
3513        }
3514        Ok(names)
3515    }
3516
3517    /// Count exported symbol names for one file.
3518    ///
3519    /// # Errors
3520    ///
3521    /// Returns an error if reading fails.
3522    pub fn exported_symbol_count_for_path(&self, file: &str) -> DbResult<usize> {
3523        let count = self.connection.query_row(
3524            "SELECT COUNT(DISTINCT name) FROM symbols WHERE path = ?1 AND exported = 1",
3525            [file],
3526            |row| row.get::<_, i64>(0),
3527        )?;
3528        Ok(i64_to_usize(count))
3529    }
3530
3531    /// Load one symbol by exact file and name.
3532    ///
3533    /// # Errors
3534    ///
3535    /// Returns an error if reading fails.
3536    pub fn load_symbol_by_name(&self, file: &str, name: &str) -> DbResult<Option<CodeSymbol>> {
3537        let mut symbols = self.load_symbols(Some(file), Some(name), 100)?;
3538        symbols.retain(|symbol| symbol.name == name);
3539        Ok(symbols.into_iter().next())
3540    }
3541
3542    /// Load all symbols with an exact file and name.
3543    ///
3544    /// # Errors
3545    ///
3546    /// Returns an error if reading fails.
3547    pub fn load_symbols_by_exact_file_and_name(
3548        &self,
3549        file: &str,
3550        name: &str,
3551    ) -> DbResult<Vec<CodeSymbol>> {
3552        self.query_symbols(
3553            "
3554            SELECT path, language, name, kind, signature, line_start, line_end, parent, parser, detail, exported, documentation,
3555                   source_byte_start, source_byte_end, source_column_start, source_column_end
3556            FROM symbols
3557            WHERE path = ?1 AND name = ?2
3558            ORDER BY line_start, line_end, kind, parent
3559            ",
3560            params![file, name],
3561        )
3562    }
3563
3564    /// Load one existing node with purpose state by repository path.
3565    ///
3566    /// # Errors
3567    ///
3568    /// Returns an error if reading or enum conversion fails.
3569    pub fn load_node_by_path(&self, path: &str) -> DbResult<Option<IndexedNode>> {
3570        let mut statement = self.connection.prepare(
3571            "
3572            SELECT
3573                n.path,
3574                n.kind,
3575                n.parent_path,
3576                n.extension,
3577                n.language,
3578                n.size_bytes,
3579                n.mtime_ns,
3580                n.content_hash,
3581                p.purpose,
3582                p.source,
3583                p.status,
3584                s.summary
3585            FROM nodes n
3586            JOIN purposes p ON p.node_id = n.id
3587            LEFT JOIN summaries s ON s.node_id = n.id
3588                AND s.summary_level = 'node'
3589                AND s.subject = ''
3590            WHERE n.exists_now = 1 AND n.path = ?1
3591            ",
3592        )?;
3593        let row = statement
3594            .query_row([path], |row| {
3595                let kind_value: String = row.get(1)?;
3596                let source_value: String = row.get(9)?;
3597                let status_value: String = row.get(10)?;
3598                Ok((
3599                    row.get::<_, String>(0)?,
3600                    kind_value,
3601                    row.get::<_, Option<String>>(2)?,
3602                    row.get::<_, Option<String>>(3)?,
3603                    row.get::<_, Option<String>>(4)?,
3604                    option_u64_from_sql(row, 5)?,
3605                    row.get::<_, Option<i64>>(6)?,
3606                    row.get::<_, Option<String>>(7)?,
3607                    row.get::<_, Option<String>>(8)?,
3608                    source_value,
3609                    status_value,
3610                    row.get::<_, Option<String>>(11)?,
3611                ))
3612            })
3613            .optional()?;
3614        row.map(indexed_node_from_parts).transpose()
3615    }
3616
3617    /// Load existing nodes for exact repository paths.
3618    ///
3619    /// # Errors
3620    ///
3621    /// Returns an error if reading or enum conversion fails.
3622    pub fn load_nodes_by_paths(&self, paths: &[String]) -> DbResult<Vec<IndexedNode>> {
3623        self.load_nodes_by_paths_controlled(paths, None)
3624    }
3625
3626    /// Load existing nodes for exact repository paths in bounded cancellable batches.
3627    ///
3628    /// # Errors
3629    ///
3630    /// Returns an error if cancellation fires or reading or enum conversion fails.
3631    pub fn load_nodes_by_paths_controlled(
3632        &self,
3633        paths: &[String],
3634        control: Option<&IndexWorkControl>,
3635    ) -> DbResult<Vec<IndexedNode>> {
3636        let mut unique_paths = paths.to_vec();
3637        unique_paths.sort();
3638        unique_paths.dedup();
3639        if unique_paths.is_empty() {
3640            return Ok(Vec::new());
3641        }
3642        let mut nodes = Vec::new();
3643        for chunk in unique_paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3644            let hydrated = with_sqlite_read_progress(
3645                &self.connection,
3646                control,
3647                IndexWorkStage::RepositoryTraversal,
3648                || {
3649                    let sql = load_nodes_by_paths_sql(chunk.len());
3650                    let mut statement = self.connection.prepare(&sql)?;
3651                    let rows = statement.query_map(params_from_iter(chunk), |row| {
3652                        let kind_value: String = row.get(1)?;
3653                        let source_value: String = row.get(9)?;
3654                        let status_value: String = row.get(10)?;
3655                        Ok((
3656                            row.get::<_, String>(0)?,
3657                            kind_value,
3658                            row.get::<_, Option<String>>(2)?,
3659                            row.get::<_, Option<String>>(3)?,
3660                            row.get::<_, Option<String>>(4)?,
3661                            option_u64_from_sql(row, 5)?,
3662                            row.get::<_, Option<i64>>(6)?,
3663                            row.get::<_, Option<String>>(7)?,
3664                            row.get::<_, Option<String>>(8)?,
3665                            source_value,
3666                            status_value,
3667                            row.get::<_, Option<String>>(11)?,
3668                        ))
3669                    })?;
3670                    let mut selected = Vec::new();
3671                    for row in rows {
3672                        selected.push(indexed_node_from_parts(row?)?);
3673                    }
3674                    Ok(selected)
3675                },
3676            )?;
3677            nodes.extend(hydrated);
3678        }
3679        Ok(nodes)
3680    }
3681
3682    /// Hydrate every exact purpose-owner path under one graph read envelope.
3683    ///
3684    /// Rows are returned in caller order. Unlike the compatibility path loader,
3685    /// this graph-facing boundary evaluates every unique requested path in the
3686    /// selected project and generation. Existing rows retain caller order;
3687    /// absent ancestor candidates are represented by omission rather than a
3688    /// partial-query failure.
3689    ///
3690    /// # Errors
3691    ///
3692    /// Returns an error for duplicate paths, a stale project or generation,
3693    /// cancellation, corrupt node or purpose state, `SQLite`
3694    /// failure, or any returned-row, decoded-byte, or hydrated-path budget
3695    /// overrun. No partial batch is returned.
3696    pub fn load_purpose_owner_nodes_by_paths_controlled(
3697        &self,
3698        project: ProjectInstanceId,
3699        generation: IndexGeneration,
3700        paths: &[String],
3701        budget: RepositoryGraphReadBudget,
3702        control: Option<&IndexWorkControl>,
3703    ) -> DbResult<RepositoryGraphReadBatch<IndexedNode>> {
3704        if paths
3705            .iter()
3706            .map(String::as_str)
3707            .collect::<HashSet<_>>()
3708            .len()
3709            != paths.len()
3710        {
3711            return Err(GraphContractError::InvalidLimits {
3712                reason: "purpose-owner hydration paths must be unique",
3713            }
3714            .into());
3715        }
3716        self.require_repository_graph_snapshot(project, generation)?;
3717        let mut meter = repository_graph::RepositoryGraphReadMeter::new(budget, paths.len())?;
3718        let path_count =
3719            u32::try_from(paths.len()).map_err(|_source| GraphContractError::InvalidLimits {
3720                reason: "purpose-owner hydration path count overflowed",
3721            })?;
3722        if path_count > budget.returned_rows() {
3723            return Err(GraphContractError::InvalidLimits {
3724                reason: "purpose-owner paths exceed the return budget",
3725            }
3726            .into());
3727        }
3728        if path_count > budget.hydrated_paths() {
3729            return Err(GraphContractError::InvalidLimits {
3730                reason: "purpose-owner paths exceed the hydration budget",
3731            }
3732            .into());
3733        }
3734        if paths.is_empty() {
3735            return Ok(RepositoryGraphReadBatch {
3736                rows: Vec::new(),
3737                work: meter.finish(0)?,
3738            });
3739        }
3740
3741        let mut selected = HashMap::with_capacity(paths.len());
3742        for chunk in paths.chunks(MAX_PURPOSE_CURATION_BATCH_ROWS) {
3743            let hydrated = with_sqlite_read_progress(
3744                &self.connection,
3745                control,
3746                IndexWorkStage::RepositoryTraversal,
3747                || {
3748                    let sql = load_nodes_by_paths_sql(chunk.len());
3749                    let mut statement = self.connection.prepare(&sql)?;
3750                    let mut rows = statement.query(params_from_iter(chunk))?;
3751                    let mut nodes = Vec::new();
3752                    while let Some(row) = rows.next()? {
3753                        let parts = indexed_node_parts_from_sql_row(row)?;
3754                        meter.record_decoded_bytes(indexed_node_parts_decoded_bytes(&parts)?)?;
3755                        let node = indexed_node_from_parts(parts)?;
3756                        meter.record_hydrated_path(&node.node.path)?;
3757                        nodes.push(node);
3758                    }
3759                    Ok(nodes)
3760                },
3761            )?;
3762            for node in hydrated {
3763                let path = node.node.path.clone();
3764                if selected.insert(path, node).is_some() {
3765                    return Err(DbError::GraphRowShape {
3766                        table: "nodes",
3767                        reason: "purpose-owner hydration returned a duplicate path",
3768                    });
3769                }
3770            }
3771        }
3772
3773        let mut ordered = Vec::with_capacity(paths.len());
3774        for path in paths {
3775            if let Some(node) = selected.remove(path) {
3776                ordered.push(node);
3777            }
3778        }
3779        let work = meter.finish(ordered.len())?;
3780        Ok(RepositoryGraphReadBatch {
3781            rows: ordered,
3782            work,
3783        })
3784    }
3785
3786    /// Load one bounded stale-safe purpose-curation batch for exact paths.
3787    ///
3788    /// The node hydration is one prepared set query regardless of row count.
3789    /// Accepted purposes are intentionally omitted from curator work.
3790    ///
3791    /// # Errors
3792    ///
3793    /// Returns an error for an invalid task, oversized batch, changed project
3794    /// binding, invalid persisted state, or failed `SQLite` read.
3795    pub fn load_purpose_curation_batch(
3796        &self,
3797        task: &str,
3798        paths: &[String],
3799    ) -> DbResult<PurposeCurationBatch> {
3800        let task = normalize_purpose_curation_task(task)?;
3801        let mut unique_paths = paths.to_vec();
3802        unique_paths.sort();
3803        unique_paths.dedup();
3804        if unique_paths.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3805            return Err(DbError::PurposeCurationBatchTooLarge {
3806                requested: unique_paths.len(),
3807                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
3808            });
3809        }
3810        let (project_instance_id, active_generation) =
3811            self.purpose_curation_context(&self.connection)?;
3812        let items = self
3813            .load_nodes_by_paths(&unique_paths)?
3814            .into_iter()
3815            .filter(|node| {
3816                matches!(
3817                    node.purpose.status,
3818                    PurposeStatus::Missing | PurposeStatus::Suggested
3819                )
3820            })
3821            .map(|node| {
3822                purpose_curation_candidate(project_instance_id, active_generation, &task, node)
3823            })
3824            .collect::<Vec<_>>();
3825        let work_key =
3826            purpose_curation_batch_work_key(project_instance_id, active_generation, &task, &items);
3827        Ok(PurposeCurationBatch {
3828            project_instance_id,
3829            active_generation,
3830            task,
3831            work_key,
3832            items,
3833        })
3834    }
3835
3836    /// Load symbol relations filtered by optional file path and query.
3837    ///
3838    /// # Errors
3839    ///
3840    /// Returns an error if reading fails.
3841    pub fn load_symbol_relations(
3842        &self,
3843        file: Option<&str>,
3844        query: Option<&str>,
3845        limit: usize,
3846    ) -> DbResult<Vec<SymbolRelation>> {
3847        let max_rows = usize_to_i64(limit.max(1));
3848        match (file, query) {
3849            (Some(file), Some(query)) => self.query_relations(
3850                "
3851                SELECT path, source_name, target_name, kind, line, context, parser
3852                FROM symbol_relations
3853                WHERE path = ?1 AND (source_name LIKE ?2 OR target_name LIKE ?2 OR context LIKE ?2)
3854                ORDER BY path, line, source_name, target_name
3855                LIMIT ?3
3856                ",
3857                params![file, like_query(query), max_rows],
3858            ),
3859            (Some(file), None) => self.query_relations(
3860                "
3861                SELECT path, source_name, target_name, kind, line, context, parser
3862                FROM symbol_relations
3863                WHERE path = ?1
3864                ORDER BY path, line, source_name, target_name
3865                LIMIT ?2
3866                ",
3867                params![file, max_rows],
3868            ),
3869            (None, Some(query)) => self.query_relations(
3870                "
3871                SELECT path, source_name, target_name, kind, line, context, parser
3872                FROM symbol_relations
3873                WHERE source_name LIKE ?1 OR target_name LIKE ?1 OR context LIKE ?1 OR path LIKE ?1
3874                ORDER BY path, line, source_name, target_name
3875                LIMIT ?2
3876                ",
3877                params![like_query(query), max_rows],
3878            ),
3879            (None, None) => self.query_relations(
3880                "
3881                SELECT path, source_name, target_name, kind, line, context, parser
3882                FROM symbol_relations
3883                ORDER BY path, line, source_name, target_name
3884                LIMIT ?1
3885                ",
3886                params![max_rows],
3887            ),
3888        }
3889    }
3890
3891    /// Load symbol relations for a file and exact relation kind.
3892    ///
3893    /// # Errors
3894    ///
3895    /// Returns an error if reading fails.
3896    pub fn load_symbol_relations_by_kind(
3897        &self,
3898        file: &str,
3899        kind: RelationKind,
3900        limit: usize,
3901    ) -> DbResult<Vec<SymbolRelation>> {
3902        let max_rows = usize_to_i64(limit.max(1));
3903        self.query_relations(
3904            "
3905            SELECT path, source_name, target_name, kind, line, context, parser
3906            FROM symbol_relations
3907            WHERE path = ?1 AND kind = ?2
3908            ORDER BY path, line, source_name, target_name
3909            LIMIT ?3
3910            ",
3911            params![file, kind.to_string(), max_rows],
3912        )
3913    }
3914
3915    /// Count symbol relations for a file and exact relation kind.
3916    ///
3917    /// # Errors
3918    ///
3919    /// Returns an error if reading fails.
3920    pub fn count_symbol_relations_by_kind(
3921        &self,
3922        file: &str,
3923        kind: RelationKind,
3924    ) -> DbResult<usize> {
3925        let count = self.connection.query_row(
3926            "SELECT COUNT(*) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3927            params![file, kind.to_string()],
3928            |row| row.get::<_, i64>(0),
3929        )?;
3930        Ok(i64_to_usize(count))
3931    }
3932
3933    /// Load distinct relation targets for a file and exact relation kind.
3934    ///
3935    /// # Errors
3936    ///
3937    /// Returns an error if reading fails.
3938    pub fn load_distinct_relation_targets_by_kind(
3939        &self,
3940        file: &str,
3941        kind: RelationKind,
3942        limit: usize,
3943    ) -> DbResult<Vec<String>> {
3944        let max_rows = usize_to_i64(limit.max(1));
3945        let mut statement = self.connection.prepare(
3946            "
3947            SELECT DISTINCT target_name
3948            FROM symbol_relations
3949            WHERE path = ?1 AND kind = ?2
3950            ORDER BY target_name
3951            LIMIT ?3
3952            ",
3953        )?;
3954        let rows = statement.query_map(params![file, kind.to_string(), max_rows], |row| {
3955            row.get::<_, String>(0)
3956        })?;
3957        let mut targets = Vec::new();
3958        for row in rows {
3959            targets.push(row?);
3960        }
3961        Ok(targets)
3962    }
3963
3964    /// Count distinct relation targets for a file and exact relation kind.
3965    ///
3966    /// # Errors
3967    ///
3968    /// Returns an error if reading fails.
3969    pub fn count_distinct_relation_targets_by_kind(
3970        &self,
3971        file: &str,
3972        kind: RelationKind,
3973    ) -> DbResult<usize> {
3974        let count = self.connection.query_row(
3975            "SELECT COUNT(DISTINCT target_name) FROM symbol_relations WHERE path = ?1 AND kind = ?2",
3976            params![file, kind.to_string()],
3977            |row| row.get::<_, i64>(0),
3978        )?;
3979        Ok(i64_to_usize(count))
3980    }
3981
3982    /// Load call relations targeting any of the requested symbol names.
3983    ///
3984    /// # Errors
3985    ///
3986    /// Returns an error if reading fails.
3987    pub fn load_call_relations_to_targets(
3988        &self,
3989        target_names: &[String],
3990        limit_per_target: usize,
3991    ) -> DbResult<Vec<SymbolRelation>> {
3992        if target_names.is_empty() {
3993            return Ok(Vec::new());
3994        }
3995        let placeholders = numbered_placeholders(1, target_names.len());
3996        let limit_placeholder = target_names.len() + 1;
3997        let sql = format!(
3998            "
3999            SELECT path, source_name, target_name, kind, line, context, parser
4000            FROM (
4001                SELECT
4002                    path,
4003                    source_name,
4004                    target_name,
4005                    kind,
4006                    line,
4007                    context,
4008                    parser,
4009                    ROW_NUMBER() OVER (
4010                        PARTITION BY target_name
4011                        ORDER BY path, line, source_name, target_name
4012                    ) AS target_row
4013                FROM symbol_relations INDEXED BY idx_symbol_relations_target
4014                WHERE kind = 'calls' AND target_name IN ({placeholders})
4015            )
4016            WHERE target_row <= ?{limit_placeholder}
4017            ORDER BY path, line, source_name, target_name
4018            "
4019        );
4020        let mut values = target_names
4021            .iter()
4022            .map(|target| Value::Text(target.clone()))
4023            .collect::<Vec<_>>();
4024        values.push(Value::Integer(usize_to_i64(limit_per_target.max(1))));
4025        let mut relations = self.query_relations(&sql, params_from_iter(values.iter()))?;
4026        relations.sort_by(|left, right| {
4027            left.path
4028                .cmp(&right.path)
4029                .then_with(|| left.line.cmp(&right.line))
4030                .then_with(|| left.source_name.cmp(&right.source_name))
4031                .then_with(|| left.target_name.cmp(&right.target_name))
4032        });
4033        relations.dedup_by(|left, right| {
4034            left.path == right.path
4035                && left.source_name == right.source_name
4036                && left.target_name == right.target_name
4037                && left.kind == right.kind
4038                && left.line == right.line
4039        });
4040        Ok(relations)
4041    }
4042
4043    /// Load import relations whose persisted target text mentions any term.
4044    ///
4045    /// # Errors
4046    ///
4047    /// Returns an error if reading fails.
4048    pub fn load_import_relations_matching_targets(
4049        &self,
4050        terms: &[String],
4051        limit_per_term: usize,
4052    ) -> DbResult<Vec<StoredImportRelation>> {
4053        let mut unique_terms = terms.to_vec();
4054        unique_terms.sort();
4055        unique_terms.dedup();
4056        let mut relations = Vec::new();
4057        for term in unique_terms.iter().filter(|term| !term.trim().is_empty()) {
4058            let mut statement = self.connection.prepare_cached(
4059                "
4060                SELECT path, source_name, target_name, line
4061                FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4062                WHERE kind = 'imports' AND target_name LIKE ?1 ESCAPE '\\'
4063                ORDER BY path, line, source_name, target_name
4064                LIMIT ?2
4065                ",
4066            )?;
4067            let rows = statement.query_map(
4068                params![
4069                    sqlite_like_pattern(term),
4070                    usize_to_i64(limit_per_term.max(1))
4071                ],
4072                stored_import_relation_from_row,
4073            )?;
4074            for row in rows {
4075                relations.push(row?);
4076            }
4077        }
4078        relations.sort_by(|left, right| {
4079            left.path
4080                .cmp(&right.path)
4081                .then_with(|| left.line.cmp(&right.line))
4082                .then_with(|| left.source_name.cmp(&right.source_name))
4083                .then_with(|| left.target_name.cmp(&right.target_name))
4084        });
4085        relations.dedup_by(|left, right| {
4086            left.path == right.path
4087                && left.source_name == right.source_name
4088                && left.target_name == right.target_name
4089                && left.line == right.line
4090        });
4091        Ok(relations)
4092    }
4093
4094    /// Load bounded import facts for one exact caller path.
4095    ///
4096    /// # Errors
4097    ///
4098    /// Returns an error if the covering indexed read fails.
4099    pub fn load_import_relations_for_path(
4100        &self,
4101        path: &str,
4102        limit: usize,
4103    ) -> DbResult<Vec<StoredImportRelation>> {
4104        let mut statement = self.connection.prepare_cached(
4105            "
4106            SELECT path, source_name, target_name, line
4107            FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
4108            WHERE kind = 'imports' AND path = ?1
4109            ORDER BY path, line, source_name, target_name
4110            LIMIT ?2
4111            ",
4112        )?;
4113        let rows = statement.query_map(
4114            params![path, usize_to_i64(limit.max(1))],
4115            stored_import_relation_from_row,
4116        )?;
4117        let mut relations = Vec::new();
4118        for row in rows {
4119            relations.push(row?);
4120        }
4121        Ok(relations)
4122    }
4123
4124    /// Count persisted symbols.
4125    ///
4126    /// # Errors
4127    ///
4128    /// Returns an error if reading fails.
4129    pub fn symbol_count(&self) -> DbResult<usize> {
4130        let count = self
4131            .connection
4132            .query_row("SELECT COUNT(*) FROM symbols", [], |row| {
4133                row.get::<_, i64>(0)
4134            })?;
4135        Ok(i64_to_usize(count))
4136    }
4137
4138    /// Count persisted symbol relations.
4139    ///
4140    /// # Errors
4141    ///
4142    /// Returns an error if reading fails.
4143    pub fn symbol_relation_count(&self) -> DbResult<usize> {
4144        let count =
4145            self.connection
4146                .query_row("SELECT COUNT(*) FROM symbol_relations", [], |row| {
4147                    row.get::<_, i64>(0)
4148                })?;
4149        count_to_usize("symbol_relations", count)
4150    }
4151
4152    /// Count persisted symbols for one file path.
4153    ///
4154    /// # Errors
4155    ///
4156    /// Returns an error if reading fails.
4157    pub fn symbol_count_for_path(&self, path: &str) -> DbResult<usize> {
4158        let count = self.connection.query_row(
4159            "SELECT COUNT(*) FROM symbols WHERE path = ?1",
4160            [path],
4161            |row| row.get::<_, i64>(0),
4162        )?;
4163        Ok(i64_to_usize(count))
4164    }
4165
4166    /// Count persisted symbols for a batch of file paths.
4167    ///
4168    /// Paths without symbols are omitted from the returned map.
4169    ///
4170    /// # Errors
4171    ///
4172    /// Returns an error if reading fails.
4173    pub fn symbol_counts_for_paths(&self, paths: &[String]) -> DbResult<HashMap<String, usize>> {
4174        let mut counts = HashMap::new();
4175        for chunk in paths.chunks(900) {
4176            if chunk.is_empty() {
4177                continue;
4178            }
4179            let placeholders = vec!["?"; chunk.len()].join(",");
4180            let sql = format!(
4181                "SELECT path, COUNT(*) FROM symbols WHERE path IN ({placeholders}) GROUP BY path"
4182            );
4183            let mut statement = self.connection.prepare(&sql)?;
4184            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4185                let path = row.get::<_, String>(0)?;
4186                let count = row.get::<_, i64>(1)?;
4187                Ok((path, i64_to_usize(count)))
4188            })?;
4189            for row in rows {
4190                let (path, count) = row?;
4191                counts.insert(path, count);
4192            }
4193        }
4194        Ok(counts)
4195    }
4196
4197    /// Return exact paths with persisted parser metadata from one bounded path set.
4198    ///
4199    /// # Errors
4200    ///
4201    /// Returns an error if reading or row decoding fails.
4202    pub fn source_parse_metadata_paths_for_paths(
4203        &self,
4204        paths: &[String],
4205    ) -> DbResult<HashSet<String>> {
4206        let mut indexed = HashSet::new();
4207        for chunk in paths.chunks(900) {
4208            if chunk.is_empty() {
4209                continue;
4210            }
4211            let placeholders = numbered_placeholders(1, chunk.len());
4212            let sql = format!(
4213                "SELECT path FROM source_parse_metadata
4214                 WHERE path IN ({placeholders})
4215                 ORDER BY path"
4216            );
4217            let mut statement = self.connection.prepare(&sql)?;
4218            let rows = statement.query_map(params_from_iter(chunk.iter()), |row| {
4219                row.get::<_, String>(0)
4220            })?;
4221            for row in rows {
4222                indexed.insert(row?);
4223            }
4224        }
4225        Ok(indexed)
4226    }
4227
4228    /// Return distinct parser strategies that produced symbols for one path.
4229    ///
4230    /// # Errors
4231    ///
4232    /// Returns an error if reading fails.
4233    pub fn symbol_parser_kinds_for_path(&self, path: &str) -> DbResult<Vec<ParserKind>> {
4234        let mut statement = self.connection.prepare(
4235            "
4236            SELECT DISTINCT parser
4237            FROM symbols
4238            WHERE path = ?1
4239            ORDER BY parser
4240            ",
4241        )?;
4242        let rows = statement.query_map([path], |row| {
4243            Ok(ParserKind::from_db(&row.get::<_, String>(0)?))
4244        })?;
4245        let mut parsers = Vec::new();
4246        for row in rows {
4247            parsers.push(row?);
4248        }
4249        Ok(parsers)
4250    }
4251
4252    /// Reconstruct persisted symbol graphs for exact repository paths in bounded batches.
4253    ///
4254    /// Paths without parser metadata are omitted. Any selected symbol or relation
4255    /// without matching metadata, or any metadata count mismatch, fails the whole
4256    /// operation instead of returning a partial graph set.
4257    ///
4258    /// # Errors
4259    ///
4260    /// Returns an error for `SQLite` failures, invalid persisted counts or enums,
4261    /// and inconsistent symbol-graph rows.
4262    pub fn load_symbol_graphs_for_paths(&self, paths: &[String]) -> DbResult<Vec<SymbolGraph>> {
4263        const PATHS_PER_QUERY: usize = 900;
4264
4265        let mut paths = paths.to_vec();
4266        paths.sort();
4267        paths.dedup();
4268        let mut graphs = Vec::with_capacity(paths.len());
4269        for chunk in paths.chunks(PATHS_PER_QUERY) {
4270            let placeholders = numbered_placeholders(1, chunk.len());
4271            let metadata_sql = format!(
4272                "SELECT path, language, source_parser, fact_parser, symbol_count, relation_count
4273                   FROM source_parse_metadata
4274                  WHERE path IN ({placeholders})
4275                  ORDER BY path"
4276            );
4277            let mut metadata_statement = self.connection.prepare(&metadata_sql)?;
4278            let metadata_rows =
4279                metadata_statement.query_map(params_from_iter(chunk.iter()), |row| {
4280                    Ok((
4281                        row.get::<_, String>(0)?,
4282                        row.get::<_, Option<String>>(1)?,
4283                        row.get::<_, String>(2)?,
4284                        row.get::<_, String>(3)?,
4285                        row.get::<_, i64>(4)?,
4286                        row.get::<_, i64>(5)?,
4287                    ))
4288                })?;
4289            let mut staged = BTreeMap::new();
4290            for row in metadata_rows {
4291                let (path, language, source_parser, fact_parser, symbol_count, relation_count) =
4292                    row?;
4293                staged.insert(
4294                    path.clone(),
4295                    (
4296                        SourceParseMetadata {
4297                            path,
4298                            language,
4299                            parser: ParserKind::from_db(&source_parser),
4300                            symbol_count: count_to_usize(
4301                                "source_parse_metadata.symbol_count",
4302                                symbol_count,
4303                            )?,
4304                            relation_count: count_to_usize(
4305                                "source_parse_metadata.relation_count",
4306                                relation_count,
4307                            )?,
4308                        },
4309                        ParserKind::from_db(&fact_parser),
4310                        Vec::new(),
4311                        Vec::new(),
4312                    ),
4313                );
4314            }
4315
4316            let symbol_sql = format!(
4317                "SELECT path, language, name, kind, signature, line_start, line_end,
4318                        parent, parser, detail, exported, documentation,
4319                        source_byte_start, source_byte_end,
4320                        source_column_start, source_column_end
4321                   FROM symbols
4322                  WHERE path IN ({placeholders})
4323                  ORDER BY path, line_start, line_end, name, kind"
4324            );
4325            for symbol in self.query_symbols(&symbol_sql, params_from_iter(chunk.iter()))? {
4326                let path = symbol.path.clone();
4327                let Some((_, _, symbols, _)) = staged.get_mut(&path) else {
4328                    return Err(DbError::SymbolGraphRowShape {
4329                        path,
4330                        reason: "symbol rows require matching parser metadata",
4331                    });
4332                };
4333                symbols.push(symbol);
4334            }
4335
4336            let relation_sql = format!(
4337                "SELECT path, source_name, target_name, kind, line, context, parser
4338                   FROM symbol_relations
4339                  WHERE path IN ({placeholders})
4340                  ORDER BY path, line, source_name, target_name, kind"
4341            );
4342            for relation in self.query_relations(&relation_sql, params_from_iter(chunk.iter()))? {
4343                let path = relation.path.clone();
4344                let Some((_, _, _, relations)) = staged.get_mut(&path) else {
4345                    return Err(DbError::SymbolGraphRowShape {
4346                        path,
4347                        reason: "relation rows require matching parser metadata",
4348                    });
4349                };
4350                relations.push(relation);
4351            }
4352
4353            for (path, (metadata, fact_parser, symbols, relations)) in staged {
4354                if metadata.symbol_count != symbols.len()
4355                    || metadata.relation_count != relations.len()
4356                {
4357                    return Err(DbError::SymbolGraphRowShape {
4358                        path,
4359                        reason: "parser metadata counts do not match persisted rows",
4360                    });
4361                }
4362                graphs.push(SymbolGraph {
4363                    path: metadata.path,
4364                    language: metadata.language,
4365                    parser: fact_parser,
4366                    symbols,
4367                    relations,
4368                });
4369            }
4370        }
4371        Ok(graphs)
4372    }
4373
4374    /// Load file-level parser metadata for one path.
4375    ///
4376    /// # Errors
4377    ///
4378    /// Returns an error if reading fails or stored counts are invalid.
4379    pub fn load_source_parse_metadata(&self, path: &str) -> DbResult<Option<SourceParseMetadata>> {
4380        self.connection
4381            .query_row(
4382                "
4383                SELECT path, language, source_parser, symbol_count, relation_count
4384                FROM source_parse_metadata
4385                WHERE path = ?1
4386                ",
4387                [path],
4388                |row| {
4389                    let symbol_count = row.get::<_, i64>(3)?;
4390                    let relation_count = row.get::<_, i64>(4)?;
4391                    Ok(SourceParseMetadata {
4392                        path: row.get(0)?,
4393                        language: row.get(1)?,
4394                        parser: ParserKind::from_db(&row.get::<_, String>(2)?),
4395                        symbol_count: i64_to_usize(symbol_count),
4396                        relation_count: i64_to_usize(relation_count),
4397                    })
4398                },
4399            )
4400            .optional()
4401            .map_err(Into::into)
4402    }
4403
4404    /// Load the maximum indexed symbol end line for one file path.
4405    ///
4406    /// # Errors
4407    ///
4408    /// Returns an error if reading fails.
4409    pub fn max_symbol_end_line_for_path(&self, path: &str) -> DbResult<usize> {
4410        let line = self.connection.query_row(
4411            "SELECT COALESCE(MAX(line_end), 0) FROM symbols WHERE path = ?1",
4412            [path],
4413            |row| row.get::<_, i64>(0),
4414        )?;
4415        Ok(i64_to_usize(line))
4416    }
4417
4418    /// Query symbols with a caller-provided statement and parameters.
4419    fn query_symbols<P>(&self, sql: &str, params: P) -> DbResult<Vec<CodeSymbol>>
4420    where
4421        P: rusqlite::Params,
4422    {
4423        let mut statement = self.connection.prepare(sql)?;
4424        let rows = statement.query_map(params, code_symbol_from_row)?;
4425        let mut symbols = Vec::new();
4426        for row in rows {
4427            symbols.push(row?);
4428        }
4429        Ok(symbols)
4430    }
4431
4432    /// Query relations with a caller-provided statement and parameters.
4433    fn query_relations<P>(&self, sql: &str, params: P) -> DbResult<Vec<SymbolRelation>>
4434    where
4435        P: rusqlite::Params,
4436    {
4437        let mut statement = self.connection.prepare(sql)?;
4438        let rows = statement.query_map(params, |row| {
4439            let kind_value: String = row.get(3)?;
4440            let relation_kind = RelationKind::from_db(&kind_value).ok_or_else(|| {
4441                rusqlite::Error::FromSqlConversionFailure(
4442                    3,
4443                    rusqlite::types::Type::Text,
4444                    Box::new(std::io::Error::other(format!(
4445                        "invalid relation kind {kind_value}"
4446                    ))),
4447                )
4448            })?;
4449            Ok(SymbolRelation {
4450                path: row.get(0)?,
4451                source_name: row.get(1)?,
4452                target_name: row.get(2)?,
4453                kind: relation_kind,
4454                line: i64_to_usize(row.get::<_, i64>(4)?),
4455                context: row.get(5)?,
4456                parser: ParserKind::from_db(&row.get::<_, String>(6)?),
4457            })
4458        })?;
4459        let mut relations = Vec::new();
4460        for row in rows {
4461            relations.push(row?);
4462        }
4463        Ok(relations)
4464    }
4465
4466    /// Return whether any accepted agent-authored purpose can affect navigation.
4467    ///
4468    /// # Errors
4469    ///
4470    /// Returns an error when the bounded indexed lookup fails.
4471    pub fn has_agent_approved_purpose(&self) -> DbResult<bool> {
4472        self.connection
4473            .query_row(
4474                "SELECT EXISTS(
4475                    SELECT 1
4476                    FROM purposes INDEXED BY idx_purposes_status
4477                    WHERE status = 'approved' AND source = 'agent'
4478                    LIMIT 1
4479                )",
4480                [],
4481                |row| row.get(0),
4482            )
4483            .map_err(Into::into)
4484    }
4485
4486    /// Persist a purpose for a path.
4487    ///
4488    /// # Errors
4489    ///
4490    /// Returns an error if the path does not exist or persistence fails.
4491    pub fn set_purpose(&self, path: &str, purpose: &str, source: PurposeSource) -> DbResult<()> {
4492        self.with_validated_write(|connection| {
4493            let node_id = self.node_id_for_path(path)?;
4494            let changed = connection
4495                .prepare_cached(
4496                    "
4497            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4498            VALUES(?1, ?2, ?3, 'approved', CURRENT_TIMESTAMP)
4499            ON CONFLICT(node_id) DO UPDATE SET
4500                purpose = excluded.purpose,
4501                source = excluded.source,
4502                status = 'approved',
4503                updated_at = CURRENT_TIMESTAMP
4504            WHERE purposes.purpose <> excluded.purpose
4505               OR purposes.source <> excluded.source
4506               OR purposes.status <> 'approved'
4507            ",
4508                )?
4509                .execute(params![node_id, purpose, source.to_string()])?;
4510            if changed > 0 {
4511                advance_authored_purpose_revision(connection)?;
4512            }
4513            Ok(())
4514        })
4515    }
4516
4517    /// Return the accepted authored-purpose revision captured by this connection.
4518    ///
4519    /// Databases created before the revision contract have revision zero until
4520    /// their first accepted purpose mutation.
4521    ///
4522    /// # Errors
4523    ///
4524    /// Returns an error when the metadata value is corrupt.
4525    pub fn authored_purpose_revision(&self) -> DbResult<u64> {
4526        load_authored_purpose_revision(&self.connection)
4527    }
4528
4529    /// Approve one purpose only while its curator work and unapproved row are current.
4530    ///
4531    /// This path never changes an accepted purpose. Call [`Self::set_purpose`]
4532    /// for a deliberate correction after an agent or user identifies one.
4533    ///
4534    /// # Errors
4535    ///
4536    /// Returns an error for invalid task input, binding/schema failure, or a
4537    /// failed atomic `SQLite` transaction. Stale and accepted rows are returned
4538    /// as typed non-error states.
4539    pub fn conditionally_set_purpose(
4540        &self,
4541        task: &str,
4542        path: &str,
4543        work_key: &str,
4544        state_token: &str,
4545        purpose: &str,
4546    ) -> DbResult<PurposeConditionalApplyState> {
4547        let request = PurposeConditionalApplyRequest {
4548            task: task.to_string(),
4549            path: path.to_string(),
4550            work_key: work_key.to_string(),
4551            state_token: state_token.to_string(),
4552            purpose: purpose.to_string(),
4553        };
4554        let mut results = self.conditionally_set_purposes(&[request])?;
4555        Ok(results
4556            .pop()
4557            .map_or(PurposeConditionalApplyState::PathUnavailable, |result| {
4558                result.state
4559            }))
4560    }
4561
4562    /// Apply a bounded stale-safe purpose-review batch in one writer transaction.
4563    ///
4564    /// Current-row lookup and conditional-update statements are cached and
4565    /// reused for every item. A stale item leaves independent matching rows
4566    /// eligible within the same host-owned batch.
4567    ///
4568    /// # Errors
4569    ///
4570    /// Returns an error for invalid task input, oversized input, binding/schema
4571    /// failure, or any failed statement/commit. A database error rolls back the
4572    /// complete batch.
4573    pub fn conditionally_set_purposes(
4574        &self,
4575        requests: &[PurposeConditionalApplyRequest],
4576    ) -> DbResult<Vec<PurposeConditionalApplyResult>> {
4577        if requests.is_empty() {
4578            return Ok(Vec::new());
4579        }
4580        if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4581            return Err(DbError::PurposeCurationBatchTooLarge {
4582                requested: requests.len(),
4583                maximum: MAX_PURPOSE_CURATION_BATCH_ROWS,
4584            });
4585        }
4586        let normalized = requests
4587            .iter()
4588            .map(|request| {
4589                normalize_purpose_curation_task(&request.task).map(|task| {
4590                    PurposeConditionalApplyRequest {
4591                        task,
4592                        path: request.path.clone(),
4593                        work_key: request.work_key.clone(),
4594                        state_token: request.state_token.clone(),
4595                        purpose: request.purpose.clone(),
4596                    }
4597                })
4598            })
4599            .collect::<DbResult<Vec<_>>>()?;
4600        self.with_validated_write(|connection| {
4601            let (project_instance_id, active_generation) =
4602                self.purpose_curation_context(connection)?;
4603            let results = normalized
4604                .iter()
4605                .map(|request| {
4606                    apply_conditional_purpose(
4607                        connection,
4608                        project_instance_id,
4609                        active_generation,
4610                        request,
4611                    )
4612                })
4613                .collect::<DbResult<Vec<_>>>()?;
4614            if results
4615                .iter()
4616                .any(|result| matches!(result.state, PurposeConditionalApplyState::Applied))
4617            {
4618                advance_authored_purpose_revision(connection)?;
4619            }
4620            Ok(results)
4621        })
4622    }
4623
4624    /// Persist a non-approved purpose suggestion for a path.
4625    ///
4626    /// # Errors
4627    ///
4628    /// Returns an error if the path does not exist or persistence fails.
4629    pub fn set_suggested_purpose(&self, path: &str, purpose: &str) -> DbResult<()> {
4630        self.with_validated_write(|connection| {
4631            let node_id = self.node_id_for_path(path)?;
4632            connection
4633                .prepare_cached(
4634                    "
4635            INSERT INTO purposes(node_id, purpose, source, status, updated_at)
4636            VALUES(?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
4637            ON CONFLICT(node_id) DO UPDATE SET
4638                purpose = excluded.purpose,
4639                source = excluded.source,
4640                status = excluded.status,
4641                updated_at = CURRENT_TIMESTAMP
4642            WHERE purposes.status IN (?5, ?6)
4643            ",
4644                )?
4645                .execute(params![
4646                    node_id,
4647                    purpose,
4648                    PurposeSource::Generated.to_string(),
4649                    PurposeStatus::Suggested.as_str(),
4650                    PurposeStatus::Missing.as_str(),
4651                    PurposeStatus::Suggested.as_str(),
4652                ])?;
4653            Ok(())
4654        })
4655    }
4656
4657    /// Load a node id for a repository path.
4658    fn node_id_for_path(&self, path: &str) -> DbResult<i64> {
4659        self.connection
4660            .prepare_cached("SELECT id FROM nodes WHERE path = ?1 AND exists_now = 1")?
4661            .query_row([path], |row| row.get::<_, i64>(0))
4662            .optional()?
4663            .ok_or_else(|| DbError::PathNotIndexed {
4664                path: path.to_string(),
4665            })
4666    }
4667
4668    /// Load existing nodes with purpose state.
4669    ///
4670    /// # Errors
4671    ///
4672    /// Returns an error if reading or enum conversion fails.
4673    pub fn load_nodes(&self) -> DbResult<Vec<IndexedNode>> {
4674        let mut statement = self.connection.prepare(
4675            "
4676            SELECT
4677                n.path,
4678                n.kind,
4679                n.parent_path,
4680                n.extension,
4681                n.language,
4682                n.size_bytes,
4683                n.mtime_ns,
4684                n.content_hash,
4685                p.purpose,
4686                p.source,
4687                p.status,
4688                s.summary
4689            FROM nodes n
4690            JOIN purposes p ON p.node_id = n.id
4691            LEFT JOIN summaries s ON s.node_id = n.id
4692                AND s.summary_level = 'node'
4693                AND s.subject = ''
4694            WHERE n.exists_now = 1
4695            ORDER BY n.path
4696            ",
4697        )?;
4698        let rows = statement.query_map([], |row| {
4699            let kind_value: String = row.get(1)?;
4700            let source_value: String = row.get(9)?;
4701            let status_value: String = row.get(10)?;
4702            Ok((
4703                row.get::<_, String>(0)?,
4704                kind_value,
4705                row.get::<_, Option<String>>(2)?,
4706                row.get::<_, Option<String>>(3)?,
4707                row.get::<_, Option<String>>(4)?,
4708                option_u64_from_sql(row, 5)?,
4709                row.get::<_, Option<i64>>(6)?,
4710                row.get::<_, Option<String>>(7)?,
4711                row.get::<_, Option<String>>(8)?,
4712                source_value,
4713                status_value,
4714                row.get::<_, Option<String>>(11)?,
4715            ))
4716        })?;
4717        let mut nodes = Vec::new();
4718        for row in rows {
4719            let (
4720                path,
4721                kind_value,
4722                parent_path,
4723                extension,
4724                language,
4725                size_bytes,
4726                mtime_ns,
4727                content_hash,
4728                purpose,
4729                source_value,
4730                status_value,
4731                summary,
4732            ) = row?;
4733            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
4734                field: "kind",
4735                value: kind_value,
4736            })?;
4737            let source = parse_source(&source_value)?;
4738            let status =
4739                PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
4740                    field: "status",
4741                    value: status_value,
4742                })?;
4743            nodes.push(IndexedNode {
4744                node: Node {
4745                    path: path.clone(),
4746                    kind,
4747                    parent_path,
4748                    extension,
4749                    language,
4750                    size_bytes,
4751                    mtime_ns,
4752                    content_hash,
4753                },
4754                purpose: Purpose {
4755                    path,
4756                    purpose,
4757                    source,
4758                    status,
4759                },
4760                summary,
4761            });
4762        }
4763        Ok(nodes)
4764    }
4765
4766    /// Load a bounded ranked node list directly from `SQLite`.
4767    ///
4768    /// This is the hot path for agent orientation commands. It keeps large
4769    /// repositories from materializing every indexed path just to answer a
4770    /// top-N folder or file query.
4771    ///
4772    /// # Errors
4773    ///
4774    /// Returns an error if reading or enum conversion fails.
4775    pub fn load_ranked_nodes(
4776        &self,
4777        query: &str,
4778        kind: NodeKind,
4779        folder: Option<&str>,
4780        limit: usize,
4781        offset: usize,
4782    ) -> DbResult<Vec<IndexedNode>> {
4783        let terms = normalize_query_terms(query);
4784        let exact_query = normalize_exact_ranked_query(query);
4785        let exact_name_enabled = !exact_query.is_empty() && !exact_query.contains('/');
4786        let exact_name_pattern = format!("%/{}", sqlite_like_escape(&exact_query));
4787        let reviewed_match_expression = reviewed_purpose_match_expression(terms.len());
4788        let score_expression = ranked_score_expression(terms.len());
4789        let mut sql = format!(
4790            "
4791            SELECT path, kind, parent_path, extension, language, size_bytes, mtime_ns,
4792                   content_hash, purpose, source, status, summary
4793            FROM (
4794                SELECT
4795                    n.path,
4796                    n.kind,
4797                    n.parent_path,
4798                    n.extension,
4799                    n.language,
4800                    n.size_bytes,
4801                    n.mtime_ns,
4802                    n.content_hash,
4803                    p.purpose,
4804                    p.source,
4805                    p.status,
4806                    s.summary,
4807                    CASE WHEN lower(n.path) = ? THEN 1 ELSE 0 END AS exact_path,
4808                    CASE WHEN ? = 1
4809                              AND (lower(n.path) = ? OR lower(n.path) LIKE ? ESCAPE '\\')
4810                         THEN 1 ELSE 0 END AS exact_name,
4811                    {reviewed_match_expression} AS reviewed_purpose,
4812                    {score_expression} AS score
4813                FROM nodes n
4814                JOIN purposes p ON p.node_id = n.id
4815                LEFT JOIN summaries s ON s.node_id = n.id
4816                    AND s.summary_level = 'node'
4817                    AND s.subject = ''
4818                LEFT JOIN summaries symbol_summaries ON symbol_summaries.node_id = n.id
4819                    AND symbol_summaries.summary_level = 'search'
4820                    AND symbol_summaries.subject = 'symbols'
4821                WHERE n.exists_now = 1
4822                  AND n.kind = ?
4823            "
4824        );
4825        let mut values = vec![
4826            Value::from(exact_query.clone()),
4827            Value::from(i64::from(exact_name_enabled)),
4828            Value::from(exact_query),
4829            Value::from(exact_name_pattern),
4830        ];
4831        for term in &terms {
4832            values.push(Value::from(sqlite_like_pattern(term)));
4833        }
4834        for term in &terms {
4835            let pattern = sqlite_like_pattern(term);
4836            values.push(Value::from(pattern.clone()));
4837            values.push(Value::from(pattern.clone()));
4838            values.push(Value::from(pattern.clone()));
4839            values.push(Value::from(pattern.clone()));
4840            values.push(Value::from(pattern));
4841        }
4842        values.push(Value::from(kind.to_string()));
4843        if kind == NodeKind::File
4844            && let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".")
4845        {
4846            sql.push_str(" AND (n.parent_path = ? OR n.parent_path LIKE ? ESCAPE '\\')");
4847            values.push(Value::from(folder.to_string()));
4848            values.push(Value::from(sqlite_descendant_pattern(folder)));
4849        }
4850        sql.push_str(
4851            "
4852            )
4853            WHERE score > 0 OR exact_path > 0 OR exact_name > 0 OR reviewed_purpose > 0
4854            ORDER BY exact_path DESC, exact_name DESC, reviewed_purpose DESC, score DESC, path
4855            LIMIT ?
4856            OFFSET ?
4857            ",
4858        );
4859        values.push(Value::from(usize_to_i64(limit.max(1))));
4860        values.push(Value::from(usize_to_i64(offset)));
4861
4862        let mut statement = self.connection.prepare(&sql)?;
4863        let mut rows = statement.query(params_from_iter(values))?;
4864        let mut nodes = Vec::new();
4865        while let Some(row) = rows.next()? {
4866            nodes.push(indexed_node_from_sql_row(row)?);
4867        }
4868        Ok(nodes)
4869    }
4870
4871    /// Sum indexed source bytes represented by file nodes.
4872    ///
4873    /// # Errors
4874    ///
4875    /// Returns an error if reading fails or the aggregate cannot fit in `usize`.
4876    pub fn source_file_byte_count(&self, folder: Option<&str>) -> DbResult<usize> {
4877        let mut sql = String::from(
4878            "
4879            SELECT COALESCE(SUM(COALESCE(size_bytes, 0)), 0)
4880            FROM nodes
4881            WHERE exists_now = 1
4882              AND kind = 'file'
4883            ",
4884        );
4885        let mut values = Vec::new();
4886        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4887            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4888            values.push(Value::from(folder.to_string()));
4889            values.push(Value::from(sqlite_descendant_pattern(folder)));
4890        }
4891        let count = self
4892            .connection
4893            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
4894        count_to_usize("source_file_bytes", count)
4895    }
4896
4897    /// Visit indexed file paths and source sizes for exact token baselines.
4898    ///
4899    /// # Errors
4900    ///
4901    /// Returns an error if reading fails, stored counts are invalid, or the
4902    /// visitor returns an error.
4903    pub fn visit_file_token_estimates<F>(
4904        &self,
4905        folder: Option<&str>,
4906        mut visitor: F,
4907    ) -> DbResult<()>
4908    where
4909        F: FnMut(String, Option<u64>) -> DbResult<bool>,
4910    {
4911        let mut sql = String::from(
4912            "
4913            SELECT path, size_bytes
4914            FROM nodes
4915            WHERE exists_now = 1
4916              AND kind = 'file'
4917            ",
4918        );
4919        let mut values = Vec::new();
4920        if let Some(folder) = folder.filter(|folder| !folder.is_empty() && *folder != ".") {
4921            sql.push_str(" AND (parent_path = ? OR parent_path LIKE ? ESCAPE '\\')");
4922            values.push(Value::from(folder.to_string()));
4923            values.push(Value::from(sqlite_descendant_pattern(folder)));
4924        }
4925        sql.push_str(" ORDER BY path");
4926        let mut statement = self.connection.prepare(&sql)?;
4927        let mut rows = statement.query(params_from_iter(values))?;
4928        while let Some(row) = rows.next()? {
4929            if !visitor(row.get::<_, String>(0)?, option_u64_from_sql(row, 1)?)? {
4930                return Ok(());
4931            }
4932        }
4933        Ok(())
4934    }
4935
4936    /// Build unresolved health findings without loading the full node table.
4937    ///
4938    /// # Errors
4939    ///
4940    /// Returns an error if reading fails or stored enum values are invalid.
4941    pub fn unresolved_health_findings(
4942        &self,
4943        resolved_ids: &[String],
4944    ) -> DbResult<Vec<HealthFinding>> {
4945        let mut findings = Vec::new();
4946        self.visit_unresolved_health_findings(resolved_ids, |finding| {
4947            findings.push(finding);
4948            Ok(true)
4949        })?;
4950        Ok(findings)
4951    }
4952
4953    /// Build a bounded unresolved health findings page.
4954    ///
4955    /// # Errors
4956    ///
4957    /// Returns an error if reading fails or stored enum values are invalid.
4958    pub fn unresolved_health_findings_page(
4959        &self,
4960        resolved_ids: &[String],
4961        query: &HealthQuery,
4962    ) -> DbResult<HealthFindingsPage> {
4963        self.unresolved_health_findings_page_with_filter(
4964            HealthResolutionFilter::Explicit(resolved_ids),
4965            query,
4966        )
4967    }
4968
4969    /// Build a bounded page filtered by this store's durable resolutions.
4970    ///
4971    /// # Errors
4972    ///
4973    /// Returns an error if reading fails or stored enum values are invalid.
4974    pub fn unresolved_health_findings_page_current(
4975        &self,
4976        query: &HealthQuery,
4977    ) -> DbResult<HealthFindingsPage> {
4978        self.unresolved_health_findings_page_with_filter(HealthResolutionFilter::Stored, query)
4979    }
4980
4981    /// Build the actionable missing/suggested purpose queue for one low-scope request.
4982    ///
4983    /// Accepted and legacy-stale purposes are excluded; deliberate accepted-purpose
4984    /// correction remains owned by the explicit purpose-set/review path.
4985    ///
4986    /// # Errors
4987    ///
4988    /// Returns an error if counting, paging, or stored-state conversion fails.
4989    pub fn purpose_curation_findings_page_current(
4990        &self,
4991        query: &HealthQuery,
4992    ) -> DbResult<HealthFindingsPage> {
4993        let specs = &PURPOSE_HEALTH_SPECS[..2];
4994        let unfiltered_total = specs.iter().try_fold(0_usize, |total, spec| {
4995            self.count_purpose_status_findings(
4996                *spec,
4997                None,
4998                HealthResolutionFilter::Stored,
4999                HealthScope::all(),
5000            )
5001            .map(|count| total + count)
5002        })?;
5003        if query
5004            .severity
5005            .is_some_and(|severity| severity != Severity::Warning)
5006        {
5007            return Ok(HealthFindingsPage {
5008                total: 0,
5009                unfiltered_total,
5010                returned: 0,
5011                start_index: query.start_index,
5012                limit: query.limit,
5013                findings: Vec::new(),
5014            });
5015        }
5016
5017        let matching_specs = query.category.as_deref().map_or(specs, |category| {
5018            specs
5019                .iter()
5020                .find(|spec| spec.category == category)
5021                .map_or(&[][..], std::slice::from_ref)
5022        });
5023        let total = self.count_purpose_lifecycle_findings(
5024            matching_specs,
5025            query.path_prefix.as_deref(),
5026            HealthResolutionFilter::Stored,
5027            query.scope,
5028        )?;
5029        let findings = if query.summary_only {
5030            Vec::new()
5031        } else {
5032            self.load_purpose_lifecycle_findings_page(
5033                matching_specs,
5034                query.path_prefix.as_deref(),
5035                HealthResolutionFilter::Stored,
5036                query.scope,
5037                query.start_index,
5038                query.limit,
5039            )?
5040        };
5041        Ok(HealthFindingsPage {
5042            total,
5043            unfiltered_total,
5044            returned: findings.len(),
5045            start_index: query.start_index,
5046            limit: query.limit,
5047            findings,
5048        })
5049    }
5050
5051    /// Count all unresolved findings without materializing finding rows or resolution ids.
5052    ///
5053    /// # Errors
5054    ///
5055    /// Returns an error if reading fails or stored enum values are invalid.
5056    pub fn unresolved_health_finding_count_current(&self) -> DbResult<usize> {
5057        self.unresolved_health_findings_page_current(&HealthQuery {
5058            start_index: 0,
5059            limit: 0,
5060            category: None,
5061            severity: None,
5062            path_prefix: None,
5063            summary_only: true,
5064            scope: HealthScope::all(),
5065        })
5066        .map(|page| page.total)
5067    }
5068
5069    /// Build a bounded unresolved page with caller-owned or store-owned filtering.
5070    fn unresolved_health_findings_page_with_filter(
5071        &self,
5072        resolution_filter: HealthResolutionFilter<'_>,
5073        query: &HealthQuery,
5074    ) -> DbResult<HealthFindingsPage> {
5075        let mut unfiltered_total = 0_usize;
5076        let mut total = 0_usize;
5077        let mut findings = Vec::new();
5078
5079        for spec in PURPOSE_HEALTH_SPECS {
5080            unfiltered_total += self.count_purpose_status_findings(
5081                spec,
5082                None,
5083                resolution_filter,
5084                HealthScope::all(),
5085            )?;
5086        }
5087
5088        let scope = query.scope;
5089        if scope.high_impact_queue() && query.category.is_none() {
5090            let matching_count = if query
5091                .severity
5092                .is_none_or(|severity| severity == Severity::Warning)
5093            {
5094                self.count_purpose_lifecycle_findings(
5095                    &PURPOSE_HEALTH_SPECS,
5096                    query.path_prefix.as_deref(),
5097                    resolution_filter,
5098                    scope,
5099                )?
5100            } else {
5101                0
5102            };
5103            if !query.summary_only
5104                && findings.len() < query.limit
5105                && total + matching_count > query.start_index
5106            {
5107                let local_start = query.start_index.saturating_sub(total);
5108                let local_limit = query.limit - findings.len();
5109                findings.extend(self.load_purpose_lifecycle_findings_page(
5110                    &PURPOSE_HEALTH_SPECS,
5111                    query.path_prefix.as_deref(),
5112                    resolution_filter,
5113                    scope,
5114                    local_start,
5115                    local_limit,
5116                )?);
5117            }
5118            total += matching_count;
5119        } else {
5120            for spec in PURPOSE_HEALTH_SPECS {
5121                if !purpose_health_spec_matches_query(spec, query) {
5122                    continue;
5123                }
5124
5125                let matching_count = self.count_purpose_status_findings(
5126                    spec,
5127                    query.path_prefix.as_deref(),
5128                    resolution_filter,
5129                    scope,
5130                )?;
5131                if !query.summary_only
5132                    && findings.len() < query.limit
5133                    && total + matching_count > query.start_index
5134                {
5135                    let local_start = query.start_index.saturating_sub(total);
5136                    let local_limit = query.limit - findings.len();
5137                    findings.extend(self.load_purpose_status_findings_page(
5138                        spec,
5139                        query.path_prefix.as_deref(),
5140                        resolution_filter,
5141                        scope,
5142                        local_start,
5143                        local_limit,
5144                    )?);
5145                }
5146                total += matching_count;
5147            }
5148        }
5149
5150        for category in STRUCTURAL_HEALTH_CATEGORIES {
5151            let unfiltered_scope = if category == CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED {
5152                HealthScope::purpose_strict()
5153            } else {
5154                HealthScope::all()
5155            };
5156            let unfiltered_count = self.count_structural_health_findings(
5157                category,
5158                None,
5159                resolution_filter,
5160                unfiltered_scope,
5161            )?;
5162            unfiltered_total += unfiltered_count;
5163            if !health_category_matches_query(category, Severity::Warning, query) {
5164                continue;
5165            }
5166            let matching_count = self.count_structural_health_findings(
5167                category,
5168                query.path_prefix.as_deref(),
5169                resolution_filter,
5170                scope,
5171            )?;
5172            if !query.summary_only
5173                && findings.len() < query.limit
5174                && total + matching_count > query.start_index
5175            {
5176                let local_start = query.start_index.saturating_sub(total);
5177                let local_limit = query.limit - findings.len();
5178                findings.extend(self.load_structural_health_findings_page(
5179                    category,
5180                    query.path_prefix.as_deref(),
5181                    resolution_filter,
5182                    scope,
5183                    local_start,
5184                    local_limit,
5185                )?);
5186            }
5187            total += matching_count;
5188        }
5189        Ok(HealthFindingsPage {
5190            total,
5191            unfiltered_total,
5192            returned: findings.len(),
5193            start_index: query.start_index,
5194            limit: query.limit,
5195            findings,
5196        })
5197    }
5198
5199    /// Visit unresolved health findings without materializing the full table.
5200    fn visit_unresolved_health_findings<F>(
5201        &self,
5202        resolved_ids: &[String],
5203        mut visitor: F,
5204    ) -> DbResult<()>
5205    where
5206        F: FnMut(HealthFinding) -> DbResult<bool>,
5207    {
5208        let resolved = resolved_ids.iter().cloned().collect::<HashSet<_>>();
5209        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[0], &resolved, &mut visitor)? {
5210            return Ok(());
5211        }
5212        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[1], &resolved, &mut visitor)? {
5213            return Ok(());
5214        }
5215        if !self.visit_purpose_status_findings(PURPOSE_HEALTH_SPECS[2], &resolved, &mut visitor)? {
5216            return Ok(());
5217        }
5218        if !self.visit_agent_review_required_findings(&resolved, &mut visitor)? {
5219            return Ok(());
5220        }
5221        self.visit_structural_health_findings(&resolved, &mut visitor)
5222    }
5223
5224    /// Visit structural health findings that are not simple purpose statuses.
5225    fn visit_structural_health_findings<F>(
5226        &self,
5227        resolved_ids: &HashSet<String>,
5228        mut visitor: F,
5229    ) -> DbResult<()>
5230    where
5231        F: FnMut(HealthFinding) -> DbResult<bool>,
5232    {
5233        if !self.visit_duplicate_purpose_findings(resolved_ids, &mut visitor)? {
5234            return Ok(());
5235        }
5236        if !self.visit_repeated_temp_folder_findings(resolved_ids, &mut visitor)? {
5237            return Ok(());
5238        }
5239        Ok(())
5240    }
5241
5242    /// Build findings for one purpose lifecycle status.
5243    fn visit_purpose_status_findings<F>(
5244        &self,
5245        spec: PurposeHealthSpec,
5246        resolved_ids: &HashSet<String>,
5247        visitor: &mut F,
5248    ) -> DbResult<bool>
5249    where
5250        F: FnMut(HealthFinding) -> DbResult<bool>,
5251    {
5252        let mut statement = self.connection.prepare(
5253            "
5254            SELECT n.path
5255            FROM nodes n
5256            JOIN purposes p ON p.node_id = n.id
5257            WHERE n.exists_now = 1
5258              AND p.status = ?1
5259            ORDER BY n.path
5260            ",
5261        )?;
5262        let rows = statement.query_map([spec.status], |row| row.get::<_, String>(0))?;
5263        for row in rows {
5264            let path = row?;
5265            let finding = HealthFinding {
5266                id: finding_id(spec.category, &path, None),
5267                severity: Severity::Warning,
5268                category: spec.category.to_string(),
5269                path,
5270                related_path: None,
5271                message: spec.message.to_string(),
5272                recommendation: spec.recommendation.to_string(),
5273            };
5274            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5275                return Ok(false);
5276            }
5277        }
5278        Ok(true)
5279    }
5280
5281    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
5282    fn count_purpose_lifecycle_findings(
5283        &self,
5284        specs: &[PurposeHealthSpec],
5285        path_prefix: Option<&str>,
5286        resolution_filter: HealthResolutionFilter<'_>,
5287        scope: HealthScope,
5288    ) -> DbResult<usize> {
5289        if specs.is_empty() {
5290            return Ok(0);
5291        }
5292        let (where_clause, values) =
5293            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5294        let sql = format!(
5295            "
5296            SELECT COUNT(*)
5297            FROM nodes n
5298            JOIN purposes p ON p.node_id = n.id
5299            WHERE {where_clause}
5300            "
5301        );
5302        let count = self
5303            .connection
5304            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5305        count_to_usize("health_purpose_lifecycle_count", count)
5306    }
5307
5308    /// Load one globally ordered purpose lifecycle page directly from `SQLite`.
5309    fn load_purpose_lifecycle_findings_page(
5310        &self,
5311        specs: &[PurposeHealthSpec],
5312        path_prefix: Option<&str>,
5313        resolution_filter: HealthResolutionFilter<'_>,
5314        scope: HealthScope,
5315        start_index: usize,
5316        limit: usize,
5317    ) -> DbResult<Vec<HealthFinding>> {
5318        if limit == 0 || specs.is_empty() {
5319            return Ok(Vec::new());
5320        }
5321        let (where_clause, mut values) =
5322            purpose_lifecycle_where_clause(specs, path_prefix, resolution_filter, scope);
5323        let limit_placeholder = values.len() + 1;
5324        let offset_placeholder = values.len() + 2;
5325        values.push(Value::from(usize_to_i64(limit)));
5326        values.push(Value::from(usize_to_i64(start_index)));
5327        let order_by = purpose_default_queue_order_expression("n", "p");
5328        let sql = format!(
5329            "
5330            SELECT n.path, p.status
5331            FROM nodes n
5332            JOIN purposes p ON p.node_id = n.id
5333            WHERE {where_clause}
5334            ORDER BY {order_by}
5335            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5336            "
5337        );
5338        let mut statement = self.connection.prepare(&sql)?;
5339        let rows = statement.query_map(params_from_iter(values), |row| {
5340            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5341        })?;
5342        let mut findings = Vec::new();
5343        for row in rows {
5344            let (path, status) = row?;
5345            let spec = purpose_health_spec_for_status(&status)?;
5346            findings.push(HealthFinding {
5347                id: finding_id(spec.category, &path, None),
5348                severity: Severity::Warning,
5349                category: spec.category.to_string(),
5350                path,
5351                related_path: None,
5352                message: spec.message.to_string(),
5353                recommendation: spec.recommendation.to_string(),
5354            });
5355        }
5356        Ok(findings)
5357    }
5358
5359    /// Count unresolved purpose lifecycle findings directly in `SQLite`.
5360    fn count_purpose_status_findings(
5361        &self,
5362        spec: PurposeHealthSpec,
5363        path_prefix: Option<&str>,
5364        resolution_filter: HealthResolutionFilter<'_>,
5365        scope: HealthScope,
5366    ) -> DbResult<usize> {
5367        let (where_clause, values) =
5368            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5369        let sql = format!(
5370            "
5371            SELECT COUNT(*)
5372            FROM nodes n
5373            JOIN purposes p ON p.node_id = n.id
5374            WHERE {where_clause}
5375            "
5376        );
5377        let count = self
5378            .connection
5379            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5380        count_to_usize("health_purpose_status_count", count)
5381    }
5382
5383    /// Load one bounded unresolved purpose lifecycle page directly from `SQLite`.
5384    fn load_purpose_status_findings_page(
5385        &self,
5386        spec: PurposeHealthSpec,
5387        path_prefix: Option<&str>,
5388        resolution_filter: HealthResolutionFilter<'_>,
5389        scope: HealthScope,
5390        start_index: usize,
5391        limit: usize,
5392    ) -> DbResult<Vec<HealthFinding>> {
5393        if limit == 0 {
5394            return Ok(Vec::new());
5395        }
5396        let (where_clause, mut values) =
5397            purpose_status_where_clause(spec, path_prefix, resolution_filter, scope);
5398        let limit_placeholder = values.len() + 1;
5399        let offset_placeholder = values.len() + 2;
5400        values.push(Value::from(usize_to_i64(limit)));
5401        values.push(Value::from(usize_to_i64(start_index)));
5402        let order_by = if scope.high_impact_queue() {
5403            purpose_default_queue_order_expression("n", "p")
5404        } else {
5405            "n.path".to_string()
5406        };
5407        let sql = format!(
5408            "
5409            SELECT n.path
5410            FROM nodes n
5411            JOIN purposes p ON p.node_id = n.id
5412            WHERE {where_clause}
5413            ORDER BY {order_by}
5414            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5415            "
5416        );
5417        let mut statement = self.connection.prepare(&sql)?;
5418        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5419        let mut findings = Vec::new();
5420        for row in rows {
5421            let path = row?;
5422            findings.push(HealthFinding {
5423                id: finding_id(spec.category, &path, None),
5424                severity: Severity::Warning,
5425                category: spec.category.to_string(),
5426                path,
5427                related_path: None,
5428                message: spec.message.to_string(),
5429                recommendation: spec.recommendation.to_string(),
5430            });
5431        }
5432        Ok(findings)
5433    }
5434
5435    /// Count unresolved structural health findings directly in `SQLite`.
5436    fn count_structural_health_findings(
5437        &self,
5438        category: &str,
5439        path_prefix: Option<&str>,
5440        resolution_filter: HealthResolutionFilter<'_>,
5441        scope: HealthScope,
5442    ) -> DbResult<usize> {
5443        match category {
5444            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => {
5445                self.count_agent_review_required_findings(path_prefix, resolution_filter, scope)
5446            }
5447            CATEGORY_DUPLICATE_PURPOSE => {
5448                self.count_duplicate_purpose_findings(path_prefix, resolution_filter, scope)
5449            }
5450            CATEGORY_REPEATED_TEMPORARY_FOLDER => {
5451                self.count_repeated_temp_folder_findings(path_prefix, resolution_filter, scope)
5452            }
5453            _ => Ok(0),
5454        }
5455    }
5456
5457    /// Load a bounded unresolved structural health page directly from `SQLite`.
5458    fn load_structural_health_findings_page(
5459        &self,
5460        category: &str,
5461        path_prefix: Option<&str>,
5462        resolution_filter: HealthResolutionFilter<'_>,
5463        scope: HealthScope,
5464        start_index: usize,
5465        limit: usize,
5466    ) -> DbResult<Vec<HealthFinding>> {
5467        match category {
5468            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self
5469                .load_agent_review_required_findings_page(
5470                    path_prefix,
5471                    resolution_filter,
5472                    scope,
5473                    start_index,
5474                    limit,
5475                ),
5476            CATEGORY_DUPLICATE_PURPOSE => self.load_duplicate_purpose_findings_page(
5477                path_prefix,
5478                resolution_filter,
5479                scope,
5480                start_index,
5481                limit,
5482            ),
5483            CATEGORY_REPEATED_TEMPORARY_FOLDER => self.load_repeated_temp_folder_findings_page(
5484                path_prefix,
5485                resolution_filter,
5486                scope,
5487                start_index,
5488                limit,
5489            ),
5490            _ => Ok(Vec::new()),
5491        }
5492    }
5493
5494    /// Visit approved navigation-critical purposes that still need agent review.
5495    fn visit_agent_review_required_findings<F>(
5496        &self,
5497        resolved_ids: &HashSet<String>,
5498        visitor: &mut F,
5499    ) -> DbResult<bool>
5500    where
5501        F: FnMut(HealthFinding) -> DbResult<bool>,
5502    {
5503        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5504        let high_impact = high_impact_file_path_expression("lower(n.path)");
5505        let approved_status = PurposeStatus::Approved.as_str();
5506        let sql = format!(
5507            "
5508            SELECT n.path
5509            FROM nodes n
5510            JOIN purposes p ON p.node_id = n.id
5511            WHERE n.exists_now = 1
5512              AND p.status = '{approved_status}'
5513              AND p.source NOT IN ({reviewed_sources})
5514              AND (n.kind = 'folder' OR (n.kind = 'file' AND {high_impact}))
5515            ORDER BY CASE WHEN n.kind = 'folder' THEN 0 ELSE 1 END, n.path
5516            "
5517        );
5518        let mut statement = self.connection.prepare(&sql)?;
5519        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
5520        for row in rows {
5521            let finding = agent_review_required_finding(row?);
5522            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
5523                return Ok(false);
5524            }
5525        }
5526        Ok(true)
5527    }
5528
5529    /// Count approved navigation-critical purposes that still need agent review.
5530    fn count_agent_review_required_findings(
5531        &self,
5532        path_prefix: Option<&str>,
5533        resolution_filter: HealthResolutionFilter<'_>,
5534        scope: HealthScope,
5535    ) -> DbResult<usize> {
5536        let (where_clause, values) = structural_finding_where_clause(
5537            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5538            path_prefix,
5539            resolution_filter,
5540            scope,
5541            1,
5542        );
5543        let source_relevant = source_relevant_node_expression("n");
5544        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5545        let review_candidate = purpose_review_candidate_expression("n", scope);
5546        let approved_status = PurposeStatus::Approved.as_str();
5547        let sql = format!(
5548            "
5549            WITH findings AS (
5550                SELECT n.path,
5551                       n.kind,
5552                       n.language,
5553                       '' AS related_path,
5554                       {source_relevant} AS source_relevant
5555                FROM nodes n
5556                JOIN purposes p ON p.node_id = n.id
5557                WHERE n.exists_now = 1
5558                  AND p.status = '{approved_status}'
5559                  AND p.source NOT IN ({reviewed_sources})
5560                  AND {review_candidate}
5561            )
5562            SELECT COUNT(*)
5563            FROM findings
5564            {where_clause}
5565            "
5566        );
5567        let count = self
5568            .connection
5569            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5570        count_to_usize("health_agent_review_required_count", count)
5571    }
5572
5573    /// Load approved navigation-critical purposes that still need agent review.
5574    fn load_agent_review_required_findings_page(
5575        &self,
5576        path_prefix: Option<&str>,
5577        resolution_filter: HealthResolutionFilter<'_>,
5578        scope: HealthScope,
5579        start_index: usize,
5580        limit: usize,
5581    ) -> DbResult<Vec<HealthFinding>> {
5582        if limit == 0 {
5583            return Ok(Vec::new());
5584        }
5585        let (where_clause, mut values) = structural_finding_where_clause(
5586            CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
5587            path_prefix,
5588            resolution_filter,
5589            scope,
5590            1,
5591        );
5592        let source_relevant = source_relevant_node_expression("n");
5593        let reviewed_sources = sql_string_literals(AGENT_REVIEWED_SOURCE_VALUES);
5594        let review_candidate = purpose_review_candidate_expression("n", scope);
5595        let approved_status = PurposeStatus::Approved.as_str();
5596        let limit_placeholder = values.len() + 1;
5597        let offset_placeholder = values.len() + 2;
5598        values.push(Value::from(usize_to_i64(limit)));
5599        values.push(Value::from(usize_to_i64(start_index)));
5600        let sql = format!(
5601            "
5602            WITH findings AS (
5603                SELECT n.path,
5604                       n.kind,
5605                       n.language,
5606                       '' AS related_path,
5607                       {source_relevant} AS source_relevant
5608                FROM nodes n
5609                JOIN purposes p ON p.node_id = n.id
5610                WHERE n.exists_now = 1
5611                  AND p.status = '{approved_status}'
5612                  AND p.source NOT IN ({reviewed_sources})
5613                  AND {review_candidate}
5614            )
5615            SELECT path
5616            FROM findings
5617            {where_clause}
5618            ORDER BY CASE WHEN kind = 'folder' THEN 0 ELSE 1 END, path
5619            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5620            "
5621        );
5622        let mut statement = self.connection.prepare(&sql)?;
5623        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(0))?;
5624        let mut findings = Vec::new();
5625        for row in rows {
5626            findings.push(agent_review_required_finding(row?));
5627        }
5628        Ok(findings)
5629    }
5630
5631    /// Count duplicate-purpose findings directly in `SQLite`.
5632    fn count_duplicate_purpose_findings(
5633        &self,
5634        path_prefix: Option<&str>,
5635        resolution_filter: HealthResolutionFilter<'_>,
5636        scope: HealthScope,
5637    ) -> DbResult<usize> {
5638        let (where_clause, values) = structural_finding_where_clause(
5639            CATEGORY_DUPLICATE_PURPOSE,
5640            path_prefix,
5641            resolution_filter,
5642            scope,
5643            1,
5644        );
5645        let source_relevant = source_relevant_node_expression("n");
5646        let duplicate_scope =
5647            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5648        let approved_status = PurposeStatus::Approved.as_str();
5649        let sql = format!(
5650            "
5651            WITH duplicate_rows AS (
5652                SELECT n.path,
5653                       n.kind,
5654                       n.language,
5655                       p.purpose,
5656                       {source_relevant} AS source_relevant,
5657                       FIRST_VALUE(n.path) OVER (
5658                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5659                           ORDER BY n.path
5660                       ) AS related_path,
5661                       ROW_NUMBER() OVER (
5662                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5663                           ORDER BY n.path
5664                       ) AS duplicate_rank,
5665                       COUNT(*) OVER (
5666                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5667                       ) AS duplicate_count
5668                FROM nodes n
5669                JOIN purposes p ON p.node_id = n.id
5670                WHERE n.exists_now = 1
5671                  AND p.status = '{approved_status}'
5672                  AND p.purpose IS NOT NULL
5673            ),
5674            findings AS (
5675                SELECT path, kind, language, purpose, related_path, source_relevant
5676                FROM duplicate_rows
5677                WHERE duplicate_count > 1
5678                  AND duplicate_rank > 1
5679            )
5680            SELECT COUNT(*)
5681            FROM findings
5682            {where_clause}
5683            "
5684        );
5685        let count = self
5686            .connection
5687            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5688        count_to_usize("health_duplicate_purpose_count", count)
5689    }
5690
5691    /// Load a bounded duplicate-purpose findings page directly in `SQLite`.
5692    fn load_duplicate_purpose_findings_page(
5693        &self,
5694        path_prefix: Option<&str>,
5695        resolution_filter: HealthResolutionFilter<'_>,
5696        scope: HealthScope,
5697        start_index: usize,
5698        limit: usize,
5699    ) -> DbResult<Vec<HealthFinding>> {
5700        if limit == 0 {
5701            return Ok(Vec::new());
5702        }
5703        let (where_clause, mut values) = structural_finding_where_clause(
5704            CATEGORY_DUPLICATE_PURPOSE,
5705            path_prefix,
5706            resolution_filter,
5707            scope,
5708            1,
5709        );
5710        let source_relevant = source_relevant_node_expression("n");
5711        let duplicate_scope =
5712            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5713        let approved_status = PurposeStatus::Approved.as_str();
5714        let limit_placeholder = values.len() + 1;
5715        let offset_placeholder = values.len() + 2;
5716        values.push(Value::from(usize_to_i64(limit)));
5717        values.push(Value::from(usize_to_i64(start_index)));
5718        let sql = format!(
5719            "
5720            WITH duplicate_rows AS (
5721                SELECT n.path,
5722                       n.kind,
5723                       n.language,
5724                       p.purpose,
5725                       {source_relevant} AS source_relevant,
5726                       FIRST_VALUE(n.path) OVER (
5727                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5728                           ORDER BY n.path
5729                       ) AS related_path,
5730                       ROW_NUMBER() OVER (
5731                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5732                           ORDER BY n.path
5733                       ) AS duplicate_rank,
5734                       COUNT(*) OVER (
5735                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5736                       ) AS duplicate_count
5737                FROM nodes n
5738                JOIN purposes p ON p.node_id = n.id
5739                WHERE n.exists_now = 1
5740                  AND p.status = '{approved_status}'
5741                  AND p.purpose IS NOT NULL
5742            ),
5743            findings AS (
5744                SELECT path, kind, language, purpose, related_path, source_relevant
5745                FROM duplicate_rows
5746                WHERE duplicate_count > 1
5747                  AND duplicate_rank > 1
5748            )
5749            SELECT path, kind, related_path
5750            FROM findings
5751            {where_clause}
5752            ORDER BY kind, lower(purpose), path
5753            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5754            "
5755        );
5756        let mut statement = self.connection.prepare(&sql)?;
5757        let rows = statement.query_map(params_from_iter(values), |row| {
5758            Ok((
5759                row.get::<_, String>(0)?,
5760                row.get::<_, String>(1)?,
5761                row.get::<_, String>(2)?,
5762            ))
5763        })?;
5764        let mut findings = Vec::new();
5765        for row in rows {
5766            let (path, kind_value, related_path) = row?;
5767            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
5768                field: "kind",
5769                value: kind_value,
5770            })?;
5771            findings.push(HealthFinding {
5772                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
5773                severity: Severity::Warning,
5774                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
5775                path,
5776                related_path: Some(related_path),
5777                message: format!("Multiple {kind} nodes share the same purpose."),
5778                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
5779            });
5780        }
5781        Ok(findings)
5782    }
5783
5784    /// Count repeated temporary-folder findings directly in `SQLite`.
5785    fn count_repeated_temp_folder_findings(
5786        &self,
5787        path_prefix: Option<&str>,
5788        resolution_filter: HealthResolutionFilter<'_>,
5789        scope: HealthScope,
5790    ) -> DbResult<usize> {
5791        let mut total = 0_usize;
5792        for bucket in TEMP_FOLDER_BUCKETS {
5793            total += self.count_repeated_temp_folder_bucket_findings(
5794                bucket,
5795                path_prefix,
5796                resolution_filter,
5797                scope,
5798            )?;
5799        }
5800        Ok(total)
5801    }
5802
5803    /// Count one repeated temporary-folder bucket directly in `SQLite`.
5804    fn count_repeated_temp_folder_bucket_findings(
5805        &self,
5806        bucket: &str,
5807        path_prefix: Option<&str>,
5808        resolution_filter: HealthResolutionFilter<'_>,
5809        scope: HealthScope,
5810    ) -> DbResult<usize> {
5811        let exact = bucket.to_string();
5812        let suffix = format!("%/{bucket}");
5813        let (where_clause, mut filter_values) = structural_finding_where_clause(
5814            CATEGORY_REPEATED_TEMPORARY_FOLDER,
5815            path_prefix,
5816            resolution_filter,
5817            scope,
5818            3,
5819        );
5820        let mut values = vec![Value::from(exact), Value::from(suffix)];
5821        values.append(&mut filter_values);
5822        let source_relevant = source_relevant_node_expression("n");
5823        let sql = format!(
5824            "
5825            WITH bucket_rows AS (
5826                SELECT n.path,
5827                       n.kind,
5828                       n.language,
5829                       {source_relevant} AS source_relevant,
5830                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5831                       ROW_NUMBER() OVER (ORDER BY path) AS duplicate_rank,
5832                       COUNT(*) OVER () AS duplicate_count
5833                FROM nodes n
5834                WHERE n.exists_now = 1
5835                  AND n.kind = 'folder'
5836                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5837            ),
5838            findings AS (
5839                SELECT path, kind, language, related_path, source_relevant
5840                FROM bucket_rows
5841                WHERE duplicate_count > 1
5842                  AND duplicate_rank > 1
5843            )
5844            SELECT COUNT(*)
5845            FROM findings
5846            {where_clause}
5847            "
5848        );
5849        let count = self
5850            .connection
5851            .query_row(&sql, params_from_iter(values), |row| row.get::<_, i64>(0))?;
5852        count_to_usize("health_repeated_temp_count", count)
5853    }
5854
5855    /// Load a bounded repeated temporary-folder findings page directly in `SQLite`.
5856    fn load_repeated_temp_folder_findings_page(
5857        &self,
5858        path_prefix: Option<&str>,
5859        resolution_filter: HealthResolutionFilter<'_>,
5860        scope: HealthScope,
5861        start_index: usize,
5862        limit: usize,
5863    ) -> DbResult<Vec<HealthFinding>> {
5864        if limit == 0 {
5865            return Ok(Vec::new());
5866        }
5867        let mut total = 0_usize;
5868        let mut findings = Vec::new();
5869        for bucket in TEMP_FOLDER_BUCKETS {
5870            let matching_count = self.count_repeated_temp_folder_bucket_findings(
5871                bucket,
5872                path_prefix,
5873                resolution_filter,
5874                scope,
5875            )?;
5876            if findings.len() < limit && total + matching_count > start_index {
5877                let local_start = start_index.saturating_sub(total);
5878                let local_limit = limit - findings.len();
5879                findings.extend(self.load_repeated_temp_folder_bucket_findings_page(
5880                    bucket,
5881                    path_prefix,
5882                    resolution_filter,
5883                    scope,
5884                    local_start,
5885                    local_limit,
5886                )?);
5887            }
5888            total += matching_count;
5889            if findings.len() >= limit {
5890                break;
5891            }
5892        }
5893        Ok(findings)
5894    }
5895
5896    /// Load one repeated temporary-folder bucket directly in `SQLite`.
5897    fn load_repeated_temp_folder_bucket_findings_page(
5898        &self,
5899        bucket: &str,
5900        path_prefix: Option<&str>,
5901        resolution_filter: HealthResolutionFilter<'_>,
5902        scope: HealthScope,
5903        start_index: usize,
5904        limit: usize,
5905    ) -> DbResult<Vec<HealthFinding>> {
5906        if limit == 0 {
5907            return Ok(Vec::new());
5908        }
5909        let exact = bucket.to_string();
5910        let suffix = format!("%/{bucket}");
5911        let (where_clause, mut filter_values) = structural_finding_where_clause(
5912            CATEGORY_REPEATED_TEMPORARY_FOLDER,
5913            path_prefix,
5914            resolution_filter,
5915            scope,
5916            3,
5917        );
5918        let mut values = vec![Value::from(exact), Value::from(suffix)];
5919        values.append(&mut filter_values);
5920        let source_relevant = source_relevant_node_expression("n");
5921        let limit_placeholder = values.len() + 1;
5922        let offset_placeholder = values.len() + 2;
5923        values.push(Value::from(usize_to_i64(limit)));
5924        values.push(Value::from(usize_to_i64(start_index)));
5925        let sql = format!(
5926            "
5927            WITH bucket_rows AS (
5928                SELECT n.path,
5929                       n.kind,
5930                       n.language,
5931                       {source_relevant} AS source_relevant,
5932                       FIRST_VALUE(n.path) OVER (ORDER BY n.path) AS related_path,
5933                       ROW_NUMBER() OVER (ORDER BY n.path) AS duplicate_rank,
5934                       COUNT(*) OVER () AS duplicate_count
5935                FROM nodes n
5936                WHERE n.exists_now = 1
5937                  AND n.kind = 'folder'
5938                  AND (lower(n.path) = ?1 OR lower(n.path) LIKE ?2)
5939            ),
5940            findings AS (
5941                SELECT path, kind, language, related_path, source_relevant
5942                FROM bucket_rows
5943                WHERE duplicate_count > 1
5944                  AND duplicate_rank > 1
5945            )
5946            SELECT path, related_path
5947            FROM findings
5948            {where_clause}
5949            ORDER BY path
5950            LIMIT ?{limit_placeholder} OFFSET ?{offset_placeholder}
5951            "
5952        );
5953        let mut statement = self.connection.prepare(&sql)?;
5954        let rows = statement.query_map(params_from_iter(values), |row| {
5955            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5956        })?;
5957        let mut findings = Vec::new();
5958        for row in rows {
5959            let (path, related_path) = row?;
5960            findings.push(HealthFinding {
5961                id: finding_id(
5962                    CATEGORY_REPEATED_TEMPORARY_FOLDER,
5963                    &path,
5964                    Some(&related_path),
5965                ),
5966                severity: Severity::Warning,
5967                category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
5968                path,
5969                related_path: Some(related_path),
5970                message: format!("Repeated temporary/generated folder name `{bucket}` found."),
5971                recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
5972            });
5973        }
5974        Ok(findings)
5975    }
5976
5977    /// Visit duplicate-purpose health findings through grouped SQL candidates.
5978    fn visit_duplicate_purpose_findings<F>(
5979        &self,
5980        resolved_ids: &HashSet<String>,
5981        visitor: &mut F,
5982    ) -> DbResult<bool>
5983    where
5984        F: FnMut(HealthFinding) -> DbResult<bool>,
5985    {
5986        let duplicate_scope =
5987            "CASE WHEN n.kind = 'folder' THEN COALESCE(n.parent_path, '') ELSE '' END";
5988        let approved_status = PurposeStatus::Approved.as_str();
5989        let sql = format!(
5990            "
5991            WITH duplicate_rows AS (
5992                SELECT n.path,
5993                       n.kind,
5994                       p.purpose,
5995                       FIRST_VALUE(n.path) OVER (
5996                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
5997                           ORDER BY n.path
5998                       ) AS related_path,
5999                       ROW_NUMBER() OVER (
6000                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6001                           ORDER BY n.path
6002                       ) AS duplicate_rank,
6003                       COUNT(*) OVER (
6004                           PARTITION BY n.kind, lower(p.purpose), {duplicate_scope}
6005                       ) AS duplicate_count
6006                FROM nodes n
6007                JOIN purposes p ON p.node_id = n.id
6008                WHERE n.exists_now = 1
6009                  AND p.status = '{approved_status}'
6010                  AND p.purpose IS NOT NULL
6011            )
6012            SELECT path, kind, purpose, related_path
6013            FROM duplicate_rows
6014            WHERE duplicate_count > 1
6015              AND duplicate_rank > 1
6016            ORDER BY kind, lower(purpose), path
6017            "
6018        );
6019        let mut statement = self.connection.prepare(&sql)?;
6020        let rows = statement.query_map([], |row| {
6021            Ok((
6022                row.get::<_, String>(0)?,
6023                row.get::<_, String>(1)?,
6024                row.get::<_, String>(2)?,
6025                row.get::<_, String>(3)?,
6026            ))
6027        })?;
6028        for row in rows {
6029            let (path, kind_value, _purpose, related_path) = row?;
6030            let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
6031                field: "kind",
6032                value: kind_value.clone(),
6033            })?;
6034            let finding = HealthFinding {
6035                id: finding_id(CATEGORY_DUPLICATE_PURPOSE, &path, Some(&related_path)),
6036                severity: Severity::Warning,
6037                category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
6038                path,
6039                related_path: Some(related_path),
6040                message: format!("Multiple {kind} nodes share the same purpose."),
6041                recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
6042            };
6043            if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6044                return Ok(false);
6045            }
6046        }
6047        Ok(true)
6048    }
6049
6050    /// Visit repeated temporary/generated folder findings.
6051    fn visit_repeated_temp_folder_findings<F>(
6052        &self,
6053        resolved_ids: &HashSet<String>,
6054        visitor: &mut F,
6055    ) -> DbResult<bool>
6056    where
6057        F: FnMut(HealthFinding) -> DbResult<bool>,
6058    {
6059        for bucket in TEMP_FOLDER_BUCKETS {
6060            let exact = bucket.to_string();
6061            let suffix = format!("%/{bucket}");
6062            let mut statement = self.connection.prepare(
6063                "
6064                SELECT path
6065                FROM nodes
6066                WHERE exists_now = 1
6067                  AND kind = 'folder'
6068                  AND (lower(path) = ?1 OR lower(path) LIKE ?2)
6069                ORDER BY path
6070                ",
6071            )?;
6072            let rows =
6073                statement.query_map(params![exact, suffix], |row| row.get::<_, String>(0))?;
6074            let mut first_path = None;
6075            for row in rows {
6076                let path = row?;
6077                let Some(first_path) = first_path.as_ref() else {
6078                    first_path = Some(path);
6079                    continue;
6080                };
6081                let finding = HealthFinding {
6082                    id: finding_id(
6083                        CATEGORY_REPEATED_TEMPORARY_FOLDER,
6084                        &path,
6085                        Some(first_path.as_str()),
6086                    ),
6087                    severity: Severity::Warning,
6088                    category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
6089                    path,
6090                    related_path: Some(first_path.clone()),
6091                    message: format!("Repeated temporary/generated folder name `{bucket}` found."),
6092                    recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
6093                };
6094                if !emit_unresolved_finding(finding, resolved_ids, visitor)? {
6095                    return Ok(false);
6096                }
6097            }
6098        }
6099        Ok(true)
6100    }
6101
6102    /// Compute an overview from the current index.
6103    ///
6104    /// # Errors
6105    ///
6106    /// Returns an error if the aggregate query fails or a count is invalid.
6107    pub fn overview(&self) -> DbResult<Overview> {
6108        let missing_status = PurposeStatus::Missing.as_str();
6109        let stale_status = PurposeStatus::Stale.as_str();
6110        let approved_status = PurposeStatus::Approved.as_str();
6111        let suggested_status = PurposeStatus::Suggested.as_str();
6112        let sql = format!(
6113            "
6114            SELECT
6115                COALESCE(SUM(CASE WHEN n.kind = 'file' THEN 1 ELSE 0 END), 0),
6116                COALESCE(SUM(CASE WHEN n.kind = 'folder' THEN 1 ELSE 0 END), 0),
6117                COALESCE(SUM(CASE WHEN p.status = '{missing_status}' THEN 1 ELSE 0 END), 0),
6118                COALESCE(SUM(CASE WHEN p.status = '{stale_status}' THEN 1 ELSE 0 END), 0),
6119                COALESCE(SUM(CASE WHEN p.status = '{approved_status}' THEN 1 ELSE 0 END), 0),
6120                COALESCE(SUM(CASE WHEN p.status = '{suggested_status}' THEN 1 ELSE 0 END), 0)
6121            FROM nodes n
6122            JOIN purposes p ON p.node_id = n.id
6123            WHERE n.exists_now = 1
6124            "
6125        );
6126        let counts = self.connection.query_row(&sql, [], |row| {
6127            Ok((
6128                row.get::<_, i64>(0)?,
6129                row.get::<_, i64>(1)?,
6130                row.get::<_, i64>(2)?,
6131                row.get::<_, i64>(3)?,
6132                row.get::<_, i64>(4)?,
6133                row.get::<_, i64>(5)?,
6134            ))
6135        })?;
6136        Ok(Overview {
6137            files: count_to_usize("files", counts.0)?,
6138            folders: count_to_usize("folders", counts.1)?,
6139            missing_purposes: count_to_usize("missing_purposes", counts.2)?,
6140            stale_purposes: count_to_usize("stale_purposes", counts.3)?,
6141            approved_purposes: count_to_usize("approved_purposes", counts.4)?,
6142            suggested_purposes: count_to_usize("suggested_purposes", counts.5)?,
6143        })
6144    }
6145
6146    /// Record a usage event.
6147    ///
6148    /// # Errors
6149    ///
6150    /// Returns an error if persistence fails.
6151    pub fn record_usage(&self, event: &UsageEvent) -> DbResult<()> {
6152        telemetry::validate_event(event, TelemetryRetentionPolicy::default())?;
6153        self.validated_project_root_identity
6154            .as_ref()
6155            .ok_or(DbError::ProjectRootIdentityMissing)?;
6156        let instance_id = self.library_usage_instance(&event.session_id, false)?;
6157        match self.record_usage_for_instance(
6158            instance_id,
6159            UsageInstanceOwner::LibraryHandle,
6160            event,
6161            false,
6162        ) {
6163            Err(DbError::TelemetryInstanceInactive) => {
6164                let replacement = self.library_usage_instance(&event.session_id, true)?;
6165                self.record_usage_for_instance(
6166                    replacement,
6167                    UsageInstanceOwner::LibraryHandle,
6168                    event,
6169                    false,
6170                )
6171            }
6172            Err(DbError::TelemetryBaselineCapacity) => {
6173                let replacement = telemetry::generate_usage_instance_id()?;
6174                self.seal_usage_instance(instance_id)?;
6175                self.library_usage_instances
6176                    .borrow_mut()
6177                    .insert(event.session_id.clone(), Some(replacement));
6178                self.record_usage_for_instance(
6179                    replacement,
6180                    UsageInstanceOwner::LibraryHandle,
6181                    event,
6182                    false,
6183                )
6184            }
6185            result => result,
6186        }
6187    }
6188
6189    /// Return or rotate one bounded direct-library runtime instance per caller label.
6190    fn library_usage_instance(
6191        &self,
6192        caller_label: &str,
6193        rotate: bool,
6194    ) -> DbResult<UsageInstanceId> {
6195        let mut instances = self.library_usage_instances.borrow_mut();
6196        if !rotate && let Some(instance) = instances.get(caller_label) {
6197            return instance.ok_or(DbError::TelemetryIdentityUnavailable);
6198        }
6199        if !instances.contains_key(caller_label)
6200            && instances.len() >= TelemetryRetentionPolicy::default().max_retained_labels
6201        {
6202            return Err(DbError::TelemetryInstanceCapacity);
6203        }
6204        let instance = telemetry::generate_usage_instance_id().ok();
6205        instances.insert(caller_label.to_string(), instance);
6206        instance.ok_or(DbError::TelemetryIdentityUnavailable)
6207    }
6208
6209    /// Record a usage event for one bounded runtime or invocation instance.
6210    ///
6211    /// The internal instance is deliberately separate from the optional
6212    /// caller-visible label carried by [`UsageEvent::session_id`].
6213    ///
6214    /// # Errors
6215    ///
6216    /// Returns an error when the selected binding changes, the instance is
6217    /// inactive, a retention bound rejects the event, or `SQLite` cannot commit
6218    /// the complete telemetry transaction.
6219    pub fn record_usage_for_instance(
6220        &self,
6221        instance_id: UsageInstanceId,
6222        owner: UsageInstanceOwner,
6223        event: &UsageEvent,
6224        seal_after_record: bool,
6225    ) -> DbResult<()> {
6226        self.record_usage_for_instance_origin(instance_id, owner, None, event, seal_after_record)
6227    }
6228
6229    /// Record one centrally routed event for a captured worktree registration.
6230    ///
6231    /// # Errors
6232    ///
6233    /// Returns an error when the selected control binding changes, the
6234    /// registration or runtime origin conflicts, a retention bound rejects the
6235    /// event, or `SQLite` cannot commit the complete telemetry transaction.
6236    pub fn record_usage_for_worktree_instance(
6237        &self,
6238        instance_id: UsageInstanceId,
6239        owner: UsageInstanceOwner,
6240        registration_id: i64,
6241        event: &UsageEvent,
6242        seal_after_record: bool,
6243    ) -> DbResult<()> {
6244        self.record_usage_for_instance_origin(
6245            instance_id,
6246            owner,
6247            Some(registration_id),
6248            event,
6249            seal_after_record,
6250        )
6251    }
6252
6253    /// Record one event with an optional captured worktree origin.
6254    fn record_usage_for_instance_origin(
6255        &self,
6256        instance_id: UsageInstanceId,
6257        owner: UsageInstanceOwner,
6258        registration_id: Option<i64>,
6259        event: &UsageEvent,
6260        seal_after_record: bool,
6261    ) -> DbResult<()> {
6262        let policy = TelemetryRetentionPolicy::default();
6263        let project_instance_id = self
6264            .validated_project_instance_id
6265            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
6266        let project_root_identity = self
6267            .validated_project_root_identity
6268            .as_ref()
6269            .ok_or(DbError::ProjectRootIdentityMissing)?;
6270        self.with_telemetry_connection(|connection| {
6271            with_validated_native_write_transaction(
6272                connection,
6273                Some(project_root_identity),
6274                Some(project_instance_id),
6275                |transaction| {
6276                    telemetry::record_usage_for_project(
6277                        transaction,
6278                        project_instance_id,
6279                        instance_id,
6280                        owner,
6281                        registration_id,
6282                        event,
6283                        policy,
6284                        seal_after_record,
6285                    )
6286                },
6287            )?;
6288            // The event is already committed. Passive maintenance remains
6289            // observable through retention state and must never make callers
6290            // retry a successfully persisted event.
6291            drop(telemetry::maintain_after_commit_for_native_project(
6292                connection,
6293                Some(project_root_identity),
6294                project_instance_id,
6295                policy,
6296            ));
6297            Ok(())
6298        })
6299    }
6300
6301    /// Seal one cleanly completed runtime or invocation instance.
6302    ///
6303    /// # Errors
6304    ///
6305    /// Returns an error when the selected binding changed, the instance is
6306    /// already inactive, or `SQLite` cannot commit the state transition.
6307    pub fn seal_usage_instance(&self, instance_id: UsageInstanceId) -> DbResult<()> {
6308        let project_root_identity = self
6309            .validated_project_root_identity
6310            .as_ref()
6311            .ok_or(DbError::ProjectRootIdentityMissing)?;
6312        self.with_telemetry_connection(|connection| {
6313            with_validated_native_write_transaction(
6314                connection,
6315                Some(project_root_identity),
6316                self.validated_project_instance_id,
6317                |transaction| telemetry::seal_usage_instance(transaction, instance_id),
6318            )
6319        })
6320    }
6321
6322    /// Return content-free bounded telemetry retention and maintenance state.
6323    ///
6324    /// # Errors
6325    ///
6326    /// Returns an error when persisted state is missing, corrupt, or cannot be read.
6327    pub fn telemetry_retention_state(&self) -> DbResult<TelemetryRetentionState> {
6328        telemetry::retention_state(&self.connection)
6329    }
6330
6331    /// Load usage events.
6332    ///
6333    /// # Errors
6334    ///
6335    /// Returns an error if loading fails.
6336    pub fn usage_events(&self, session_id: Option<&str>) -> DbResult<Vec<UsageEvent>> {
6337        telemetry::usage_events(&self.connection, session_id)
6338    }
6339
6340    /// Build a token overview.
6341    ///
6342    /// # Errors
6343    ///
6344    /// Returns an error if loading events fails.
6345    pub fn token_overview(&self, session_id: Option<&str>) -> DbResult<TokenOverview> {
6346        telemetry::token_overview(&self.connection, session_id)
6347    }
6348
6349    /// Build the control atlas's combined native-main and synchronized-worktree overview.
6350    ///
6351    /// # Errors
6352    ///
6353    /// Returns an error when the selected binding or aggregate state is invalid,
6354    /// arithmetic overflows, or `SQLite` cannot complete the bounded read.
6355    pub fn repository_token_overview(&self) -> DbResult<TokenOverview> {
6356        telemetry::repository_token_overview(&self.connection)
6357    }
6358
6359    /// Build exact retained routed plus synchronized totals for one active alias.
6360    ///
6361    /// # Errors
6362    ///
6363    /// Returns an error when the alias is absent, aggregate state is invalid,
6364    /// arithmetic overflows, or `SQLite` cannot complete the bounded read.
6365    pub fn registered_worktree_token_overview(
6366        &self,
6367        alias: &WorktreeAlias,
6368    ) -> DbResult<TokenOverview> {
6369        let registration = self.worktree_registration(alias)?;
6370        telemetry::worktree_token_overview(&self.connection, registration.registration_id)
6371    }
6372
6373    /// Build token trend aggregates grouped by day, week, month, or year.
6374    ///
6375    /// # Errors
6376    ///
6377    /// Returns an error if the window is unsupported or loading events fails.
6378    pub fn token_trends(
6379        &self,
6380        session_id: Option<&str>,
6381        window: TokenTrendWindow,
6382    ) -> DbResult<TokenTrendReport> {
6383        telemetry::token_trends(&self.connection, session_id, window)
6384    }
6385
6386    /// Build combined native-main and synchronized-worktree token trends.
6387    ///
6388    /// # Errors
6389    ///
6390    /// Returns an error for an unsupported window, invalid aggregate state,
6391    /// arithmetic overflow, or a bounded `SQLite` read failure.
6392    pub fn repository_token_trends(&self, window: TokenTrendWindow) -> DbResult<TokenTrendReport> {
6393        telemetry::repository_token_trends(&self.connection, window)
6394    }
6395
6396    /// Build exact retained routed plus synchronized trends for one active alias.
6397    ///
6398    /// # Errors
6399    ///
6400    /// Returns an error when the alias is absent, the window or aggregate state
6401    /// is invalid, arithmetic overflows, or `SQLite` cannot complete the read.
6402    pub fn registered_worktree_token_trends(
6403        &self,
6404        alias: &WorktreeAlias,
6405        window: TokenTrendWindow,
6406    ) -> DbResult<TokenTrendReport> {
6407        let registration = self.worktree_registration(alias)?;
6408        telemetry::worktree_token_trends(&self.connection, registration.registration_id, window)
6409    }
6410
6411    /// Mark a deterministic health finding as agent-resolved.
6412    ///
6413    /// # Errors
6414    ///
6415    /// Returns an error if the finding is not active or persistence fails.
6416    pub fn resolve_health_finding(&self, resolution: &HealthResolution) -> DbResult<()> {
6417        self.with_validated_write(|connection| {
6418            if !self.active_health_finding_matches(resolution)? {
6419                return Err(DbError::HealthFindingNotActive {
6420                    finding_id: resolution.finding_id.clone(),
6421                    category: resolution.category.clone(),
6422                    path: resolution.path.clone(),
6423                });
6424            }
6425            connection.execute(
6426                "
6427            INSERT INTO health_resolutions(
6428                finding_id,
6429                category,
6430                path,
6431                related_path,
6432                rationale,
6433                resolved_by,
6434                resolved_at
6435            )
6436            VALUES(?1, ?2, ?3, ?4, ?5, 'agent', CURRENT_TIMESTAMP)
6437            ON CONFLICT(finding_id) DO UPDATE SET
6438                category = excluded.category,
6439                path = excluded.path,
6440                related_path = excluded.related_path,
6441                rationale = excluded.rationale,
6442                resolved_by = 'agent',
6443                resolved_at = CURRENT_TIMESTAMP
6444            ",
6445                params![
6446                    resolution.finding_id,
6447                    resolution.category,
6448                    resolution.path,
6449                    resolution.related_path,
6450                    resolution.rationale,
6451                ],
6452            )?;
6453            Ok(())
6454        })
6455    }
6456
6457    /// Return whether the visible SQL health surface contains the exact finding.
6458    fn active_health_finding_matches(&self, resolution: &HealthResolution) -> DbResult<bool> {
6459        const PAGE_SIZE: usize = 256;
6460        let mut start_index = 0_usize;
6461        loop {
6462            let page = self.unresolved_health_findings_page_current(&HealthQuery {
6463                start_index,
6464                limit: PAGE_SIZE,
6465                category: Some(resolution.category.clone()),
6466                severity: Some(Severity::Warning),
6467                path_prefix: Some(resolution.path.clone()),
6468                summary_only: false,
6469                scope: HealthScope::all(),
6470            })?;
6471            if page.findings.iter().any(|finding| {
6472                finding.id == resolution.finding_id
6473                    && finding.category == resolution.category
6474                    && finding.path == resolution.path
6475                    && finding.related_path == resolution.related_path
6476            }) {
6477                return Ok(true);
6478            }
6479            if page.returned == 0 || start_index + page.returned >= page.total {
6480                return Ok(false);
6481            }
6482            start_index += page.returned;
6483        }
6484    }
6485
6486    /// Load resolved health finding ids.
6487    ///
6488    /// # Errors
6489    ///
6490    /// Returns an error if reading fails.
6491    pub fn resolved_health_ids(&self) -> DbResult<Vec<String>> {
6492        let mut statement = self
6493            .connection
6494            .prepare("SELECT finding_id FROM health_resolutions ORDER BY finding_id")?;
6495        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
6496        let mut ids = Vec::new();
6497        for row in rows {
6498            ids.push(row?);
6499        }
6500        Ok(ids)
6501    }
6502}
6503
6504/// Acquire the publication writer without waiting after exact input validation.
6505fn begin_immediate_publication(connection: &Connection) -> DbResult<()> {
6506    connection.busy_timeout(SQLITE_PUBLICATION_ACQUIRE_TIMEOUT)?;
6507    let begin_result = connection.execute_batch("BEGIN IMMEDIATE");
6508    let restore_result = connection.busy_timeout(SQLITE_BUSY_TIMEOUT);
6509    match (begin_result, restore_result) {
6510        (Ok(()), Ok(())) => Ok(()),
6511        (Err(error), Ok(())) => Err(error.into()),
6512        (Ok(()), Err(error)) => Err(schema::rollback_after_error(connection, error.into())),
6513        (Err(operation), Err(restore)) => Err(DbError::PublicationAcquirePolicyRestore {
6514            operation: Box::new(operation),
6515            restore: Box::new(restore),
6516        }),
6517    }
6518}
6519
6520/// Read the recorded project root without creating or migrating a database.
6521///
6522/// # Errors
6523///
6524/// Returns an error if `SQLite` cannot open or query the database read-only.
6525pub fn read_project_root_read_only(path: &Path) -> DbResult<Option<String>> {
6526    schema::read_project_root(path)
6527}
6528
6529/// Read an untrusted predecessor root candidate without creating, migrating,
6530/// or repairing a database.
6531///
6532/// This is recovery evidence for selecting a filesystem candidate only. It is
6533/// never an authoritative project identity and callers must canonicalize the
6534/// candidate through the live filesystem before opening a project database.
6535/// Current databases intentionally return no candidate because their typed
6536/// native identity is the only authority. A predecessor candidate is returned
6537/// only when the schema-owned policy can establish that its legacy projection
6538/// is usable; otherwise the typed native-identity error is returned.
6539///
6540/// # Errors
6541///
6542/// Returns an error when the database cannot be inspected read-only or its
6543/// predecessor schema is incompatible or malformed.
6544pub fn read_legacy_project_root_candidate_read_only(path: &Path) -> DbResult<Option<String>> {
6545    let (preflight, _) = schema::inspect_compatibility(path, None)?;
6546    if preflight.state != SchemaState::UpgradeRequired {
6547        return Ok(None);
6548    }
6549    if schema::legacy_root_requires_native_authority(preflight.project_root.as_deref()) {
6550        return Err(DbError::ProjectRootIdentityMissing);
6551    }
6552    Ok(preflight.project_root)
6553}
6554
6555/// Validate one existing project binding without creating, migrating, or repairing it.
6556///
6557/// A fresh or absent database is admitted for a later initializer. Existing current
6558/// databases use their native identity, while supported predecessors use the same
6559/// read-only native-equivalence proof as the storage writer. No SQLite write
6560/// connection or WAL state is opened by this check.
6561///
6562/// # Errors
6563///
6564/// Returns an error when the database is malformed, incompatible, missing an
6565/// unambiguous predecessor binding, or belongs to another project root.
6566pub fn preflight_project_binding_read_only(path: &Path, root: &Path) -> DbResult<()> {
6567    let expected_identity = CanonicalProjectRoot::from_path(root)?;
6568    schema::preflight_for_project(path, &expected_identity).map(|_| ())
6569}
6570
6571/// Read the authoritative native project-root identity without mutation.
6572///
6573/// # Errors
6574///
6575/// Returns an error if the database cannot be opened read-only or the identity
6576/// row contains an invalid versioned codec payload.
6577pub fn read_project_root_identity_read_only(path: &Path) -> DbResult<Option<CanonicalProjectRoot>> {
6578    let location = sqlite_profile::inspect_database_location(path)?;
6579    if !location.database_exists {
6580        return Ok(None);
6581    }
6582    let (preflight, _) = schema::inspect_compatibility(path, None)?;
6583    if preflight.state != schema::SchemaState::Current {
6584        return Ok(None);
6585    }
6586    let connection = schema::open_current_read_only(path, None)?.0;
6587    let identity = project_identity::load_project_root_identity(&connection)?;
6588    connection.execute_batch("ROLLBACK")?;
6589    Ok(identity)
6590}
6591
6592/// Verify the current project database schema, identity, and full integrity read-only.
6593///
6594/// # Errors
6595///
6596/// Returns an error when the database is missing, incompatible, corrupt, or belongs to another
6597/// project root.
6598pub fn verify_project_database(path: &Path, project_root: &Path) -> DbResult<()> {
6599    let identity = CanonicalProjectRoot::from_path(project_root)?;
6600    schema::verify_current_integrity_for_project(path, &identity)
6601}
6602
6603/// Normalize a filesystem path stored in `SQLite` metadata.
6604fn normalize_metadata_path(path: &Path) -> String {
6605    normalize_native_path_display(path)
6606}
6607
6608/// Upsert one metadata value through the caller's active connection/transaction.
6609fn set_metadata(connection: &Connection, key: &str, value: &str) -> DbResult<()> {
6610    connection.execute(
6611        "
6612        INSERT INTO metadata(key, value)
6613        VALUES(?1, ?2)
6614        ON CONFLICT(key) DO UPDATE SET value = excluded.value
6615        ",
6616        [key, value],
6617    )?;
6618    Ok(())
6619}
6620
6621/// Load the accepted authored-purpose revision without scanning purpose rows.
6622fn load_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6623    let value = connection
6624        .prepare_cached("SELECT value FROM metadata WHERE key = ?1")?
6625        .query_row([AUTHORED_PURPOSE_REVISION_KEY], |row| {
6626            row.get::<_, String>(0)
6627        })
6628        .optional()?;
6629    value.map_or(Ok(0), |value| {
6630        value
6631            .parse::<u64>()
6632            .map_err(|source| DbError::InvalidInteger {
6633                field: AUTHORED_PURPOSE_REVISION_KEY,
6634                value,
6635                source,
6636            })
6637    })
6638}
6639
6640/// Advance the accepted authored-purpose revision in the caller's transaction.
6641fn advance_authored_purpose_revision(connection: &Connection) -> DbResult<u64> {
6642    let current = load_authored_purpose_revision(connection)?;
6643    let revision = current
6644        .checked_add(1)
6645        .ok_or(DbError::IntegerMetadataOverflow {
6646            field: AUTHORED_PURPOSE_REVISION_KEY,
6647            value: current,
6648        })?;
6649    set_metadata(
6650        connection,
6651        AUTHORED_PURPOSE_REVISION_KEY,
6652        &revision.to_string(),
6653    )?;
6654    Ok(revision)
6655}
6656
6657/// Load the authoritative and projection revisions without scanning text rows.
6658fn load_file_text_fts_revisions(connection: &Connection) -> DbResult<Option<(u64, u64)>> {
6659    let (source, projection) = connection.query_row(
6660        "
6661        SELECT
6662            (SELECT value FROM metadata WHERE key = ?1),
6663            (SELECT value FROM metadata WHERE key = ?2)
6664        ",
6665        params![
6666            FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6667            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6668        ],
6669        |row| {
6670            Ok((
6671                row.get::<_, Option<String>>(0)?,
6672                row.get::<_, Option<String>>(1)?,
6673            ))
6674        },
6675    )?;
6676    if source.is_none() && projection.is_none() {
6677        return Ok(None);
6678    }
6679    let (Some(source), Some(projection)) = (source, projection) else {
6680        return Err(DbError::FileTextFtsStateInvalid {
6681            reason: "only one FTS revision is present",
6682        });
6683    };
6684    let source_revision =
6685        source
6686            .parse::<u64>()
6687            .map_err(|source_error| DbError::InvalidInteger {
6688                field: FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6689                value: source,
6690                source: source_error,
6691            })?;
6692    let projection_revision =
6693        projection
6694            .parse::<u64>()
6695            .map_err(|source_error| DbError::InvalidInteger {
6696                field: FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6697                value: projection,
6698                source: source_error,
6699            })?;
6700    Ok(Some((source_revision, projection_revision)))
6701}
6702
6703/// Mark an authoritative text mutation before changing its FTS projection.
6704fn begin_file_text_fts_update(connection: &Connection) -> DbResult<u64> {
6705    let (source, projection) =
6706        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6707            reason: "FTS revisions are missing",
6708        })?;
6709    if source != projection {
6710        return Err(DbError::FileTextFtsStateInvalid {
6711            reason: "an earlier FTS revision is incomplete",
6712        });
6713    }
6714    let revision = source
6715        .checked_add(1)
6716        .ok_or(DbError::FileTextFtsStateInvalid {
6717            reason: "FTS revision overflowed",
6718        })?;
6719    set_metadata(
6720        connection,
6721        FILE_TEXT_FTS_SOURCE_REVISION_KEY,
6722        &revision.to_string(),
6723    )?;
6724    Ok(revision)
6725}
6726
6727/// Publish the matching projection revision after every FTS mutation succeeds.
6728fn complete_file_text_fts_update(connection: &Connection, revision: u64) -> DbResult<()> {
6729    let (source, projection) =
6730        load_file_text_fts_revisions(connection)?.ok_or(DbError::FileTextFtsStateInvalid {
6731            reason: "FTS revisions disappeared during an update",
6732        })?;
6733    if source != revision || projection.checked_add(1) != Some(revision) {
6734        return Err(DbError::FileTextFtsStateInvalid {
6735            reason: "FTS revisions changed during an update",
6736        });
6737    }
6738    set_metadata(
6739        connection,
6740        FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
6741        &revision.to_string(),
6742    )
6743}
6744
6745/// Persist one usage event in the immutable released schema-8 fixture shape.
6746#[cfg(test)]
6747fn record_released_schema_eight_usage_event(
6748    connection: &Connection,
6749    event: &UsageEvent,
6750) -> DbResult<()> {
6751    connection.execute(
6752        "
6753        INSERT INTO usage_events(
6754            session_id,
6755            command,
6756            path,
6757            query,
6758            estimated_tokens_without_projectatlas,
6759            estimated_tokens_with_projectatlas,
6760            estimated_tokens_saved,
6761            token_savings_bucket,
6762            provider,
6763            model,
6764            tokenizer_backend,
6765            accuracy,
6766            baseline_kind,
6767            confidence,
6768            calculation_trace,
6769            accounting_layer,
6770            estimate_method,
6771            denominator_kind,
6772            baseline_identity,
6773            baseline_fingerprint,
6774            dedupe_scope,
6775            created_at
6776        )
6777        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, CURRENT_TIMESTAMP)
6778        ",
6779        params![
6780            event.session_id,
6781            event.command,
6782            event.path,
6783            event.query,
6784            option_usize_to_i64(
6785                "estimated_tokens_without_projectatlas",
6786                event.estimated_tokens_without_projectatlas,
6787            )?,
6788            option_usize_to_i64(
6789                "estimated_tokens_with_projectatlas",
6790                event.estimated_tokens_with_projectatlas,
6791            )?,
6792            event.estimated_tokens_saved,
6793            event.token_savings_bucket,
6794            event.provider,
6795            event.model,
6796            event.tokenizer_backend,
6797            event.accuracy,
6798            event.baseline_kind,
6799            event.confidence,
6800            event.calculation_trace,
6801            event.accounting_layer,
6802            event.estimate_method,
6803            event.denominator_kind,
6804            event.baseline_identity,
6805            event.baseline_fingerprint,
6806            event.dedupe_scope
6807        ],
6808    )?;
6809    Ok(())
6810}
6811
6812/// Load durable publication metadata from one connection snapshot.
6813fn load_index_publication(connection: &Connection) -> DbResult<Option<IndexPublication>> {
6814    let row = connection
6815        .query_row(
6816            "
6817            SELECT state.value, fingerprint.value, generation.value
6818            FROM metadata AS state
6819            LEFT JOIN metadata AS fingerprint ON fingerprint.key = ?2
6820            LEFT JOIN metadata AS generation ON generation.key = ?3
6821            WHERE state.key = ?1
6822            ",
6823            params![
6824                INDEX_PUBLICATION_STATE_KEY,
6825                INDEX_PUBLICATION_FINGERPRINT_KEY,
6826                INDEX_PUBLICATION_GENERATION_KEY,
6827            ],
6828            |row| {
6829                Ok((
6830                    row.get::<_, String>(0)?,
6831                    row.get::<_, Option<String>>(1)?,
6832                    row.get::<_, Option<String>>(2)?,
6833                ))
6834            },
6835        )
6836        .optional()?;
6837    let Some((state, contract_fingerprint, generation)) = row else {
6838        return Ok(None);
6839    };
6840    let generation = generation.map_or(Ok(IndexGeneration::ZERO), |value| {
6841        value
6842            .parse::<u64>()
6843            .map(IndexGeneration::new)
6844            .map_err(|source| DbError::InvalidInteger {
6845                field: INDEX_PUBLICATION_GENERATION_KEY,
6846                value,
6847                source,
6848            })
6849    })?;
6850    Ok(Some(IndexPublication {
6851        state: IndexPublicationState::from_db(state)?,
6852        contract_fingerprint,
6853        generation,
6854    }))
6855}
6856
6857/// Mark every indexed node absent before a complete scan replacement.
6858fn mark_all_scan_nodes_absent(connection: &Connection) -> DbResult<()> {
6859    connection.execute("UPDATE nodes SET exists_now = 0", [])?;
6860    Ok(())
6861}
6862
6863/// Delete derived rows whose owning scan node remained absent.
6864fn delete_absent_scan_projections(connection: &Connection) -> DbResult<()> {
6865    content_classification::delete_absent_file_content_classifications(connection)?;
6866    let fts_revision = begin_file_text_fts_update(connection)?;
6867    connection.execute(
6868        "DELETE FROM symbol_relations WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6869        [],
6870    )?;
6871    connection.execute(
6872        "DELETE FROM symbols WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6873        [],
6874    )?;
6875    connection.execute(
6876        "DELETE FROM source_parse_metadata WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6877        [],
6878    )?;
6879    connection.execute(
6880        "INSERT INTO file_text_fts(file_text_fts, rowid, content) \
6881         SELECT 'delete', rowid, content FROM file_texts \
6882         WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6883        [],
6884    )?;
6885    connection.execute(
6886        "DELETE FROM file_texts WHERE path IN (SELECT path FROM nodes WHERE exists_now = 0)",
6887        [],
6888    )?;
6889    complete_file_text_fts_update(connection, fts_revision)?;
6890    Ok(())
6891}
6892
6893/// Upsert scanned nodes through transaction-owned prepared statements.
6894fn upsert_nodes(connection: &Connection, nodes: &[Node]) -> DbResult<()> {
6895    content_classification::delete_non_file_content_classifications(connection, nodes)?;
6896    let mut select_existing = connection.prepare_cached(
6897        "
6898        SELECT content_hash
6899        FROM nodes
6900        WHERE path = ?1
6901        ",
6902    )?;
6903    let mut upsert_node = connection.prepare_cached(
6904        "
6905        INSERT INTO nodes(path, kind, parent_path, extension, language, size_bytes, mtime_ns, content_hash, exists_now)
6906        VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
6907        ON CONFLICT(path) DO UPDATE SET
6908            kind = excluded.kind,
6909            parent_path = excluded.parent_path,
6910            extension = excluded.extension,
6911            language = excluded.language,
6912            size_bytes = excluded.size_bytes,
6913            mtime_ns = excluded.mtime_ns,
6914            content_hash = excluded.content_hash,
6915            exists_now = 1,
6916            last_seen_at = CURRENT_TIMESTAMP,
6917            last_indexed_at = CURRENT_TIMESTAMP
6918        ",
6919    )?;
6920    let mut ensure_file_classification = connection.prepare_cached(
6921        "INSERT INTO file_content_classifications(path, classification)
6922         SELECT ?1, 'opaque'
6923          WHERE ?2 = 'file'
6924         ON CONFLICT(path) DO NOTHING",
6925    )?;
6926    let mut select_node_id = connection.prepare_cached("SELECT id FROM nodes WHERE path = ?1")?;
6927    let mut ensure_purpose = connection.prepare_cached(
6928        "
6929        INSERT INTO purposes(node_id, purpose, source, status)
6930        VALUES(?1, NULL, 'missing', 'missing')
6931        ON CONFLICT(node_id) DO NOTHING
6932        ",
6933    )?;
6934    let mut upsert_summary = connection.prepare_cached(
6935        "
6936        INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
6937        VALUES(?1, 'node', '', ?2, CURRENT_TIMESTAMP)
6938        ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
6939            summary = CASE WHEN ?3 THEN excluded.summary ELSE summaries.summary END,
6940            updated_at = CURRENT_TIMESTAMP
6941        ",
6942    )?;
6943    for node in nodes {
6944        let size_bytes = node
6945            .size_bytes
6946            .map(|value| {
6947                i64::try_from(value).map_err(|_source| DbError::GraphCountOverflow {
6948                    field: "nodes.size_bytes",
6949                    value,
6950                })
6951            })
6952            .transpose()?;
6953        let existing = select_existing
6954            .query_row([&node.path], |row| row.get::<_, Option<String>>(0))
6955            .optional()?;
6956        let content_changed = existing.as_ref().is_some_and(|old_hash| {
6957            node.kind == NodeKind::File
6958                && old_hash.is_some()
6959                && node.content_hash.is_some()
6960                && old_hash != &node.content_hash
6961        });
6962        upsert_node.execute(params![
6963            node.path,
6964            node.kind.to_string(),
6965            node.parent_path,
6966            node.extension,
6967            node.language,
6968            size_bytes,
6969            node.mtime_ns,
6970            node.content_hash
6971        ])?;
6972        ensure_file_classification.execute(params![node.path, node.kind.to_string()])?;
6973        let node_id = select_node_id.query_row([&node.path], |row| row.get::<_, i64>(0))?;
6974        ensure_purpose.execute([node_id])?;
6975        upsert_summary.execute(params![
6976            node_id,
6977            generate_node_summary(node),
6978            content_changed
6979        ])?;
6980    }
6981    Ok(())
6982}
6983
6984/// Read one persisted indexed text row.
6985fn file_text_from_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedFileText> {
6986    let byte_count = count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?;
6987    let line_count = count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?;
6988    let text = IndexedFileText {
6989        path: row.get(0)?,
6990        content_hash: row.get(1)?,
6991        byte_count,
6992        line_count,
6993        content: row.get(4)?,
6994    };
6995    validate_indexed_file_text(&text)?;
6996    Ok(text)
6997}
6998
6999/// Verify that persisted byte/line accounting matches authoritative content.
7000fn validate_indexed_file_text(text: &IndexedFileText) -> DbResult<()> {
7001    let actual_bytes = text.content.len();
7002    if text.byte_count != actual_bytes {
7003        return Err(DbError::FileTextMetadataMismatch {
7004            path: text.path.clone(),
7005            field: "byte_count",
7006            recorded: text.byte_count,
7007            actual: actual_bytes,
7008        });
7009    }
7010    let actual_lines = text.content.lines().count();
7011    if text.line_count != actual_lines {
7012        return Err(DbError::FileTextMetadataMismatch {
7013            path: text.path.clone(),
7014            field: "line_count",
7015            recorded: text.line_count,
7016            actual: actual_lines,
7017        });
7018    }
7019    Ok(())
7020}
7021
7022/// Virtual-machine operations between bounded database-read progress checks.
7023const SQLITE_READ_PROGRESS_OPS: i32 = 1_000;
7024
7025/// Deterministic observation of production `SQLite` progress boundaries for downstream tests.
7026#[cfg(feature = "sqlite-progress-test-observer")]
7027pub mod sqlite_progress_test_observer {
7028    use projectatlas_core::IndexWorkStage;
7029    use std::cell::RefCell;
7030
7031    /// One observable event emitted by the production `SQLite` read-progress boundary.
7032    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
7033    pub enum SqliteReadProgressEvent {
7034        /// A repository relation-family query entered after its live-control precheck.
7035        RepositoryRelationFamilyQueryEntered,
7036        /// A repository relation-family query returned from `SQLite`.
7037        RepositoryRelationFamilyQueryExited,
7038        /// `SQLite` invoked the production progress callback.
7039        CallbackEntered {
7040            /// Typed work stage owned by the guarded query.
7041            stage: IndexWorkStage,
7042        },
7043        /// The production progress callback evaluated its control.
7044        CallbackEvaluated {
7045            /// Typed work stage owned by the guarded query.
7046            stage: IndexWorkStage,
7047            /// Whether the callback requested `SQLite` interruption.
7048            interrupted: bool,
7049        },
7050    }
7051
7052    /// Thread-local callback used only while a downstream test owns observation.
7053    type Observer = Box<dyn FnMut(SqliteReadProgressEvent)>;
7054
7055    thread_local! {
7056        /// Observer scoped to the synchronous test thread running the SQLite connection.
7057        static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
7058    }
7059
7060    /// Restores a prior nested observer on every return or unwind path.
7061    struct ObserverGuard {
7062        /// Observer replaced for the current scope.
7063        previous: Option<Observer>,
7064    }
7065
7066    impl Drop for ObserverGuard {
7067        fn drop(&mut self) {
7068            OBSERVER.with(|slot| {
7069                drop(slot.replace(self.previous.take()));
7070            });
7071        }
7072    }
7073
7074    /// Run one operation while observing its synchronous `SQLite` progress events.
7075    pub fn observe_sqlite_read_progress<T>(
7076        observer: impl FnMut(SqliteReadProgressEvent) + 'static,
7077        operation: impl FnOnce() -> T,
7078    ) -> T {
7079        let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
7080        let _guard = ObserverGuard { previous };
7081        operation()
7082    }
7083
7084    /// Notify the current thread-local observer when one is installed.
7085    pub(crate) fn notify(event: SqliteReadProgressEvent) {
7086        OBSERVER.with(|slot| {
7087            if let Some(observer) = slot.borrow_mut().as_mut() {
7088                observer(event);
7089            }
7090        });
7091    }
7092}
7093
7094/// Clears a connection-local `SQLite` progress handler on every exit path.
7095pub(crate) struct SqliteReadProgressGuard<'connection> {
7096    /// Connection whose temporary progress callback is armed.
7097    connection: &'connection Connection,
7098    /// Whether this guard installed a callback that must be removed.
7099    armed: bool,
7100}
7101
7102impl<'connection> SqliteReadProgressGuard<'connection> {
7103    /// Install one cooperative progress callback for the owning work stage.
7104    pub(crate) fn new(
7105        connection: &'connection Connection,
7106        control: Option<&IndexWorkControl>,
7107        stage: IndexWorkStage,
7108    ) -> DbResult<Self> {
7109        let armed = if let Some(control) = control {
7110            control.check(stage)?;
7111            let progress_control = control.clone();
7112            connection.progress_handler(
7113                SQLITE_READ_PROGRESS_OPS,
7114                Some(move || {
7115                    #[cfg(feature = "sqlite-progress-test-observer")]
7116                    sqlite_progress_test_observer::notify(
7117                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEntered {
7118                            stage,
7119                        },
7120                    );
7121                    let interrupted = progress_control.check(stage).is_err();
7122                    #[cfg(feature = "sqlite-progress-test-observer")]
7123                    sqlite_progress_test_observer::notify(
7124                        sqlite_progress_test_observer::SqliteReadProgressEvent::CallbackEvaluated {
7125                            stage,
7126                            interrupted,
7127                        },
7128                    );
7129                    interrupted
7130                }),
7131            )?;
7132            true
7133        } else {
7134            false
7135        };
7136        Ok(Self { connection, armed })
7137    }
7138}
7139
7140impl Drop for SqliteReadProgressGuard<'_> {
7141    fn drop(&mut self) {
7142        if self.armed {
7143            drop(self.connection.progress_handler(0, None::<fn() -> bool>));
7144        }
7145    }
7146}
7147
7148/// Run one read with a temporary cooperative progress handler.
7149pub(crate) fn with_sqlite_read_progress<T>(
7150    connection: &Connection,
7151    control: Option<&IndexWorkControl>,
7152    stage: IndexWorkStage,
7153    operation: impl FnOnce() -> DbResult<T>,
7154) -> DbResult<T> {
7155    let guard = SqliteReadProgressGuard::new(connection, control, stage)?;
7156    let result = operation();
7157    drop(guard);
7158    if result.as_ref().is_err_and(|error| {
7159        matches!(
7160            error,
7161            DbError::Sqlite(sqlite)
7162                if sqlite.sqlite_error_code() == Some(ErrorCode::OperationInterrupted)
7163        )
7164    }) && let Some(control) = control
7165    {
7166        control.check(stage)?;
7167    }
7168    result
7169}
7170
7171/// Run one text-index read through the shared bounded database-read guard.
7172fn with_file_text_progress<T>(
7173    connection: &Connection,
7174    control: Option<&IndexWorkControl>,
7175    operation: impl FnOnce() -> DbResult<T>,
7176) -> DbResult<T> {
7177    with_sqlite_read_progress(connection, control, IndexWorkStage::TextIndex, operation)
7178}
7179
7180/// Enforce the single safe-token contract used by FTS candidate lookup.
7181fn validate_file_text_fts_token(token: &str) -> DbResult<()> {
7182    if token.len() < 3 {
7183        return Err(DbError::FileTextFtsTokenUnsafe {
7184            reason: "token is shorter than three ASCII bytes",
7185        });
7186    }
7187    if !token.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
7188        return Err(DbError::FileTextFtsTokenUnsafe {
7189            reason: "token is not exclusively ASCII alphanumeric",
7190        });
7191    }
7192    Ok(())
7193}
7194
7195/// Normalize only the root/trailing-separator cases of an already-relative scope.
7196fn normalized_file_text_path_prefix(path_prefix: Option<&str>) -> Option<&str> {
7197    path_prefix
7198        .map(|prefix| prefix.trim_end_matches('/'))
7199        .filter(|prefix| !prefix.is_empty() && *prefix != ".")
7200}
7201
7202/// Build one binary-collation range containing exactly a path's descendants.
7203fn file_text_descendant_range(path_prefix: &str) -> (String, String) {
7204    (format!("{path_prefix}/"), format!("{path_prefix}0"))
7205}
7206
7207/// Decode one bounded page of FTS metadata candidates without source text.
7208fn collect_file_text_fts_candidates(
7209    rows: &mut rusqlite::Rows<'_>,
7210    control: Option<&IndexWorkControl>,
7211    candidates: &mut Vec<FileTextFtsCandidate>,
7212) -> DbResult<()> {
7213    while let Some(row) = rows.next()? {
7214        if let Some(control) = control {
7215            control.check(IndexWorkStage::TextIndex)?;
7216        }
7217        let path = row.get::<_, String>(0)?;
7218        let bm25 = row.get::<_, f64>(4)?;
7219        if !bm25.is_finite() {
7220            return Err(DbError::FileTextFtsScoreInvalid { path });
7221        }
7222        candidates.push(FileTextFtsCandidate {
7223            path,
7224            content_hash: row.get(1)?,
7225            byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7226            line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7227            bm25,
7228        });
7229    }
7230    Ok(())
7231}
7232
7233/// Decode persisted file metadata before the potentially large source column.
7234fn file_text_metadata_from_row(row: &rusqlite::Row<'_>) -> DbResult<FileTextMetadata> {
7235    let path = row.get::<_, String>(0)?;
7236    let classification = row
7237        .get::<_, Option<String>>(4)?
7238        .ok_or_else(|| DbError::FileContentClassificationMissing { path: path.clone() })?;
7239    Ok(FileTextMetadata {
7240        path,
7241        content_hash: row.get(1)?,
7242        byte_count: count_to_usize("file_texts.byte_count", row.get::<_, i64>(2)?)?,
7243        line_count: count_to_usize("file_texts.line_count", row.get::<_, i64>(3)?)?,
7244        classification: ContentClassification::from_db(&classification).ok_or(
7245            DbError::InvalidEnum {
7246                field: "file_content_classifications.classification",
7247                value: classification,
7248            },
7249        )?,
7250    })
7251}
7252
7253/// Visit fallback rows with metadata-first admission and cooperative stops.
7254fn visit_file_text_fallback_rows<A, V>(
7255    rows: &mut rusqlite::Rows<'_>,
7256    content_statement: &mut rusqlite::CachedStatement<'_>,
7257    control: Option<&IndexWorkControl>,
7258    admit: &mut A,
7259    visitor: &mut V,
7260) -> DbResult<()>
7261where
7262    A: FnMut(&FileTextMetadata) -> DbResult<FileTextAdmission>,
7263    V: FnMut(IndexedFileText) -> DbResult<bool>,
7264{
7265    while let Some(row) = rows.next()? {
7266        if let Some(control) = control {
7267            control.check(IndexWorkStage::TextIndex)?;
7268        }
7269        let metadata = file_text_metadata_from_row(row)?;
7270        match admit(&metadata)? {
7271            FileTextAdmission::Skip => continue,
7272            FileTextAdmission::Stop => return Ok(()),
7273            FileTextAdmission::Read => {}
7274        }
7275        let content =
7276            content_statement.query_row([&metadata.path], |content_row| content_row.get(0))?;
7277        let text = IndexedFileText {
7278            path: metadata.path,
7279            content_hash: metadata.content_hash,
7280            byte_count: metadata.byte_count,
7281            line_count: metadata.line_count,
7282            content,
7283        };
7284        validate_indexed_file_text(&text)?;
7285        if !visitor(text)? {
7286            return Ok(());
7287        }
7288    }
7289    Ok(())
7290}
7291
7292/// Raw standard node columns retained until typed enum validation succeeds.
7293type IndexedNodeParts = (
7294    String,
7295    String,
7296    Option<String>,
7297    Option<String>,
7298    Option<String>,
7299    Option<u64>,
7300    Option<i64>,
7301    Option<String>,
7302    Option<String>,
7303    String,
7304    String,
7305    Option<String>,
7306);
7307
7308/// Decode the standard node select column order without interpreting enums.
7309fn indexed_node_parts_from_sql_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedNodeParts> {
7310    let kind_value: String = row.get(1)?;
7311    let source_value: String = row.get(9)?;
7312    let status_value: String = row.get(10)?;
7313    Ok((
7314        row.get::<_, String>(0)?,
7315        kind_value,
7316        row.get::<_, Option<String>>(2)?,
7317        row.get::<_, Option<String>>(3)?,
7318        row.get::<_, Option<String>>(4)?,
7319        option_u64_from_sql(row, 5)?,
7320        row.get::<_, Option<i64>>(6)?,
7321        row.get::<_, Option<String>>(7)?,
7322        row.get::<_, Option<String>>(8)?,
7323        source_value,
7324        status_value,
7325        row.get::<_, Option<String>>(11)?,
7326    ))
7327}
7328
7329/// Build an indexed node from the standard node select column order.
7330fn indexed_node_from_sql_row(row: &rusqlite::Row<'_>) -> DbResult<IndexedNode> {
7331    indexed_node_from_parts(indexed_node_parts_from_sql_row(row)?)
7332}
7333
7334/// Count exact variable-width payload and fixed scalar slots for one node row.
7335fn indexed_node_parts_decoded_bytes(row: &IndexedNodeParts) -> DbResult<u64> {
7336    let lengths = [
7337        row.0.len(),
7338        row.1.len(),
7339        row.2.as_ref().map_or(0, String::len),
7340        row.3.as_ref().map_or(0, String::len),
7341        row.4.as_ref().map_or(0, String::len),
7342        row.7.as_ref().map_or(0, String::len),
7343        row.8.as_ref().map_or(0, String::len),
7344        row.9.len(),
7345        row.10.len(),
7346        row.11.as_ref().map_or(0, String::len),
7347    ];
7348    let mut bytes = 16_u64;
7349    for length in lengths {
7350        let length =
7351            u64::try_from(length).map_err(|_source| GraphContractError::InvalidLimits {
7352                reason: "purpose-owner decoded field length overflowed",
7353            })?;
7354        bytes = bytes
7355            .checked_add(length)
7356            .ok_or(GraphContractError::InvalidLimits {
7357                reason: "purpose-owner decoded row size overflowed",
7358            })?;
7359    }
7360    Ok(bytes)
7361}
7362
7363/// Build an indexed node from database row parts.
7364fn indexed_node_from_parts(row: IndexedNodeParts) -> DbResult<IndexedNode> {
7365    let (
7366        path,
7367        kind_value,
7368        parent_path,
7369        extension,
7370        language,
7371        size_bytes,
7372        mtime_ns,
7373        content_hash,
7374        purpose,
7375        source_value,
7376        status_value,
7377        summary,
7378    ) = row;
7379    let kind = NodeKind::from_db(&kind_value).ok_or_else(|| DbError::InvalidEnum {
7380        field: "kind",
7381        value: kind_value,
7382    })?;
7383    let source = parse_source(&source_value)?;
7384    let status = PurposeStatus::from_db(&status_value).ok_or_else(|| DbError::InvalidEnum {
7385        field: "status",
7386        value: status_value,
7387    })?;
7388    Ok(IndexedNode {
7389        node: Node {
7390            path: path.clone(),
7391            kind,
7392            parent_path,
7393            extension,
7394            language,
7395            size_bytes,
7396            mtime_ns,
7397            content_hash,
7398        },
7399        purpose: Purpose {
7400            path,
7401            purpose,
7402            source,
7403            status,
7404        },
7405        summary,
7406    })
7407}
7408
7409/// Split a user query into lowercase terms for SQL ranking.
7410fn normalize_query_terms(query: &str) -> Vec<String> {
7411    query
7412        .split(|character: char| !character.is_alphanumeric())
7413        .filter(|term| !term.is_empty())
7414        .map(str::to_lowercase)
7415        .collect()
7416}
7417
7418/// Normalize the complete query used by exact path and basename admission tiers.
7419fn normalize_exact_ranked_query(query: &str) -> String {
7420    query.trim().replace('\\', "/").to_lowercase()
7421}
7422
7423/// Build the reviewed-purpose admission predicate for normalized query terms.
7424fn reviewed_purpose_match_expression(term_count: usize) -> String {
7425    if term_count == 0 {
7426        return "0".to_string();
7427    }
7428    let matches = std::iter::repeat_n(
7429        "lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\'",
7430        term_count,
7431    )
7432    .collect::<Vec<_>>()
7433    .join(" OR ");
7434    format!(
7435        "CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7436                    AND ({matches}) THEN 1 ELSE 0 END"
7437    )
7438}
7439
7440/// Build the SQL score expression for ranked node lookup.
7441fn ranked_score_expression(term_count: usize) -> String {
7442    if term_count == 0 {
7443        return "1".to_string();
7444    }
7445    (0..term_count)
7446        .map(|_| {
7447            "(CASE WHEN lower(n.path) LIKE ? ESCAPE '\\' THEN 20 ELSE 0 END \
7448             + CASE WHEN p.status = 'approved' AND p.source IN ('agent', 'human') \
7449                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 30 ELSE 0 END \
7450             + CASE WHEN NOT (p.status = 'approved' AND p.source IN ('agent', 'human')) \
7451                          AND lower(COALESCE(p.purpose, '')) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END \
7452             + CASE WHEN lower(COALESCE(s.summary, '')) LIKE ? ESCAPE '\\' THEN 10 ELSE 0 END \
7453             + CASE WHEN lower(COALESCE(symbol_summaries.summary, '')) LIKE ? ESCAPE '\\' THEN 25 ELSE 0 END)"
7454                .to_string()
7455        })
7456        .collect::<Vec<_>>()
7457        .join(" + ")
7458}
7459
7460/// Convert a normalized term into a `SQLite` LIKE pattern.
7461fn sqlite_like_pattern(term: &str) -> String {
7462    format!("%{}%", sqlite_like_escape(term))
7463}
7464
7465/// Build a `SQLite` LIKE descendant pattern for a repository path prefix.
7466fn sqlite_descendant_pattern(path: &str) -> String {
7467    format!("{}/%", sqlite_like_escape(path))
7468}
7469
7470/// Escape user or path text for `SQLite` LIKE patterns with backslash escaping.
7471fn sqlite_like_escape(value: &str) -> String {
7472    value
7473        .replace('\\', "\\\\")
7474        .replace('%', "\\%")
7475        .replace('_', "\\_")
7476}
7477
7478/// Replace the denormalized symbol-name search summary for one file node.
7479fn replace_symbol_search_summary(
7480    connection: &Connection,
7481    node_id: i64,
7482    summary: Option<&str>,
7483) -> DbResult<()> {
7484    if let Some(summary) = summary {
7485        connection
7486            .prepare_cached(
7487                "
7488                INSERT INTO summaries(node_id, summary_level, subject, summary, updated_at)
7489                VALUES(?1, 'search', 'symbols', ?2, CURRENT_TIMESTAMP)
7490                ON CONFLICT(node_id, summary_level, subject) DO UPDATE SET
7491                    summary = excluded.summary,
7492                    updated_at = CURRENT_TIMESTAMP
7493                ",
7494            )?
7495            .execute(params![node_id, summary])?;
7496    } else {
7497        connection
7498            .prepare_cached(
7499                "
7500                DELETE FROM summaries
7501                WHERE node_id = ?1
7502                  AND summary_level = 'search'
7503                  AND subject = 'symbols'
7504                ",
7505            )?
7506            .execute([node_id])?;
7507    }
7508    Ok(())
7509}
7510
7511/// Build a bounded search-only summary from symbol names.
7512fn symbol_search_summary(graph: &SymbolGraph) -> Option<String> {
7513    let mut names = graph
7514        .symbols
7515        .iter()
7516        .filter(|symbol| !matches!(symbol.kind, SymbolKind::Import | SymbolKind::Unknown))
7517        .map(|symbol| symbol.name.trim())
7518        .filter(|name| !name.is_empty())
7519        .map(ToString::to_string)
7520        .collect::<Vec<_>>();
7521    names.sort();
7522    names.dedup();
7523    if names.is_empty() {
7524        return None;
7525    }
7526    let summary = format!("symbols {}", names.join(" "));
7527    Some(truncate_summary_chars(
7528        &summary,
7529        MAX_SYMBOL_SEARCH_SUMMARY_CHARS,
7530    ))
7531}
7532
7533/// Truncate a summary at a valid UTF-8 boundary.
7534fn truncate_summary_chars(value: &str, max_chars: usize) -> String {
7535    if value.chars().count() <= max_chars {
7536        return value.to_string();
7537    }
7538    value.chars().take(max_chars).collect()
7539}
7540
7541/// Parse a stored purpose source value into the domain enum.
7542fn parse_source(value: &str) -> DbResult<PurposeSource> {
7543    let source = match value {
7544        value if value == PurposeSource::Missing.as_str() => PurposeSource::Missing,
7545        value if value == PurposeSource::Imported.as_str() => PurposeSource::Imported,
7546        value if value == PurposeSource::Generated.as_str() => PurposeSource::Generated,
7547        // Older databases could contain `human`; ProjectAtlas now treats
7548        // explicit approval as agent-owned and serializes new writes as `agent`.
7549        value if value == PurposeSource::Agent.as_str() || value == LEGACY_HUMAN_PURPOSE_SOURCE => {
7550            PurposeSource::Agent
7551        }
7552        _ => {
7553            return Err(DbError::InvalidEnum {
7554                field: "source",
7555                value: value.to_string(),
7556            });
7557        }
7558    };
7559    Ok(source)
7560}
7561
7562/// Normalize a bounded host-owned task label used only for curator work identity.
7563fn normalize_purpose_curation_task(task: &str) -> DbResult<String> {
7564    let normalized = task.split_whitespace().collect::<Vec<_>>().join(" ");
7565    if normalized.is_empty() {
7566        return Err(DbError::PurposeCurationTaskInvalid {
7567            reason: "task must not be blank",
7568        });
7569    }
7570    if normalized.len() > MAX_PURPOSE_CURATION_TASK_BYTES {
7571        return Err(DbError::PurposeCurationTaskInvalid {
7572            reason: "task exceeds the UTF-8 byte limit",
7573        });
7574    }
7575    if normalized.chars().any(char::is_control) {
7576        return Err(DbError::PurposeCurationTaskInvalid {
7577            reason: "task contains control characters",
7578        });
7579    }
7580    Ok(normalized)
7581}
7582
7583/// Load one current purpose row inside the caller's read or write snapshot.
7584fn load_current_purpose_state(
7585    connection: &Connection,
7586    path: &str,
7587) -> DbResult<Option<(i64, Purpose)>> {
7588    let row = connection
7589        .prepare_cached(
7590            "
7591            SELECT n.id, p.purpose, p.source, p.status
7592            FROM nodes n
7593            JOIN purposes p ON p.node_id = n.id
7594            WHERE n.exists_now = 1 AND n.path = ?1
7595            ",
7596        )?
7597        .query_row([path], |row| {
7598            Ok((
7599                row.get::<_, i64>(0)?,
7600                row.get::<_, Option<String>>(1)?,
7601                row.get::<_, String>(2)?,
7602                row.get::<_, String>(3)?,
7603            ))
7604        })
7605        .optional()?;
7606    row.map(|(node_id, purpose, source, status)| {
7607        let source = parse_source(&source)?;
7608        let status = PurposeStatus::from_db(&status).ok_or_else(|| DbError::InvalidEnum {
7609            field: "purpose_status",
7610            value: status,
7611        })?;
7612        Ok((
7613            node_id,
7614            Purpose {
7615                path: path.to_string(),
7616                purpose,
7617                source,
7618                status,
7619            },
7620        ))
7621    })
7622    .transpose()
7623}
7624
7625/// Apply one queue item inside the caller's validated writer transaction.
7626fn apply_conditional_purpose(
7627    connection: &Connection,
7628    project: ProjectInstanceId,
7629    generation: IndexGeneration,
7630    request: &PurposeConditionalApplyRequest,
7631) -> DbResult<PurposeConditionalApplyResult> {
7632    let current = load_current_purpose_state(connection, &request.path)?;
7633    let Some((node_id, current_purpose)) = current else {
7634        return Ok(PurposeConditionalApplyResult {
7635            path: request.path.clone(),
7636            state: PurposeConditionalApplyState::PathUnavailable,
7637            current_purpose: None,
7638        });
7639    };
7640    if !matches!(
7641        current_purpose.status,
7642        PurposeStatus::Missing | PurposeStatus::Suggested
7643    ) {
7644        return Ok(PurposeConditionalApplyResult {
7645            path: request.path.clone(),
7646            state: PurposeConditionalApplyState::Accepted,
7647            current_purpose: Some(current_purpose),
7648        });
7649    }
7650    let current_work_key =
7651        purpose_curation_item_work_key(project, generation, &request.task, &request.path);
7652    let current_state_token = purpose_curation_state_token(&current_work_key, &current_purpose);
7653    if current_work_key != request.work_key || current_state_token != request.state_token {
7654        return Ok(PurposeConditionalApplyResult {
7655            path: request.path.clone(),
7656            state: PurposeConditionalApplyState::Stale,
7657            current_purpose: Some(current_purpose),
7658        });
7659    }
7660    let generation_sql =
7661        i64::try_from(generation.get()).map_err(|_source| DbError::GraphCountOverflow {
7662            field: "project_identity.active_generation",
7663            value: generation.get(),
7664        })?;
7665    let changed = connection
7666        .prepare_cached(
7667            "
7668            UPDATE purposes
7669            SET purpose = ?2,
7670                source = ?3,
7671                status = ?4,
7672                updated_at = CURRENT_TIMESTAMP
7673            WHERE node_id = ?1
7674              AND status = ?5
7675              AND source = ?6
7676              AND purpose IS ?7
7677              AND EXISTS (
7678                  SELECT 1
7679                  FROM nodes n
7680                  JOIN project_identity pi ON pi.singleton = 1
7681                  WHERE n.id = ?1
7682                    AND n.exists_now = 1
7683                    AND n.path = ?8
7684                    AND pi.project_instance_id = ?9
7685                    AND pi.active_generation = ?10
7686              )
7687            ",
7688        )?
7689        .execute(params![
7690            node_id,
7691            request.purpose,
7692            PurposeSource::Agent.as_str(),
7693            PurposeStatus::Approved.as_str(),
7694            current_purpose.status.as_str(),
7695            current_purpose.source.as_str(),
7696            current_purpose.purpose,
7697            request.path,
7698            &project.as_bytes()[..],
7699            generation_sql,
7700        ])?;
7701    if changed == 1 {
7702        Ok(PurposeConditionalApplyResult {
7703            path: request.path.clone(),
7704            state: PurposeConditionalApplyState::Applied,
7705            current_purpose: Some(Purpose {
7706                path: request.path.clone(),
7707                purpose: Some(request.purpose.clone()),
7708                source: PurposeSource::Agent,
7709                status: PurposeStatus::Approved,
7710            }),
7711        })
7712    } else {
7713        Ok(PurposeConditionalApplyResult {
7714            path: request.path.clone(),
7715            state: PurposeConditionalApplyState::Stale,
7716            current_purpose: Some(current_purpose),
7717        })
7718    }
7719}
7720
7721/// Construct one candidate with deterministic work and stale-state identities.
7722fn purpose_curation_candidate(
7723    project: ProjectInstanceId,
7724    generation: IndexGeneration,
7725    task: &str,
7726    node: IndexedNode,
7727) -> PurposeCurationCandidate {
7728    let work_key = purpose_curation_item_work_key(project, generation, task, &node.node.path);
7729    let state_token = purpose_curation_state_token(&work_key, &node.purpose);
7730    PurposeCurationCandidate {
7731        node,
7732        work_key,
7733        state_token,
7734    }
7735}
7736
7737/// Derive one stable project/generation/task/path work identity.
7738fn purpose_curation_item_work_key(
7739    project: ProjectInstanceId,
7740    generation: IndexGeneration,
7741    task: &str,
7742    path: &str,
7743) -> String {
7744    digest_fields(
7745        PURPOSE_CURATION_ITEM_KEY_DOMAIN,
7746        &[
7747            &project.as_bytes(),
7748            &generation.get().to_le_bytes(),
7749            task.as_bytes(),
7750            path.as_bytes(),
7751        ],
7752    )
7753}
7754
7755/// Bind conditional apply to the exact unapproved purpose row selected by the queue.
7756fn purpose_curation_state_token(work_key: &str, purpose: &Purpose) -> String {
7757    let purpose_presence = [u8::from(purpose.purpose.is_some())];
7758    digest_fields(
7759        PURPOSE_CURATION_STATE_TOKEN_DOMAIN,
7760        &[
7761            work_key.as_bytes(),
7762            &purpose_presence,
7763            purpose.purpose.as_deref().unwrap_or_default().as_bytes(),
7764            purpose.source.as_str().as_bytes(),
7765            purpose.status.as_str().as_bytes(),
7766        ],
7767    )
7768}
7769
7770/// Derive a deterministic identity for one complete returned candidate set.
7771fn purpose_curation_batch_work_key(
7772    project: ProjectInstanceId,
7773    generation: IndexGeneration,
7774    task: &str,
7775    items: &[PurposeCurationCandidate],
7776) -> String {
7777    let mut hasher = Hasher::new();
7778    digest_field(&mut hasher, PURPOSE_CURATION_BATCH_KEY_DOMAIN.as_bytes());
7779    digest_field(&mut hasher, &project.as_bytes());
7780    digest_field(&mut hasher, &generation.get().to_le_bytes());
7781    digest_field(&mut hasher, task.as_bytes());
7782    for item in items {
7783        digest_field(&mut hasher, item.work_key.as_bytes());
7784        digest_field(&mut hasher, item.state_token.as_bytes());
7785    }
7786    hasher.finalize().to_hex().to_string()
7787}
7788
7789/// Hash length-delimited fields under one stable domain separator.
7790fn digest_fields(domain: &str, fields: &[&[u8]]) -> String {
7791    let mut hasher = Hasher::new();
7792    digest_field(&mut hasher, domain.as_bytes());
7793    for field in fields {
7794        digest_field(&mut hasher, field);
7795    }
7796    hasher.finalize().to_hex().to_string()
7797}
7798
7799/// Append one unambiguous field to a deterministic digest.
7800fn digest_field(hasher: &mut Hasher, value: &[u8]) {
7801    let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
7802    hasher.update(&length.to_le_bytes());
7803    hasher.update(value);
7804}
7805
7806/// Convert an aggregate database count into a platform `usize`.
7807fn count_to_usize(field: &'static str, value: i64) -> DbResult<usize> {
7808    usize::try_from(value).map_err(|source| DbError::InvalidCount {
7809        field,
7810        value,
7811        source,
7812    })
7813}
7814
7815/// Convert a usize to i64 with saturation for database storage.
7816fn usize_to_i64(value: usize) -> i64 {
7817    i64::try_from(value).unwrap_or(i64::MAX)
7818}
7819
7820/// Convert a non-negative i64 to usize for database reads.
7821fn i64_to_usize(value: i64) -> usize {
7822    usize::try_from(value.max(0)).unwrap_or(usize::MAX)
7823}
7824
7825/// Decode an optional nonnegative `SQLite` integer into the public unsigned size type.
7826fn option_u64_from_sql(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<u64>> {
7827    row.get::<_, Option<i64>>(index)?
7828        .map(|value| {
7829            u64::try_from(value).map_err(|source| {
7830                rusqlite::Error::FromSqlConversionFailure(
7831                    index,
7832                    rusqlite::types::Type::Integer,
7833                    Box::new(source),
7834                )
7835            })
7836        })
7837        .transpose()
7838}
7839
7840/// Convert an optional token count into the exact `SQLite` integer range.
7841#[cfg(test)]
7842fn option_usize_to_i64(field: &'static str, value: Option<usize>) -> DbResult<Option<i64>> {
7843    value
7844        .map(|value| {
7845            i64::try_from(value).map_err(|_source| DbError::TelemetryIntegerOverflow { field })
7846        })
7847        .transpose()
7848}
7849
7850/// Build the classified symbol query from static clauses and bound caller values.
7851fn classified_symbols_sql(
7852    file: Option<&str>,
7853    query: Option<&str>,
7854    selection: ContentSelection,
7855    limit: usize,
7856) -> (String, Vec<Value>) {
7857    let mut predicates = Vec::new();
7858    let mut bindings = Vec::new();
7859    if let Some(file) = file {
7860        bindings.push(Value::Text(file.to_string()));
7861        predicates.push(format!("symbol.path = ?{}", bindings.len()));
7862    }
7863    if let Some(query) = query {
7864        bindings.push(Value::Text(like_query(query)));
7865        let placeholder = bindings.len();
7866        let path_predicate = if file.is_some() {
7867            String::new()
7868        } else {
7869            format!(" OR symbol.path LIKE ?{placeholder}")
7870        };
7871        predicates.push(format!(
7872            "(symbol.name LIKE ?{placeholder} OR symbol.signature LIKE ?{placeholder} \
7873             OR symbol.documentation LIKE ?{placeholder}{path_predicate})"
7874        ));
7875    }
7876    match selection {
7877        ContentSelection::UnspecifiedLegacy => {}
7878        ContentSelection::Source => {
7879            bindings.push(Value::Text(
7880                ContentClassification::Source.as_str().to_string(),
7881            ));
7882            predicates.push(format!(
7883                "(classification.classification = ?{} OR classification.path IS NULL)",
7884                bindings.len()
7885            ));
7886        }
7887        ContentSelection::Documentation => {
7888            bindings.push(Value::Text(
7889                ContentClassification::Documentation.as_str().to_string(),
7890            ));
7891            predicates.push(format!(
7892                "(classification.classification = ?{} OR classification.path IS NULL)",
7893                bindings.len()
7894            ));
7895        }
7896        ContentSelection::Both => {
7897            bindings.push(Value::Text(
7898                ContentClassification::Source.as_str().to_string(),
7899            ));
7900            let source = bindings.len();
7901            bindings.push(Value::Text(
7902                ContentClassification::Documentation.as_str().to_string(),
7903            ));
7904            let documentation = bindings.len();
7905            predicates.push(format!(
7906                "(classification.classification IN (?{source}, ?{documentation}) \
7907                 OR classification.path IS NULL)"
7908            ));
7909        }
7910    }
7911    bindings.push(Value::Integer(usize_to_i64(limit.max(1))));
7912    let limit = bindings.len();
7913    let where_clause = if predicates.is_empty() {
7914        String::new()
7915    } else {
7916        format!("WHERE {}", predicates.join(" AND "))
7917    };
7918    let sql = format!(
7919        "SELECT symbol.path, symbol.language, symbol.name, symbol.kind,
7920                symbol.signature, symbol.line_start, symbol.line_end, symbol.parent,
7921                symbol.parser, symbol.detail, symbol.exported, symbol.documentation,
7922                symbol.source_byte_start, symbol.source_byte_end,
7923                symbol.source_column_start, symbol.source_column_end,
7924                classification.classification
7925           FROM symbols AS symbol INDEXED BY idx_symbols_path
7926           LEFT JOIN file_content_classifications AS classification
7927             ON classification.path = symbol.path
7928           {where_clause}
7929          ORDER BY symbol.path, symbol.line_start, symbol.name
7930          LIMIT ?{limit}"
7931    );
7932    (sql, bindings)
7933}
7934
7935/// Validate and narrow one parser-supplied source selector for `SQLite` storage.
7936fn symbol_source_selector_values(symbol: &CodeSymbol) -> DbResult<[Option<i64>; 4]> {
7937    let Some(selector) = symbol.source_selector else {
7938        return Ok([None; 4]);
7939    };
7940    if symbol.line_start == 0
7941        || symbol.line_end < symbol.line_start
7942        || selector.byte_end < selector.byte_start
7943        || (symbol.line_start == symbol.line_end && selector.column_end < selector.column_start)
7944    {
7945        return Err(DbError::SymbolGraphRowShape {
7946            path: symbol.path.clone(),
7947            reason: "symbol source selector range is invalid",
7948        });
7949    }
7950    let narrow = |value| match i64::try_from(value) {
7951        Ok(value) => Ok(Some(value)),
7952        Err(_) => Err(DbError::SymbolGraphRowShape {
7953            path: symbol.path.clone(),
7954            reason: "symbol source selector exceeds the SQLite integer range",
7955        }),
7956    };
7957    Ok([
7958        narrow(selector.byte_start)?,
7959        narrow(selector.byte_end)?,
7960        narrow(selector.column_start)?,
7961        narrow(selector.column_end)?,
7962    ])
7963}
7964
7965/// Decode one all-or-none persisted source selector without coercing corrupt ranges.
7966fn symbol_source_selector_from_row(
7967    row: &rusqlite::Row<'_>,
7968    line_start: usize,
7969    line_end: usize,
7970) -> rusqlite::Result<Option<SymbolSourceSelector>> {
7971    let raw = [
7972        row.get::<_, Option<i64>>(12)?,
7973        row.get::<_, Option<i64>>(13)?,
7974        row.get::<_, Option<i64>>(14)?,
7975        row.get::<_, Option<i64>>(15)?,
7976    ];
7977    let [
7978        Some(byte_start),
7979        Some(byte_end),
7980        Some(column_start),
7981        Some(column_end),
7982    ] = raw
7983    else {
7984        if raw.iter().all(Option::is_none) {
7985            return Ok(None);
7986        }
7987        return Err(invalid_symbol_source_selector(
7988            "symbol source selector columns are only partially populated",
7989        ));
7990    };
7991    if byte_start < 0
7992        || byte_end < byte_start
7993        || column_start < 0
7994        || column_end < 0
7995        || line_start == 0
7996        || line_end < line_start
7997        || (line_start == line_end && column_end < column_start)
7998    {
7999        return Err(invalid_symbol_source_selector(
8000            "symbol source selector range is invalid",
8001        ));
8002    }
8003    Ok(Some(SymbolSourceSelector {
8004        byte_start: symbol_source_selector_usize(byte_start)?,
8005        byte_end: symbol_source_selector_usize(byte_end)?,
8006        column_start: symbol_source_selector_usize(column_start)?,
8007        column_end: symbol_source_selector_usize(column_end)?,
8008    }))
8009}
8010
8011/// Convert one validated nonnegative selector integer without losing overflow detail.
8012fn symbol_source_selector_usize(value: i64) -> rusqlite::Result<usize> {
8013    usize::try_from(value).map_err(|source| {
8014        rusqlite::Error::FromSqlConversionFailure(
8015            12,
8016            rusqlite::types::Type::Integer,
8017            Box::new(source),
8018        )
8019    })
8020}
8021
8022/// Construct one fail-closed `SQLite` conversion error for a malformed selector row.
8023fn invalid_symbol_source_selector(message: &'static str) -> rusqlite::Error {
8024    rusqlite::Error::FromSqlConversionFailure(
8025        12,
8026        rusqlite::types::Type::Integer,
8027        Box::new(std::io::Error::other(message)),
8028    )
8029}
8030
8031/// Decode one persisted symbol row through the shared column contract.
8032fn code_symbol_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeSymbol> {
8033    let line_start = i64_to_usize(row.get::<_, i64>(5)?);
8034    let line_end = i64_to_usize(row.get::<_, i64>(6)?);
8035    Ok(CodeSymbol {
8036        path: row.get(0)?,
8037        language: row.get(1)?,
8038        name: row.get(2)?,
8039        kind: SymbolKind::from_db(row.get_ref(3)?.as_str()?),
8040        signature: row.get(4)?,
8041        line_start,
8042        line_end,
8043        source_selector: symbol_source_selector_from_row(row, line_start, line_end)?,
8044        parent: row.get(7)?,
8045        parser: ParserKind::from_db(row.get_ref(8)?.as_str()?),
8046        detail: row.get(9)?,
8047        exported: row.get::<_, i64>(10)? != 0,
8048        documentation: row.get(11)?,
8049    })
8050}
8051
8052/// Decode one joined symbol/classification row through both owning contracts.
8053fn classified_symbol_from_row(row: &rusqlite::Row<'_>) -> DbResult<ClassifiedSymbol> {
8054    let symbol = code_symbol_from_row(row)?;
8055    let classification = row.get::<_, Option<String>>(16)?.ok_or_else(|| {
8056        DbError::FileContentClassificationMissing {
8057            path: symbol.path.clone(),
8058        }
8059    })?;
8060    Ok(ClassifiedSymbol {
8061        symbol,
8062        classification: content_classification::parse_classification(classification)?,
8063    })
8064}
8065
8066/// Read the exact owned-string byte floor before hydrating one symbol row.
8067fn code_symbol_preflight_bytes(row: &rusqlite::Row<'_>) -> DbResult<u64> {
8068    let string_bytes = row.get::<_, i64>(16)?;
8069    let string_bytes = u64::try_from(string_bytes).map_err(|source| DbError::InvalidCount {
8070        field: "symbol persisted field bytes",
8071        value: string_bytes,
8072        source,
8073    })?;
8074    let row_bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8075        DbError::InvalidCount {
8076            field: "symbol persisted field bytes",
8077            value: i64::MAX,
8078            source,
8079        }
8080    })?;
8081    row_bytes.checked_add(string_bytes).ok_or_else(|| {
8082        GraphContractError::InvalidLimits {
8083            reason: "symbol persisted row bytes overflowed",
8084        }
8085        .into()
8086    })
8087}
8088
8089/// Decode the covering persisted import projection.
8090fn stored_import_relation_from_row(
8091    row: &rusqlite::Row<'_>,
8092) -> rusqlite::Result<StoredImportRelation> {
8093    Ok(StoredImportRelation {
8094        path: row.get(0)?,
8095        source_name: row.get(1)?,
8096        target_name: row.get(2)?,
8097        line: i64_to_usize(row.get::<_, i64>(3)?),
8098    })
8099}
8100
8101/// Count the retained Rust row plus its owned string allocation capacities.
8102fn code_symbol_decoded_bytes(symbol: &CodeSymbol) -> DbResult<u64> {
8103    let capacities = [
8104        symbol.path.capacity(),
8105        symbol.language.as_ref().map_or(0, String::capacity),
8106        symbol.name.capacity(),
8107        symbol.signature.capacity(),
8108        symbol.documentation.as_ref().map_or(0, String::capacity),
8109        symbol.parent.as_ref().map_or(0, String::capacity),
8110        symbol.detail.as_ref().map_or(0, String::capacity),
8111    ];
8112    let mut bytes = u64::try_from(std::mem::size_of::<CodeSymbol>()).map_err(|source| {
8113        DbError::InvalidCount {
8114            field: "symbol decoded field bytes",
8115            value: i64::MAX,
8116            source,
8117        }
8118    })?;
8119    for capacity in capacities {
8120        let capacity = u64::try_from(capacity).map_err(|source| DbError::InvalidCount {
8121            field: "symbol decoded field bytes",
8122            value: i64::MAX,
8123            source,
8124        })?;
8125        bytes = bytes
8126            .checked_add(capacity)
8127            .ok_or(GraphContractError::InvalidLimits {
8128                reason: "symbol decoded row bytes overflowed",
8129            })?;
8130    }
8131    Ok(bytes)
8132}
8133
8134/// Wrap a query string for a SQL LIKE expression.
8135fn like_query(query: &str) -> String {
8136    format!("%{query}%")
8137}
8138
8139/// Build the single set-oriented query used to hydrate exact purpose-work paths.
8140fn load_nodes_by_paths_sql(path_count: usize) -> String {
8141    let placeholders = numbered_placeholders(1, path_count);
8142    format!(
8143        "
8144        SELECT
8145            n.path,
8146            n.kind,
8147            n.parent_path,
8148            n.extension,
8149            n.language,
8150            n.size_bytes,
8151            n.mtime_ns,
8152            n.content_hash,
8153            p.purpose,
8154            p.source,
8155            p.status,
8156            s.summary
8157        FROM nodes n
8158        JOIN purposes p ON p.node_id = n.id
8159        LEFT JOIN summaries s ON s.node_id = n.id
8160            AND s.summary_level = 'node'
8161            AND s.subject = ''
8162        WHERE n.exists_now = 1 AND n.path IN ({placeholders})
8163        ORDER BY n.path
8164        "
8165    )
8166}
8167
8168/// Build numbered SQL placeholders starting at a caller-selected index.
8169fn numbered_placeholders(start: usize, count: usize) -> String {
8170    (start..start + count)
8171        .map(|index| format!("?{index}"))
8172        .collect::<Vec<_>>()
8173        .join(", ")
8174}
8175
8176/// Generate the durable node-level content summary.
8177fn generate_node_summary(node: &Node) -> String {
8178    match node.kind {
8179        NodeKind::Folder => format!("Folder for {}", path_label(&node.path)),
8180        NodeKind::File => file_summary(node),
8181    }
8182}
8183
8184/// Generate a one-line observed file summary from scan metadata.
8185fn file_summary(node: &Node) -> String {
8186    let language = node
8187        .language
8188        .as_deref()
8189        .or(node.extension.as_deref())
8190        .unwrap_or("unknown");
8191    let size = node.size_bytes.map_or_else(
8192        || "unknown size".to_string(),
8193        |bytes| format!("{bytes} bytes"),
8194    );
8195    format!("{language} file, {size}")
8196}
8197
8198/// Return a readable label for a repository-relative path.
8199fn path_label(path: &str) -> String {
8200    if path == "." {
8201        return "repository root".to_string();
8202    }
8203    path.rsplit('/')
8204        .next()
8205        .filter(|value| !value.is_empty())
8206        .unwrap_or(path)
8207        .replace(['-', '_'], " ")
8208}
8209
8210/// Emit a health finding when it has not already been resolved.
8211fn emit_unresolved_finding<F>(
8212    finding: HealthFinding,
8213    resolved_ids: &HashSet<String>,
8214    visitor: &mut F,
8215) -> DbResult<bool>
8216where
8217    F: FnMut(HealthFinding) -> DbResult<bool>,
8218{
8219    if resolved_ids.contains(&finding.id) {
8220        return Ok(true);
8221    }
8222    visitor(finding)
8223}
8224
8225/// Return whether a purpose-status source can match a bounded health query.
8226fn purpose_health_spec_matches_query(spec: PurposeHealthSpec, query: &HealthQuery) -> bool {
8227    health_category_matches_query(spec.category, Severity::Warning, query)
8228}
8229
8230/// Return whether a health category/severity can match a bounded query.
8231fn health_category_matches_query(category: &str, severity: Severity, query: &HealthQuery) -> bool {
8232    query
8233        .category
8234        .as_deref()
8235        .is_none_or(|requested| category.eq_ignore_ascii_case(requested))
8236        && query.severity.is_none_or(|requested| severity == requested)
8237}
8238
8239/// Return purpose health metadata for a stored purpose status.
8240fn purpose_health_spec_for_status(status: &str) -> DbResult<PurposeHealthSpec> {
8241    PURPOSE_HEALTH_SPECS
8242        .iter()
8243        .copied()
8244        .find(|spec| spec.status == status)
8245        .ok_or_else(|| DbError::InvalidEnum {
8246            field: "status",
8247            value: status.to_string(),
8248        })
8249}
8250
8251/// Build the health finding for an approved purpose that still needs agent review.
8252fn agent_review_required_finding(path: String) -> HealthFinding {
8253    HealthFinding {
8254        id: finding_id(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, &path, None),
8255        severity: Severity::Warning,
8256        category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8257        path,
8258        related_path: None,
8259        message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8260        recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
8261    }
8262}
8263
8264/// Build the shared SQL filter for globally ordered purpose lifecycle findings.
8265fn purpose_lifecycle_where_clause(
8266    specs: &[PurposeHealthSpec],
8267    path_prefix: Option<&str>,
8268    resolution_filter: HealthResolutionFilter<'_>,
8269    scope: HealthScope,
8270) -> (String, Vec<Value>) {
8271    let statuses = specs
8272        .iter()
8273        .map(|spec| format!("'{}'", spec.status))
8274        .collect::<Vec<_>>()
8275        .join(", ");
8276    let mut clauses = vec![
8277        "n.exists_now = 1".to_string(),
8278        format!("p.status IN ({statuses})"),
8279    ];
8280    let mut values = Vec::new();
8281
8282    if source_filter_applies_before_queue(scope) {
8283        clauses.push(source_relevant_node_expression("n"));
8284    }
8285    if scope.high_impact_queue() {
8286        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8287    }
8288
8289    let normalized_prefix = path_prefix
8290        .map(normalize_repo_path_prefix)
8291        .filter(|prefix| prefix != ".");
8292    if let Some(prefix) = normalized_prefix {
8293        clauses.push(format!(
8294            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8295            values.len() + 1,
8296            values.len() + 2
8297        ));
8298        values.push(Value::from(prefix.clone()));
8299        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8300    }
8301
8302    match resolution_filter {
8303        HealthResolutionFilter::Explicit(resolved_ids) => {
8304            for spec in specs {
8305                let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8306                if !resolved_paths.is_empty() {
8307                    clauses.push(format!(
8308                        "NOT (p.status = '{}' AND n.path IN ({}))",
8309                        spec.status,
8310                        numbered_placeholders(values.len() + 1, resolved_paths.len())
8311                    ));
8312                    values.extend(resolved_paths.into_iter().map(Value::from));
8313                }
8314            }
8315        }
8316        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(
8317            &purpose_lifecycle_finding_id_expression(specs, "n", "p"),
8318        )),
8319    }
8320
8321    (clauses.join(" AND "), values)
8322}
8323
8324/// Build the shared SQL filter for purpose lifecycle health findings.
8325fn purpose_status_where_clause(
8326    spec: PurposeHealthSpec,
8327    path_prefix: Option<&str>,
8328    resolution_filter: HealthResolutionFilter<'_>,
8329    scope: HealthScope,
8330) -> (String, Vec<Value>) {
8331    let mut clauses = vec!["n.exists_now = 1".to_string(), "p.status = ?1".to_string()];
8332    let mut values = vec![Value::from(spec.status.to_string())];
8333
8334    if source_filter_applies_before_queue(scope) {
8335        clauses.push(source_relevant_node_expression("n"));
8336    }
8337    if scope.high_impact_queue() {
8338        clauses.push(purpose_default_queue_node_expression("n", "p", scope));
8339    }
8340
8341    let normalized_prefix = path_prefix
8342        .map(normalize_repo_path_prefix)
8343        .filter(|prefix| prefix != ".");
8344    if let Some(prefix) = normalized_prefix {
8345        clauses.push(format!(
8346            "(n.path = ?{} OR n.path LIKE ?{} ESCAPE '\\')",
8347            values.len() + 1,
8348            values.len() + 2
8349        ));
8350        values.push(Value::from(prefix.clone()));
8351        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8352    }
8353
8354    match resolution_filter {
8355        HealthResolutionFilter::Explicit(resolved_ids) => {
8356            let resolved_paths = resolved_purpose_paths(resolved_ids, spec.category);
8357            if !resolved_paths.is_empty() {
8358                clauses.push(format!(
8359                    "n.path NOT IN ({})",
8360                    numbered_placeholders(values.len() + 1, resolved_paths.len())
8361                ));
8362                values.extend(resolved_paths.into_iter().map(Value::from));
8363            }
8364        }
8365        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8366            "'{}:' || n.path || ':'",
8367            spec.category
8368        ))),
8369    }
8370
8371    (clauses.join(" AND "), values)
8372}
8373
8374/// Build a structural-health SQL filter over `findings` CTE columns.
8375fn structural_finding_where_clause(
8376    category: &str,
8377    path_prefix: Option<&str>,
8378    resolution_filter: HealthResolutionFilter<'_>,
8379    scope: HealthScope,
8380    first_placeholder: usize,
8381) -> (String, Vec<Value>) {
8382    let mut placeholder = first_placeholder;
8383    let mut clauses = Vec::new();
8384    let mut values = Vec::new();
8385
8386    if source_filter_applies_before_queue(scope) {
8387        clauses.push("source_relevant = 1".to_string());
8388    }
8389    if scope.high_impact_queue() {
8390        clauses.push(purpose_default_queue_finding_expression(scope));
8391    }
8392
8393    let normalized_prefix = path_prefix
8394        .map(normalize_repo_path_prefix)
8395        .filter(|prefix| prefix != ".");
8396    if let Some(prefix) = normalized_prefix {
8397        clauses.push(format!(
8398            "((path = ?{path_exact} OR path LIKE ?{path_descendant} ESCAPE '\\') \
8399              OR (related_path = ?{related_exact} OR related_path LIKE ?{related_descendant} ESCAPE '\\'))",
8400            path_exact = placeholder,
8401            path_descendant = placeholder + 1,
8402            related_exact = placeholder + 2,
8403            related_descendant = placeholder + 3
8404        ));
8405        values.push(Value::from(prefix.clone()));
8406        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8407        values.push(Value::from(prefix.clone()));
8408        values.push(Value::from(sqlite_descendant_pattern(&prefix)));
8409        placeholder += 4;
8410    }
8411
8412    match resolution_filter {
8413        HealthResolutionFilter::Explicit(resolved_ids) => {
8414            let resolved_ids = resolved_ids_for_category(resolved_ids, category);
8415            if !resolved_ids.is_empty() {
8416                clauses.push(format!(
8417                    "('{category}:' || path || ':' || related_path) NOT IN ({})",
8418                    numbered_placeholders(placeholder, resolved_ids.len())
8419                ));
8420                values.extend(resolved_ids.into_iter().map(Value::from));
8421            }
8422        }
8423        HealthResolutionFilter::Stored => clauses.push(stored_resolution_filter_clause(&format!(
8424            "'{category}:' || path || ':' || related_path"
8425        ))),
8426    }
8427
8428    if clauses.is_empty() {
8429        (String::new(), values)
8430    } else {
8431        (format!("WHERE {}", clauses.join(" AND ")), values)
8432    }
8433}
8434
8435/// Build the exact stored finding-id expression for mixed purpose lifecycle rows.
8436fn purpose_lifecycle_finding_id_expression(
8437    specs: &[PurposeHealthSpec],
8438    node_alias: &str,
8439    purpose_alias: &str,
8440) -> String {
8441    let category_cases = specs
8442        .iter()
8443        .map(|spec| format!("WHEN '{}' THEN '{}:'", spec.status, spec.category))
8444        .collect::<Vec<_>>()
8445        .join(" ");
8446    format!(
8447        "(CASE {purpose_alias}.status {category_cases} ELSE '' END || {node_alias}.path || ':')"
8448    )
8449}
8450
8451/// Build an indexed anti-lookup against durable health resolutions.
8452fn stored_resolution_filter_clause(finding_id_expression: &str) -> String {
8453    format!(
8454        "NOT EXISTS (SELECT 1 FROM health_resolutions hr WHERE hr.finding_id = {finding_id_expression})"
8455    )
8456}
8457
8458/// SQL expression for approved purposes that need agent review at the requested scope.
8459fn purpose_review_candidate_expression(node_alias: &str, scope: HealthScope) -> String {
8460    let scope = match scope {
8461        HealthScope::All => HealthScope::PurposeStrict,
8462        other => other,
8463    };
8464    purpose_default_queue_node_expression(node_alias, "p", scope)
8465}
8466
8467/// SQL expression for paths that belong in the default purpose queue.
8468fn purpose_default_queue_node_expression(
8469    node_alias: &str,
8470    purpose_alias: &str,
8471    scope: HealthScope,
8472) -> String {
8473    let asset_clause = if scope.include_assets() {
8474        format!(
8475            " OR ({node_alias}.kind = 'file' AND NOT ({}))",
8476            source_relevant_node_expression(node_alias)
8477        )
8478    } else {
8479        String::new()
8480    };
8481    let source_file_clause = if scope.include_source_files() {
8482        format!(" OR ({node_alias}.kind = 'file' AND COALESCE({node_alias}.language, '') <> '')")
8483    } else {
8484        String::new()
8485    };
8486    let all_file_clause = if scope.include_all_files() {
8487        format!(" OR {node_alias}.kind = 'file'")
8488    } else {
8489        String::new()
8490    };
8491    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8492    format!(
8493        "({node_alias}.kind = 'folder' \
8494          OR ({node_alias}.kind = 'file' \
8495              AND {purpose_alias}.status = 'stale' \
8496              AND {purpose_alias}.source IN ({stale_queue_sources})) \
8497          OR ({node_alias}.kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8498        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8499    )
8500}
8501
8502/// SQL expression for finding CTE columns that belong in the default purpose queue.
8503fn purpose_default_queue_finding_expression(scope: HealthScope) -> String {
8504    let asset_clause = if scope.include_assets() {
8505        " OR (kind = 'file' AND COALESCE(language, '') = '')"
8506    } else {
8507        ""
8508    };
8509    let source_file_clause = if scope.include_source_files() {
8510        " OR (kind = 'file' AND COALESCE(language, '') <> '')"
8511    } else {
8512        ""
8513    };
8514    let all_file_clause = if scope.include_all_files() {
8515        " OR kind = 'file'"
8516    } else {
8517        ""
8518    };
8519    format!(
8520        "(kind = 'folder' OR (kind = 'file' AND {}){source_file_clause}{all_file_clause}{asset_clause})",
8521        high_impact_file_path_expression("lower(path)")
8522    )
8523}
8524
8525/// SQL ORDER BY expression that keeps folder-purpose work ahead of file cleanup.
8526fn purpose_default_queue_order_expression(node_alias: &str, purpose_alias: &str) -> String {
8527    let stale_queue_sources = sql_string_literals(STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES);
8528    format!(
8529        "CASE \
8530            WHEN {node_alias}.kind = 'folder' THEN 0 \
8531            WHEN {node_alias}.kind = 'file' \
8532                AND {purpose_alias}.status = 'stale' \
8533                AND ({purpose_alias}.source IN ({stale_queue_sources}) OR {}) THEN 1 \
8534            WHEN {node_alias}.kind = 'file' AND {} THEN 2 \
8535            ELSE 3 \
8536        END, {node_alias}.path",
8537        high_impact_file_path_expression(&format!("lower({node_alias}.path)")),
8538        high_impact_file_path_expression(&format!("lower({node_alias}.path)"))
8539    )
8540}
8541
8542/// Purpose sources whose stale file purposes stay in the default queue regardless of path impact.
8543const STALE_FILE_PURPOSE_QUEUE_SOURCE_VALUES: &[&str] = &["human", "imported"];
8544
8545/// Return whether `source_only` should run before queue-specific folder/file selection.
8546fn source_filter_applies_before_queue(scope: HealthScope) -> bool {
8547    scope.source_only_filter() && !scope.high_impact_queue()
8548}
8549
8550/// Render trusted static strings as SQL string literals.
8551fn sql_string_literals(values: &[&str]) -> String {
8552    values
8553        .iter()
8554        .map(|value| format!("'{}'", value.replace('\'', "''")))
8555        .collect::<Vec<_>>()
8556        .join(", ")
8557}
8558
8559/// SQL expression mirroring the path-based high-impact file heuristic.
8560fn high_impact_file_path_expression(lower_path: &str) -> String {
8561    let name_matches = HIGH_IMPACT_FILE_NAMES
8562        .iter()
8563        .map(|name| format!("{lower_path} = '{name}' OR {lower_path} LIKE '%/{name}'"))
8564        .collect::<Vec<_>>()
8565        .join(" OR ");
8566    let prefix_matches = HIGH_IMPACT_PATH_PREFIXES
8567        .iter()
8568        .map(|prefix| format!("{lower_path} LIKE '{prefix}%'"))
8569        .collect::<Vec<_>>()
8570        .join(" OR ");
8571    let segment_matches = HIGH_IMPACT_PATH_SEGMENTS
8572        .iter()
8573        .map(|segment| format!("{lower_path} LIKE '%{segment}%'"))
8574        .collect::<Vec<_>>()
8575        .join(" OR ");
8576    format!("({name_matches} OR {prefix_matches} OR {segment_matches})")
8577}
8578
8579/// Return a SQL expression that treats source files and folders with source descendants as source-relevant.
8580fn source_relevant_node_expression(alias: &str) -> String {
8581    format!(
8582        "(({alias}.kind = 'file' AND COALESCE({alias}.language, '') <> '') \
8583          OR ({alias}.kind = 'folder' AND EXISTS (\
8584              SELECT 1 FROM nodes source_child \
8585              WHERE source_child.exists_now = 1 \
8586                AND source_child.kind = 'file' \
8587                AND COALESCE(source_child.language, '') <> '' \
8588                AND (\
8589                    {alias}.path = '.' \
8590                    OR source_child.parent_path = {alias}.path \
8591                    OR substr(source_child.parent_path, 1, length({alias}.path) + 1) = {alias}.path || '/'\
8592                )\
8593          )))"
8594    )
8595}
8596
8597/// Extract resolved primary paths for lifecycle categories without related paths.
8598fn resolved_purpose_paths(resolved_ids: &[String], category: &str) -> Vec<String> {
8599    let prefix = format!("{category}:");
8600    resolved_ids
8601        .iter()
8602        .filter_map(|id| {
8603            id.strip_prefix(&prefix)
8604                .and_then(|rest| rest.strip_suffix(':'))
8605                .filter(|path| !path.is_empty())
8606                .map(ToOwned::to_owned)
8607        })
8608        .collect()
8609}
8610
8611/// Extract resolved full ids for categories that include related paths.
8612fn resolved_ids_for_category(resolved_ids: &[String], category: &str) -> Vec<String> {
8613    let prefix = format!("{category}:");
8614    resolved_ids
8615        .iter()
8616        .filter(|id| id.starts_with(&prefix))
8617        .cloned()
8618        .collect()
8619}
8620
8621#[cfg(test)]
8622mod tests {
8623    use super::*;
8624    use projectatlas_core::telemetry::{
8625        READ_AVOIDANCE_CONFIDENCE_MODELED, READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED,
8626        READ_AVOIDANCE_CONFIDENCE_OBSERVED, TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
8627        TOKEN_ACCURACY_HEURISTIC, TOKEN_BASELINE_SELECTED_CANDIDATES,
8628        TOKEN_BUCKET_FULL_FILE_COMPRESSION, TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
8629        TOKEN_COMMAND_SEARCH, TOKEN_CONFIDENCE_INFERRED, TOKEN_DEDUPE_SCOPE_EVENT,
8630        usage_from_estimates, usage_from_estimates_with_accounting,
8631        usage_from_estimates_with_context, usage_from_text,
8632    };
8633    use projectatlas_core::{NodeKind, normalized_parent};
8634    use std::error::Error;
8635    use std::fmt::Debug;
8636    use std::fs;
8637    use std::io;
8638    use std::time::Instant;
8639
8640    #[test]
8641    fn unsupported_schema_versions_follow_the_migration_inventory() -> Result<(), Box<dyn Error>> {
8642        for found in schema::PREVIOUS_SCHEMA_VERSION..=schema::SCHEMA_VERSION {
8643            let error = DbError::SchemaVersion {
8644                found,
8645                expected: schema::SCHEMA_VERSION,
8646            };
8647            let expected_migration = if found < schema::SCHEMA_VERSION {
8648                Some((
8649                    found,
8650                    schema::SCHEMA_VERSION,
8651                    u32::try_from(schema::SCHEMA_VERSION - found)?,
8652                ))
8653            } else {
8654                None
8655            };
8656            require_eq(
8657                &error.supported_schema_migration(),
8658                &expected_migration,
8659                "admitted schema migration",
8660            )?;
8661            require_eq(
8662                &error.unsupported_schema_version(),
8663                &None,
8664                "admitted schema version",
8665            )?;
8666        }
8667        for found in [
8668            schema::PREVIOUS_SCHEMA_VERSION - 1,
8669            schema::SCHEMA_VERSION + 1,
8670        ] {
8671            let error = DbError::SchemaVersion {
8672                found,
8673                expected: schema::SCHEMA_VERSION,
8674            };
8675            require_eq(
8676                &error.unsupported_schema_version(),
8677                &Some((found, schema::SCHEMA_VERSION)),
8678                "unsupported schema version",
8679            )?;
8680            require_eq(
8681                &error.supported_schema_migration(),
8682                &None,
8683                "unsupported schema migration",
8684            )?;
8685        }
8686        require_eq(
8687            &DbError::SchemaVersionMissing.unsupported_schema_version(),
8688            &None,
8689            "unrelated database error",
8690        )?;
8691        require_eq(
8692            &DbError::SchemaVersionMissing.supported_schema_migration(),
8693            &None,
8694            "unrelated migration error",
8695        )?;
8696        Ok(())
8697    }
8698
8699    #[test]
8700    fn stores_nodes_and_overview() -> Result<(), Box<dyn Error>> {
8701        let mut store = AtlasStore::in_memory()?;
8702        let node = Node {
8703            path: "src/main.rs".to_string(),
8704            kind: NodeKind::File,
8705            parent_path: normalized_parent("src/main.rs"),
8706            extension: Some(".rs".to_string()),
8707            language: Some("rust".to_string()),
8708            size_bytes: Some(12),
8709            mtime_ns: Some(10),
8710            content_hash: Some("abc".to_string()),
8711        };
8712        store.replace_scan(&[node])?;
8713        let overview = store.overview()?;
8714        require_eq(&overview.files, &1, "file count")?;
8715        require_eq(&overview.missing_purposes, &1, "missing purpose count")?;
8716        let nodes = store.load_nodes()?;
8717        require_eq(
8718            &nodes[0].purpose.purpose,
8719            &None,
8720            "purpose remains separate from summary",
8721        )?;
8722        require_eq(
8723            &nodes[0].summary,
8724            &Some("rust file, 12 bytes".to_string()),
8725            "node-level summary",
8726        )?;
8727        let loaded = store
8728            .load_node_by_path("src/main.rs")?
8729            .ok_or_else(|| io::Error::other("indexed node was not found by path"))?;
8730        require_eq(
8731            &loaded.node.path,
8732            &"src/main.rs".to_string(),
8733            "targeted path lookup",
8734        )?;
8735        require_eq(
8736            &store.load_node_by_path("src/missing.rs")?.is_none(),
8737            &true,
8738            "missing targeted path lookup",
8739        )?;
8740        Ok(())
8741    }
8742
8743    #[test]
8744    fn load_nodes_rejects_negative_size_bytes_without_partial_page() -> Result<(), Box<dyn Error>> {
8745        let mut store = AtlasStore::in_memory()?;
8746        store.replace_scan(&[
8747            test_file_node("src/a.rs", "hash-a"),
8748            test_file_node("src/b.rs", "hash-b"),
8749        ])?;
8750        store.connection.execute(
8751            "UPDATE nodes SET size_bytes = -1 WHERE path = 'src/b.rs'",
8752            [],
8753        )?;
8754
8755        let Err(error) = store.load_nodes() else {
8756            return Err(io::Error::other("negative node size returned a partial page").into());
8757        };
8758        require(
8759            matches!(
8760                error,
8761                DbError::Sqlite(rusqlite::Error::FromSqlConversionFailure(
8762                    5,
8763                    rusqlite::types::Type::Integer,
8764                    _
8765                ))
8766            ),
8767            "negative node size returned the wrong conversion error",
8768        )?;
8769        Ok(())
8770    }
8771
8772    #[test]
8773    fn validated_existing_database_paths_are_never_recreated() -> Result<(), Box<dyn Error>> {
8774        let temp = tempfile::tempdir()?;
8775
8776        let current_path = temp.path().join("current.db");
8777        drop(AtlasStore::open(&current_path)?);
8778        let (current, current_location) = schema::preflight(&current_path, None)?;
8779        require_eq(
8780            &current.state,
8781            &SchemaState::Current,
8782            "current preflight state",
8783        )?;
8784        fs::remove_file(&current_path)?;
8785        if Connection::open_with_flags(
8786            &current_path,
8787            writable_open_flags(current.state, current_location.database_exists),
8788        )
8789        .is_ok()
8790        {
8791            return Err(io::Error::other("current database path was recreated").into());
8792        }
8793        require_eq(&current_path.exists(), &false, "current path stays absent")?;
8794
8795        let released_path = temp.path().join("released.db");
8796        let released_root = temp.path().join("released-root");
8797        write_released_schema_eight_compatibility_fixture(&released_path, &released_root)?;
8798        let (released, released_location) = schema::preflight(&released_path, None)?;
8799        require_eq(
8800            &released.state,
8801            &SchemaState::UpgradeRequired,
8802            "released preflight state",
8803        )?;
8804        fs::remove_file(&released_path)?;
8805        if Connection::open_with_flags(
8806            &released_path,
8807            writable_open_flags(released.state, released_location.database_exists),
8808        )
8809        .is_ok()
8810        {
8811            return Err(io::Error::other("released database path was recreated").into());
8812        }
8813        require_eq(
8814            &released_path.exists(),
8815            &false,
8816            "released path stays absent",
8817        )?;
8818
8819        let fresh_path = temp.path().join("fresh.db");
8820        let (fresh, _) = schema::preflight(&fresh_path, None)?;
8821        require_eq(&fresh.state, &SchemaState::Fresh, "fresh preflight state")?;
8822        drop(AtlasStore::open(&fresh_path)?);
8823        require_eq(&fresh_path.is_file(), &true, "fresh path is created")?;
8824        drop(AtlasStore::open_read_only(&fresh_path)?);
8825        Ok(())
8826    }
8827
8828    #[cfg(windows)]
8829    #[test]
8830    fn released_schema_layouts_upgrade_without_losing_local_state() -> Result<(), Box<dyn Error>> {
8831        let layouts: [(&str, fn(&Path, &Path) -> Result<(), Box<dyn Error>>); 2] = [
8832            (
8833                "fresh-v0.3.26",
8834                write_released_schema_eight_compatibility_fixture,
8835            ),
8836            (
8837                "evolved-v0.3.11-to-v0.3.26",
8838                write_evolved_released_schema_eight_compatibility_fixture,
8839            ),
8840        ];
8841        let mut previous_binding = None;
8842        for (label, write_fixture) in layouts {
8843            let binding =
8844                assert_released_schema_upgrade_preserves_local_state(label, write_fixture)?;
8845            if previous_binding
8846                .as_ref()
8847                .is_some_and(|previous| previous == &binding)
8848            {
8849                return Err(io::Error::other(
8850                    "independent released-schema databases shared one project binding",
8851                )
8852                .into());
8853            }
8854            previous_binding = Some(binding);
8855        }
8856        Ok(())
8857    }
8858
8859    /// Verify one released schema layout through migration, reopen, and publication.
8860    #[cfg(windows)]
8861    fn assert_released_schema_upgrade_preserves_local_state(
8862        label: &str,
8863        write_fixture: fn(&Path, &Path) -> Result<(), Box<dyn Error>>,
8864    ) -> Result<CapturedProjectBinding, Box<dyn Error>> {
8865        let temp = tempfile::tempdir()?;
8866        let db_path = temp.path().join("projectatlas.db");
8867        let root = temp.path().join("repository");
8868        write_fixture(&db_path, &root)?;
8869        fs::create_dir_all(&root)?;
8870        let database_before_read = fs::read(&db_path)?;
8871
8872        let Err(read_error) = AtlasStore::open_read_only(&db_path) else {
8873            return Err(io::Error::other("schema-8 read-only open unexpectedly succeeded").into());
8874        };
8875        require_eq(
8876            &matches!(
8877                read_error,
8878                DbError::SchemaVersion {
8879                    found: PREVIOUS_SCHEMA_VERSION,
8880                    expected: SCHEMA_VERSION,
8881                }
8882            ),
8883            &true,
8884            "schema-8 read-only rejection",
8885        )?;
8886        require_eq(
8887            &fs::read(&db_path)?,
8888            &database_before_read,
8889            "read-only rejection leaves database unchanged",
8890        )?;
8891
8892        let store = AtlasStore::open(&db_path)?;
8893        let stored_schema = store.connection.query_row(
8894            "SELECT value FROM metadata WHERE key = ?1",
8895            [SCHEMA_VERSION_KEY],
8896            |row| row.get::<_, String>(0),
8897        )?;
8898        require_eq(
8899            &stored_schema,
8900            &SCHEMA_VERSION.to_string(),
8901            "upgraded schema version",
8902        )?;
8903        let migrated_binding = store.captured_project_binding()?;
8904        drop(store);
8905        schema::verify_current_integrity(&db_path, Some(&normalize_native_path_display(&root)))?;
8906        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
8907        require_eq(
8908            &store.captured_project_binding()?,
8909            &migrated_binding,
8910            &format!("{label} identity survives reopen"),
8911        )?;
8912        require_eq(
8913            &store.project_root()?,
8914            &Some(normalize_native_path_display(&root)),
8915            "upgraded project root",
8916        )?;
8917        let node = store
8918            .load_node_by_path("src/lib.rs")?
8919            .ok_or_else(|| io::Error::other("upgraded source node missing"))?;
8920        require_eq(
8921            &node.node.content_hash,
8922            &Some("hash-legacy".to_string()),
8923            "upgraded source row",
8924        )?;
8925        require_eq(
8926            &node.purpose.purpose,
8927            &Some("Schema compatibility source".to_string()),
8928            "upgraded purpose text",
8929        )?;
8930        require_eq(
8931            &node.purpose.source,
8932            &PurposeSource::Agent,
8933            "upgraded purpose source",
8934        )?;
8935        require_eq(
8936            &node.purpose.status,
8937            &PurposeStatus::Approved,
8938            "upgraded purpose review state",
8939        )?;
8940        require_eq(
8941            &store.resolved_health_ids()?,
8942            &vec!["schema-review".to_string()],
8943            "upgraded authored review",
8944        )?;
8945        let custom_setting = store.connection.query_row(
8946            "SELECT value FROM metadata WHERE key = 'custom_setting'",
8947            [],
8948            |row| row.get::<_, String>(0),
8949        )?;
8950        require_eq(
8951            &custom_setting,
8952            &"preserved".to_string(),
8953            "upgraded compatible metadata",
8954        )?;
8955        let telemetry = store.token_overview(Some("schema-session"))?;
8956        require_eq(&telemetry.calls, &1, "upgraded telemetry call count")?;
8957        require_eq(
8958            &telemetry.estimated_saved,
8959            &80,
8960            "upgraded telemetry savings",
8961        )?;
8962        require_eq(
8963            &store.index_publication()?,
8964            &None,
8965            "untrusted publication metadata invalidated",
8966        )?;
8967        let remaining_publication_keys = store.connection.query_row(
8968            "SELECT COUNT(*) FROM metadata WHERE key IN (?1, ?2, ?3)",
8969            params![
8970                INDEX_PUBLICATION_STATE_KEY,
8971                INDEX_PUBLICATION_FINGERPRINT_KEY,
8972                INDEX_PUBLICATION_GENERATION_KEY,
8973            ],
8974            |row| row.get::<_, i64>(0),
8975        )?;
8976        require_eq(
8977            &remaining_publication_keys,
8978            &0,
8979            "all untrusted publication keys removed",
8980        )?;
8981
8982        {
8983            let mut publication = store.begin_index_publication("schema-9-contract")?;
8984            write_test_projection(&mut publication, "fresh")?;
8985            publication.complete()?;
8986        }
8987        require_test_projection(&store, 1, "fresh")?;
8988        require_eq(
8989            &store
8990                .index_publication()?
8991                .and_then(|publication| publication.contract_fingerprint),
8992            &Some("schema-9-contract".to_string()),
8993            &format!("{label} fresh publication contract"),
8994        )?;
8995        Ok(migrated_binding)
8996    }
8997
8998    #[test]
8999    fn future_schema_rejection_preserves_source_and_authored_rows() -> Result<(), Box<dyn Error>> {
9000        let temp = tempfile::tempdir()?;
9001        let db_path = temp.path().join("projectatlas.db");
9002        let root = temp.path().join("repository");
9003        fs::create_dir(&root)?;
9004        let future_schema = SCHEMA_VERSION + 1;
9005        write_schema_compatibility_fixture(&db_path, &root, future_schema, "future")?;
9006        let database_before = fs::read(&db_path)?;
9007        let Err(open_error) = AtlasStore::open(&db_path) else {
9008            return Err(
9009                io::Error::other("future schema writable open unexpectedly succeeded").into(),
9010            );
9011        };
9012        require_eq(
9013            &matches!(
9014                open_error,
9015                DbError::SchemaVersion {
9016                    found,
9017                    expected: SCHEMA_VERSION,
9018                } if found == future_schema
9019            ),
9020            &true,
9021            "future schema rejection",
9022        )?;
9023        require_eq(
9024            &fs::read(&db_path)?,
9025            &database_before,
9026            "future schema bytes remain unchanged",
9027        )?;
9028        let connection = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
9029        let stored_schema = connection.query_row(
9030            "SELECT value FROM metadata WHERE key = ?1",
9031            [SCHEMA_VERSION_KEY],
9032            |row| row.get::<_, String>(0),
9033        )?;
9034        require_eq(
9035            &stored_schema,
9036            &future_schema.to_string(),
9037            "future schema remains unchanged",
9038        )?;
9039        let stored_root = connection.query_row(
9040            "SELECT value FROM metadata WHERE key = 'project_root'",
9041            [],
9042            |row| row.get::<_, String>(0),
9043        )?;
9044        require_eq(
9045            &stored_root,
9046            &normalize_native_path_display(&root),
9047            "future project root remains unchanged",
9048        )?;
9049        let source_state = connection.query_row(
9050            "
9051            SELECT n.content_hash, p.purpose, p.source, p.status
9052            FROM nodes AS n
9053            JOIN purposes AS p ON p.node_id = n.id
9054            WHERE n.path = 'src/lib.rs'
9055            ",
9056            [],
9057            |row| {
9058                Ok((
9059                    row.get::<_, Option<String>>(0)?,
9060                    row.get::<_, Option<String>>(1)?,
9061                    row.get::<_, String>(2)?,
9062                    row.get::<_, String>(3)?,
9063                ))
9064            },
9065        )?;
9066        require_eq(
9067            &source_state,
9068            &(
9069                Some("hash-future".to_string()),
9070                Some("Schema compatibility source".to_string()),
9071                PurposeSource::Agent.to_string(),
9072                PurposeStatus::Approved.as_str().to_string(),
9073            ),
9074            "future source and purpose rows remain unchanged",
9075        )?;
9076        let review_rationale = connection.query_row(
9077            "SELECT rationale FROM health_resolutions WHERE finding_id = 'schema-review'",
9078            [],
9079            |row| row.get::<_, String>(0),
9080        )?;
9081        require_eq(
9082            &review_rationale,
9083            &"Reviewed schema fixture".to_string(),
9084            "future authored review remains unchanged",
9085        )?;
9086        let telemetry_state = connection.query_row(
9087            "
9088            SELECT COUNT(*), SUM(e.estimated_tokens_saved)
9089            FROM usage_events AS e
9090            JOIN usage_instances AS i USING(instance_row_id)
9091            WHERE i.caller_label = 'schema-session'
9092            ",
9093            [],
9094            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
9095        )?;
9096        require_eq(
9097            &telemetry_state,
9098            &(1, Some(80)),
9099            "future telemetry remains unchanged",
9100        )?;
9101        Ok(())
9102    }
9103
9104    #[test]
9105    fn projection_refresh_cannot_replace_publication_contract() -> Result<(), Box<dyn Error>> {
9106        let mut store = AtlasStore::in_memory()?;
9107        store.begin_index_publication("contract-a")?.complete()?;
9108
9109        store
9110            .begin_index_projection_refresh("contract-a")?
9111            .complete()?;
9112        require_eq(
9113            &store.index_publication()?,
9114            &Some(IndexPublication {
9115                state: IndexPublicationState::Complete,
9116                contract_fingerprint: Some("contract-a".to_string()),
9117                generation: IndexGeneration::new(2),
9118            }),
9119            "matching projection refresh",
9120        )?;
9121
9122        let publication = store.begin_index_projection_refresh("contract-a")?;
9123        set_metadata(
9124            &publication.connection,
9125            INDEX_PUBLICATION_FINGERPRINT_KEY,
9126            "contract-b",
9127        )?;
9128        let Err(mismatch) = publication.complete() else {
9129            return Err(io::Error::other(
9130                "projection refresh replaced the global publication contract",
9131            )
9132            .into());
9133        };
9134        if !matches!(mismatch, DbError::PublicationContractChanged) {
9135            return Err(io::Error::other(format!(
9136                "unexpected projection refresh mismatch: {mismatch}"
9137            ))
9138            .into());
9139        }
9140        require_eq(
9141            &store.index_publication()?,
9142            &Some(IndexPublication {
9143                state: IndexPublicationState::Complete,
9144                contract_fingerprint: Some("contract-a".to_string()),
9145                generation: IndexGeneration::new(2),
9146            }),
9147            "mismatched projection refresh rolls back",
9148        )?;
9149        Ok(())
9150    }
9151
9152    #[test]
9153    fn read_snapshots_expose_only_complete_publications() -> Result<(), Box<dyn Error>> {
9154        let temp = tempfile::tempdir()?;
9155        let db_path = temp.path().join("projectatlas.db");
9156        let independent_db_path = temp.path().join("independent.db");
9157        let mut writer_a = AtlasStore::open(&db_path)?;
9158        require_writable_connection_profile(&writer_a.connection)?;
9159        {
9160            let mut publication = writer_a.begin_index_publication("contract")?;
9161            write_test_projection(&mut publication, "old")?;
9162            publication.complete()?;
9163        }
9164        let mut writer_b = AtlasStore::open(&db_path)?;
9165        let mut independent_writer = AtlasStore::open(&independent_db_path)?;
9166        let old_reader = AtlasStore::open_read_only(&db_path)?;
9167        require_read_connection_profile(&old_reader.connection)?;
9168        if old_reader
9169            .connection
9170            .execute("DELETE FROM metadata", [])
9171            .is_ok()
9172        {
9173            return Err(io::Error::other("read-only connection accepted a mutation").into());
9174        }
9175        require_test_projection(&old_reader, 1, "old")?;
9176
9177        {
9178            let mut publication = writer_a.begin_index_publication("contract")?;
9179            write_test_projection(&mut publication, "new")?;
9180            require_test_projection(&old_reader, 1, "old")?;
9181
9182            let probe_started = std::time::Instant::now();
9183            let Err(probe_contention) = writer_b.probe_index_publication_writer() else {
9184                return Err(io::Error::other(
9185                    "writer availability probe entered an active publication transaction",
9186                )
9187                .into());
9188            };
9189            require_eq(
9190                &matches!(
9191                    probe_contention,
9192                    DbError::Sqlite(ref error)
9193                        if matches!(
9194                            error.sqlite_error_code(),
9195                            Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9196                        )
9197                ),
9198                &true,
9199                "same-database writer availability probe",
9200            )?;
9201            require_eq(
9202                &(probe_started.elapsed() < Duration::from_secs(2)),
9203                &true,
9204                "fail-fast same-database writer availability probe",
9205            )?;
9206
9207            let started = std::time::Instant::now();
9208            let Err(contention) = writer_b.begin_index_publication("contract") else {
9209                return Err(io::Error::other(
9210                    "second writer entered an active publication transaction",
9211                )
9212                .into());
9213            };
9214            require_eq(
9215                &matches!(
9216                    contention,
9217                    DbError::Sqlite(ref error)
9218                        if matches!(
9219                            error.sqlite_error_code(),
9220                            Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
9221                        )
9222                ),
9223                &true,
9224                "same-database writer contention",
9225            )?;
9226            require_eq(
9227                &(started.elapsed() < Duration::from_secs(2)),
9228                &true,
9229                "fail-fast same-database writer acquisition",
9230            )?;
9231            let restored_busy_timeout =
9232                writer_b
9233                    .connection
9234                    .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9235            require_eq(
9236                &u128::try_from(restored_busy_timeout)?,
9237                &SQLITE_BUSY_TIMEOUT.as_millis(),
9238                "ordinary busy timeout after failed publication acquisition",
9239            )?;
9240
9241            independent_writer
9242                .begin_index_publication("independent-contract")?
9243                .complete()?;
9244            require_eq(
9245                &independent_writer
9246                    .index_publication()?
9247                    .ok_or_else(|| io::Error::other("independent publication missing"))?
9248                    .generation,
9249                &IndexGeneration::new(1),
9250                "independent database generation",
9251            )?;
9252
9253            drop(publication);
9254        }
9255        let rolled_back_reader = AtlasStore::open_read_only(&db_path)?;
9256        require_test_projection(&rolled_back_reader, 1, "old")?;
9257        rolled_back_reader.finish_index_read_snapshot()?;
9258
9259        {
9260            let mut publication = writer_a.begin_index_publication("contract")?;
9261            write_test_projection(&mut publication, "new")?;
9262            publication.complete()?;
9263        }
9264        let restored_busy_timeout =
9265            writer_a
9266                .connection
9267                .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0))?;
9268        require_eq(
9269            &u128::try_from(restored_busy_timeout)?,
9270            &SQLITE_BUSY_TIMEOUT.as_millis(),
9271            "ordinary busy timeout after successful publication acquisition",
9272        )?;
9273        require_test_projection(&old_reader, 1, "old")?;
9274        let new_reader = AtlasStore::open_read_only(&db_path)?;
9275        require_test_projection(&new_reader, 2, "new")?;
9276        new_reader.finish_index_read_snapshot()?;
9277        old_reader.finish_index_read_snapshot()?;
9278        Ok(())
9279    }
9280
9281    #[test]
9282    fn authored_write_waits_for_the_existing_writer_before_validation() -> Result<(), Box<dyn Error>>
9283    {
9284        let temp = tempfile::tempdir()?;
9285        let root = temp.path().join("repository");
9286        fs::create_dir_all(&root)?;
9287        let db_path = temp.path().join("projectatlas.db");
9288        let mut store = AtlasStore::open_for_project(&db_path, &root)?;
9289        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9290
9291        let blocking_writer = Connection::open(&db_path)?;
9292        blocking_writer.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
9293        blocking_writer.execute_batch("BEGIN IMMEDIATE")?;
9294        let release = std::thread::spawn(move || {
9295            std::thread::sleep(Duration::from_millis(1_250));
9296            blocking_writer.execute_batch("ROLLBACK")
9297        });
9298
9299        let started = Instant::now();
9300        store.set_purpose(
9301            "src/lib.rs",
9302            "Own the library source.",
9303            PurposeSource::Agent,
9304        )?;
9305        let elapsed = started.elapsed();
9306        release
9307            .join()
9308            .map_err(|_panic| io::Error::other("blocking writer thread panicked"))??;
9309
9310        require_eq(
9311            &(elapsed >= Duration::from_secs(1)),
9312            &true,
9313            "authored write waited for the existing writer",
9314        )?;
9315        require_eq(
9316            &(elapsed < SQLITE_BUSY_TIMEOUT),
9317            &true,
9318            "authored write stayed within the ordinary busy timeout",
9319        )?;
9320        require_eq(
9321            &store
9322                .load_node_by_path("src/lib.rs")?
9323                .ok_or_else(|| io::Error::other("purpose node missing"))?
9324                .purpose
9325                .purpose,
9326            &Some("Own the library source.".to_string()),
9327            "purpose after bounded writer contention",
9328        )
9329    }
9330
9331    #[test]
9332    fn writable_mutators_reject_an_active_read_snapshot() -> Result<(), Box<dyn Error>> {
9333        let temp = tempfile::tempdir()?;
9334        let db_path = temp.path().join("projectatlas.db");
9335        let mut store = AtlasStore::open(&db_path)?;
9336        store.replace_scan(&[test_file_node("src/lib.rs", "initial")])?;
9337        store.begin_index_read_snapshot()?;
9338
9339        let Err(purpose_error) = store.set_purpose(
9340            "src/lib.rs",
9341            "Must not be written through a read snapshot.",
9342            PurposeSource::Agent,
9343        ) else {
9344            return Err(io::Error::other("purpose wrote through a read snapshot").into());
9345        };
9346        require_eq(
9347            &matches!(purpose_error, DbError::IndexReadSnapshotActive),
9348            &true,
9349            "read-snapshot purpose rejection",
9350        )?;
9351
9352        let Err(scan_error) = store.replace_scan(&[]) else {
9353            return Err(io::Error::other("scan wrote through a read snapshot").into());
9354        };
9355        require_eq(
9356            &matches!(scan_error, DbError::IndexReadSnapshotActive),
9357            &true,
9358            "read-snapshot scan rejection",
9359        )?;
9360
9361        store.finish_index_read_snapshot()?;
9362        require_eq(
9363            &store.load_node_by_path("src/lib.rs")?.is_some(),
9364            &true,
9365            "read-snapshot rejected writes preserved indexed state",
9366        )?;
9367        Ok(())
9368    }
9369
9370    #[test]
9371    fn stale_publication_base_is_rejected_before_batch_mutation() -> Result<(), Box<dyn Error>> {
9372        let temp = tempfile::tempdir()?;
9373        let db_path = temp.path().join("projectatlas.db");
9374        let mut writer_a = AtlasStore::open(&db_path)?;
9375        {
9376            let mut publication =
9377                writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)?;
9378            write_test_projection(&mut publication, "base")?;
9379            publication.complete()?;
9380        }
9381        let prepared_base = writer_a
9382            .index_publication()?
9383            .ok_or_else(|| io::Error::other("prepared base publication missing"))?
9384            .generation;
9385
9386        let incomplete_result = {
9387            let mut publication =
9388                writer_a.begin_index_publication_from("contract", prepared_base)?;
9389            publication.begin_scan_replacement()?;
9390            publication.complete()
9391        };
9392        if !matches!(incomplete_result, Err(DbError::ScanReplacementIncomplete)) {
9393            return Err(io::Error::other(
9394                "unfinished scan replacement was allowed to complete publication",
9395            )
9396            .into());
9397        }
9398        require_test_projection(&writer_a, 1, "base")?;
9399
9400        let mut writer_b = AtlasStore::open(&db_path)?;
9401        {
9402            let mut publication = writer_b.begin_index_publication("contract")?;
9403            write_test_projection(&mut publication, "winner")?;
9404            publication.complete()?;
9405        }
9406
9407        let Err(conflict) = writer_a.begin_index_publication_from("contract", prepared_base) else {
9408            return Err(io::Error::other("stale prepared publication was accepted").into());
9409        };
9410        require_eq(
9411            &matches!(
9412                conflict,
9413                DbError::PublicationBaseGenerationChanged { expected, found }
9414                    if expected == IndexGeneration::new(1)
9415                        && found == IndexGeneration::new(2)
9416            ),
9417            &true,
9418            "stale publication base conflict",
9419        )?;
9420        require_test_projection(&writer_a, 2, "winner")?;
9421        require_eq(
9422            &writer_a.load_symbols(Some("src/lib.rs"), None, 10)?.len(),
9423            &1,
9424            "rejected batch symbol row count",
9425        )?;
9426        require_eq(
9427            &writer_a
9428                .load_symbol_relations(Some("src/lib.rs"), None, 10)?
9429                .len(),
9430            &1,
9431            "rejected batch relation row count",
9432        )?;
9433
9434        let Err(zero_conflict) =
9435            writer_a.begin_index_publication_from("contract", IndexGeneration::ZERO)
9436        else {
9437            return Err(io::Error::other("zero base matched an initialized store").into());
9438        };
9439        require_eq(
9440            &matches!(
9441                zero_conflict,
9442                DbError::PublicationBaseGenerationChanged { expected, found }
9443                    if expected == IndexGeneration::ZERO
9444                        && found == IndexGeneration::new(2)
9445            ),
9446            &true,
9447            "zero publication base conflict",
9448        )?;
9449        require_test_projection(&writer_a, 2, "winner")?;
9450        Ok(())
9451    }
9452
9453    #[test]
9454    fn records_token_overview() -> Result<(), Box<dyn Error>> {
9455        let project = tempfile::tempdir()?;
9456        let mut store = AtlasStore::in_memory()?;
9457        store.set_project_root(project.path())?;
9458        let mut session_event = usage_from_estimates(
9459            "session",
9460            "outline",
9461            Some("src/main.rs".to_string()),
9462            None,
9463            100,
9464            20,
9465        );
9466        session_event.estimated_tokens_saved = Some(1);
9467        store.record_usage(&session_event)?;
9468        let mut unknown_event = usage_from_estimates("session", "unknown", None, None, 0, 0);
9469        unknown_event.estimated_tokens_without_projectatlas = None;
9470        unknown_event.estimated_tokens_with_projectatlas = None;
9471        unknown_event.estimated_tokens_saved = None;
9472        store.record_usage(&unknown_event)?;
9473        store.record_usage(&usage_from_estimates(
9474            "other-session",
9475            "outline",
9476            Some("src/lib.rs".to_string()),
9477            None,
9478            200,
9479            50,
9480        ))?;
9481        let overview = store.token_overview(Some("session"))?;
9482        require_eq(&overview.calls, &1, "usage call count")?;
9483        require_eq(&overview.estimated_saved, &80, "saved token count")?;
9484        require_eq(&overview.buckets.len(), &1, "usage bucket count")?;
9485        require_eq(
9486            &overview.buckets[0].accuracy,
9487            &TOKEN_ACCURACY_HEURISTIC.to_string(),
9488            "usage bucket accuracy",
9489        )?;
9490        let all_sessions = store.token_overview(None)?;
9491        require_eq(&all_sessions.calls, &2, "all-session usage call count")?;
9492        require_eq(
9493            &all_sessions.estimated_without_projectatlas,
9494            &300,
9495            "all-session baseline tokens",
9496        )?;
9497        require_eq(
9498            &all_sessions.estimated_with_projectatlas,
9499            &70,
9500            "all-session atlas tokens",
9501        )?;
9502        require_eq(
9503            &all_sessions.estimated_saved,
9504            &230,
9505            "all-session saved tokens",
9506        )?;
9507        require_eq(
9508            &all_sessions.likely_file_reads_avoided,
9509            &0,
9510            "non-search estimate events do not count as avoided file reads",
9511        )?;
9512        require_eq(
9513            &all_sessions.read_avoidance_confidence,
9514            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9515            "non-search estimate read avoidance confidence",
9516        )?;
9517
9518        store.record_usage(&usage_from_text(
9519            "bucketed",
9520            "summary",
9521            Some("src/main.rs".to_string()),
9522            None,
9523            "abcdefghijkl",
9524            "abcd",
9525        ))?;
9526        store.record_usage(&usage_from_estimates(
9527            "bucketed", "folders", None, None, 100, 20,
9528        ))?;
9529        let bucketed = store.token_overview(Some("bucketed"))?;
9530        require_eq(&bucketed.buckets.len(), &2, "bucketed overview count")?;
9531        require_eq(
9532            &bucketed.buckets[0].token_savings_bucket,
9533            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9534            "source compression bucket",
9535        )?;
9536        require_eq(
9537            &bucketed.buckets[1].token_savings_bucket,
9538            &TOKEN_BUCKET_NAVIGATION_AVOIDANCE.to_string(),
9539            "navigation bucket",
9540        )?;
9541        require_eq(
9542            &bucketed.observed_file_read_replacements,
9543            &1,
9544            "observed read replacement count",
9545        )?;
9546        require_eq(
9547            &bucketed.modeled_file_reads_avoided,
9548            &0,
9549            "folder navigation does not count as modeled file-read avoidance",
9550        )?;
9551        require_eq(
9552            &bucketed.likely_file_reads_avoided,
9553            &1,
9554            "observed-only likely file reads avoided",
9555        )?;
9556        require_eq(
9557            &bucketed.read_avoidance_confidence,
9558            &READ_AVOIDANCE_CONFIDENCE_OBSERVED.to_string(),
9559            "observed-only read avoidance confidence",
9560        )?;
9561
9562        store.record_usage(&usage_from_text(
9563            "deduped",
9564            "summary",
9565            Some("src/lib.rs".to_string()),
9566            None,
9567            "abcdabcd",
9568            "ab",
9569        ))?;
9570        store.record_usage(&usage_from_estimates(
9571            "deduped",
9572            TOKEN_COMMAND_SEARCH,
9573            None,
9574            Some("token".to_string()),
9575            400,
9576            40,
9577        ))?;
9578        store.record_usage(&usage_from_estimates(
9579            "deduped",
9580            TOKEN_COMMAND_SEARCH,
9581            None,
9582            Some("token".to_string()),
9583            400,
9584            30,
9585        ))?;
9586        let deduped = store.token_overview(Some("deduped"))?;
9587        require_eq(
9588            &deduped.legacy_gross_estimated_saved,
9589            &731,
9590            "legacy gross saved tokens remains available",
9591        )?;
9592        require_eq(
9593            &deduped.measured_tokens_saved,
9594            &1,
9595            "measured saved tokens remain separate",
9596        )?;
9597        require_eq(
9598            &deduped.gross_modeled_tokens_avoided,
9599            &730,
9600            "gross modeled avoided tokens remains available",
9601        )?;
9602        require_eq(
9603            &deduped.deduped_modeled_tokens_avoided,
9604            &330,
9605            "modeled avoided tokens are deduped by baseline",
9606        )?;
9607        require_eq(
9608            &deduped.tokens_avoided,
9609            &331,
9610            "headline avoided tokens use measured plus deduped modeled",
9611        )?;
9612        require_eq(
9613            &deduped.observed_file_read_replacements,
9614            &1,
9615            "deduped observed read replacements",
9616        )?;
9617        require_eq(
9618            &deduped.modeled_file_reads_avoided,
9619            &2,
9620            "deduped raw search events remain likely file reads avoided",
9621        )?;
9622        require_eq(
9623            &deduped.likely_file_reads_avoided,
9624            &3,
9625            "deduped likely file reads avoided",
9626        )?;
9627        require_eq(
9628            &deduped.read_avoidance_confidence,
9629            &READ_AVOIDANCE_CONFIDENCE_MODELED.to_string(),
9630            "deduped read avoidance confidence",
9631        )?;
9632
9633        store.record_usage(&usage_from_estimates_with_accounting(
9634            "event-scoped",
9635            "folders",
9636            None,
9637            Some("token".to_string()),
9638            400,
9639            40,
9640            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9641            TOKEN_BASELINE_SELECTED_CANDIDATES,
9642            TOKEN_CONFIDENCE_INFERRED,
9643            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9644            TOKEN_BASELINE_SELECTED_CANDIDATES,
9645            TOKEN_DEDUPE_SCOPE_EVENT,
9646        ))?;
9647        store.record_usage(&usage_from_estimates_with_accounting(
9648            "event-scoped",
9649            "folders",
9650            None,
9651            Some("token".to_string()),
9652            400,
9653            30,
9654            TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9655            TOKEN_BASELINE_SELECTED_CANDIDATES,
9656            TOKEN_CONFIDENCE_INFERRED,
9657            TOKEN_ACCOUNTING_MODELED_AVOIDANCE,
9658            TOKEN_BASELINE_SELECTED_CANDIDATES,
9659            TOKEN_DEDUPE_SCOPE_EVENT,
9660        ))?;
9661        let event_scoped = store.token_overview(Some("event-scoped"))?;
9662        require_eq(
9663            &event_scoped.gross_modeled_tokens_avoided,
9664            &730,
9665            "event-scoped gross modeled avoided tokens",
9666        )?;
9667        require_eq(
9668            &event_scoped.deduped_modeled_tokens_avoided,
9669            &730,
9670            "event-scoped modeled events are not collapsed",
9671        )?;
9672        require_eq(
9673            &event_scoped.repeated_baselines_deduped,
9674            &0,
9675            "event-scoped modeled events do not count as deduped repeats",
9676        )?;
9677        require_eq(
9678            &event_scoped.likely_file_reads_avoided,
9679            &0,
9680            "folder navigation events do not count as likely file reads avoided",
9681        )?;
9682        require_eq(
9683            &event_scoped.read_avoidance_confidence,
9684            &READ_AVOIDANCE_CONFIDENCE_NOT_RECORDED.to_string(),
9685            "folder navigation read avoidance confidence",
9686        )?;
9687
9688        let mut negative_event = usage_from_estimates("negative", "outline", None, None, 20, 50);
9689        negative_event.estimated_tokens_saved = Some(999);
9690        store.record_usage(&negative_event)?;
9691        let negative = store.token_overview(Some("negative"))?;
9692        require_eq(&negative.calls, &1, "negative session call count")?;
9693        require_eq(
9694            &negative.estimated_saved,
9695            &-30,
9696            "negative session recomputed delta",
9697        )?;
9698        require_eq(
9699            &negative.savings_rate,
9700            &Some(-1.5),
9701            "negative session savings rate",
9702        )?;
9703
9704        let mut zero_event = usage_from_estimates("zero-baseline", "outline", None, None, 0, 12);
9705        zero_event.estimated_tokens_saved = Some(999);
9706        store.record_usage(&zero_event)?;
9707        let zero_baseline = store.token_overview(Some("zero-baseline"))?;
9708        require_eq(&zero_baseline.calls, &1, "zero baseline call count")?;
9709        require_eq(
9710            &zero_baseline.estimated_saved,
9711            &-12,
9712            "zero baseline recomputed delta",
9713        )?;
9714        require_eq(
9715            &zero_baseline.savings_rate,
9716            &None,
9717            "zero baseline savings rate",
9718        )?;
9719
9720        let large_project = tempfile::tempdir()?;
9721        let mut large_store = AtlasStore::in_memory()?;
9722        large_store.set_project_root(large_project.path())?;
9723        let maximum_estimate = usize::try_from(i64::MAX)?;
9724        large_store.record_usage(&usage_from_estimates(
9725            "large-primary",
9726            "large",
9727            None,
9728            None,
9729            maximum_estimate,
9730            0,
9731        ))?;
9732        let Err(overflow) = large_store.record_usage(&usage_from_estimates(
9733            "large-rejected",
9734            "large",
9735            None,
9736            None,
9737            maximum_estimate,
9738            0,
9739        )) else {
9740            return Err(io::Error::other("overflowing telemetry aggregate was committed").into());
9741        };
9742        require_eq(
9743            &matches!(overflow, DbError::TelemetryIntegerOverflow { .. }),
9744            &true,
9745            "overflowing telemetry aggregate error",
9746        )?;
9747        let large = large_store.token_overview(Some("large-primary"))?;
9748        require_eq(
9749            &large.estimated_saved,
9750            &isize::MAX,
9751            "largest accepted aggregate narrows at the public boundary",
9752        )?;
9753        require_eq(
9754            &large_store.token_overview(Some("large-rejected"))?.calls,
9755            &0,
9756            "rejected aggregate left no partial report",
9757        )?;
9758        Ok(())
9759    }
9760
9761    #[test]
9762    fn direct_library_usage_rotates_at_baseline_capacity_without_reopening_the_old_scope()
9763    -> Result<(), Box<dyn Error>> {
9764        let project = tempfile::tempdir()?;
9765        let mut store = AtlasStore::in_memory()?;
9766        store.set_project_root(project.path())?;
9767        let policy = TelemetryRetentionPolicy::default();
9768        let label = "bounded-library-label";
9769
9770        for index in 0..policy.max_baselines_per_instance {
9771            store.record_usage(&usage_from_estimates(
9772                label,
9773                "search",
9774                None,
9775                Some(format!("query-{index}")),
9776                100,
9777                20,
9778            ))?;
9779        }
9780        let old_instance = store
9781            .library_usage_instances
9782            .borrow()
9783            .get(label)
9784            .copied()
9785            .flatten()
9786            .ok_or_else(|| io::Error::other("library instance was not retained"))?;
9787
9788        let boundary_event = usage_from_estimates(
9789            label,
9790            "search",
9791            None,
9792            Some("capacity-boundary".to_string()),
9793            100,
9794            20,
9795        );
9796        store.record_usage(&boundary_event)?;
9797        let replacement = store
9798            .library_usage_instances
9799            .borrow()
9800            .get(label)
9801            .copied()
9802            .flatten()
9803            .ok_or_else(|| io::Error::other("replacement library instance was not retained"))?;
9804        require_eq(
9805            &(replacement != old_instance),
9806            &true,
9807            "capacity rotation created a new internal instance",
9808        )?;
9809        require_eq(
9810            &store.connection.query_row(
9811                "SELECT COUNT(*) FROM usage_instances WHERE state = 'sealed'",
9812                [],
9813                |row| row.get::<_, i64>(0),
9814            )?,
9815            &1,
9816            "sealed predecessor instances",
9817        )?;
9818        require_eq(
9819            &store.connection.query_row(
9820                "SELECT COUNT(*) FROM usage_instances WHERE state = 'active'",
9821                [],
9822                |row| row.get::<_, i64>(0),
9823            )?,
9824            &1,
9825            "active replacement instances",
9826        )?;
9827        require_eq(
9828            &store.connection.query_row(
9829                "SELECT COUNT(*) FROM usage_instance_baselines",
9830                [],
9831                |row| row.get::<_, i64>(0),
9832            )?,
9833            &1,
9834            "only replacement baseline witnesses remain",
9835        )?;
9836        require_eq(
9837            &store.token_overview(Some(label))?.calls,
9838            &(policy.max_baselines_per_instance + 1),
9839            "caller-label report spans both bounded instances",
9840        )?;
9841        require_eq(
9842            &store.token_overview(None)?.estimated_saved,
9843            &(isize::try_from(policy.max_baselines_per_instance + 1)? * 80),
9844            "global totals remain exact across rotation",
9845        )?;
9846        require_eq(
9847            &matches!(
9848                store.record_usage_for_instance(
9849                    old_instance,
9850                    UsageInstanceOwner::LibraryHandle,
9851                    &boundary_event,
9852                    false,
9853                ),
9854                Err(DbError::TelemetryInstanceInactive)
9855            ),
9856            &true,
9857            "sealed predecessor cannot be reopened",
9858        )?;
9859        Ok(())
9860    }
9861
9862    #[test]
9863    fn rejected_library_labels_do_not_consume_the_bounded_identity_map()
9864    -> Result<(), Box<dyn Error>> {
9865        let project = tempfile::tempdir()?;
9866        let mut store = AtlasStore::in_memory()?;
9867        store.set_project_root(project.path())?;
9868        let maximum = TelemetryRetentionPolicy::default().max_label_bytes;
9869
9870        for extra in 1..=4 {
9871            let event =
9872                usage_from_estimates(&"x".repeat(maximum + extra), "summary", None, None, 100, 20);
9873            require_eq(
9874                &matches!(
9875                    store.record_usage(&event),
9876                    Err(DbError::TelemetryFieldTooLarge {
9877                        field: "session_id",
9878                        ..
9879                    })
9880                ),
9881                &true,
9882                "oversized caller label rejection",
9883            )?;
9884        }
9885        require_eq(
9886            &store.library_usage_instances.borrow().len(),
9887            &0,
9888            "rejected labels retained no identity-map entries",
9889        )?;
9890        Ok(())
9891    }
9892
9893    #[test]
9894    fn token_trends_group_usage_by_period_and_bucket() -> Result<(), Box<dyn Error>> {
9895        let project = tempfile::tempdir()?;
9896        let mut store = AtlasStore::in_memory()?;
9897        store.set_project_root(project.path())?;
9898        for (session, bucket, baseline_kind, confidence, without, with) in [
9899            (
9900                "session",
9901                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9902                "selected_candidates",
9903                "inferred",
9904                100_usize,
9905                25_usize,
9906            ),
9907            (
9908                "session",
9909                TOKEN_BUCKET_FULL_FILE_COMPRESSION,
9910                "full_file",
9911                "observed",
9912                50_usize,
9913                10_usize,
9914            ),
9915            (
9916                "session",
9917                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9918                "selected_candidates",
9919                "inferred",
9920                80_usize,
9921                20_usize,
9922            ),
9923            (
9924                "other",
9925                TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
9926                "selected_candidates",
9927                "inferred",
9928                999_usize,
9929                1_usize,
9930            ),
9931        ] {
9932            store.record_usage(&usage_from_estimates_with_context(
9933                session,
9934                "trend",
9935                None,
9936                None,
9937                without,
9938                with,
9939                bucket,
9940                baseline_kind,
9941                confidence,
9942            ))?;
9943        }
9944
9945        let trends = store.token_trends(Some("session"), TokenTrendWindow::Day)?;
9946        require_eq(&trends.periods.len(), &1, "current daily period")?;
9947        require_eq(&trends.periods[0].calls, &3, "session trend call count")?;
9948        require_eq(
9949            &trends.periods[0].estimated_saved,
9950            &175,
9951            "session trend saved tokens",
9952        )?;
9953        require_eq(
9954            &trends.periods[0].buckets.len(),
9955            &2,
9956            "trend preserves evidence buckets",
9957        )?;
9958        require_eq(
9959            &trends.periods[0].buckets[0].token_savings_bucket,
9960            &TOKEN_BUCKET_FULL_FILE_COMPRESSION.to_string(),
9961            "full-file bucket remains visible",
9962        )?;
9963        require_eq(
9964            &trends.periods[0].buckets[0].confidence,
9965            &"observed".to_string(),
9966            "bucket confidence remains visible",
9967        )?;
9968        let all_labels = store.token_trends(None, TokenTrendWindow::Day)?;
9969        require_eq(&all_labels.periods.len(), &1, "all-label daily period")?;
9970        require_eq(&all_labels.periods[0].calls, &4, "all-label trend calls")?;
9971        Ok(())
9972    }
9973
9974    #[test]
9975    fn unsupported_legacy_schema_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
9976        let temp = tempfile::tempdir()?;
9977        let db_path = temp.path().join("legacy.db");
9978        {
9979            let connection = Connection::open(&db_path)?;
9980            connection.execute_batch(
9981                "
9982                CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
9983                INSERT INTO metadata(key, value) VALUES('schema_version', '7');
9984                CREATE TABLE usage_events (
9985                    id INTEGER PRIMARY KEY,
9986                    session_id TEXT NOT NULL,
9987                    command TEXT NOT NULL,
9988                    path TEXT,
9989                    query TEXT,
9990                    estimated_tokens_without_projectatlas INTEGER,
9991                    estimated_tokens_with_projectatlas INTEGER,
9992                    estimated_tokens_saved INTEGER,
9993                    token_savings_bucket TEXT NOT NULL DEFAULT 'navigation_avoidance',
9994                    provider TEXT NOT NULL DEFAULT 'heuristic',
9995                    model TEXT NOT NULL DEFAULT 'unknown',
9996                    tokenizer_backend TEXT NOT NULL DEFAULT 'chars_div_4',
9997                    accuracy TEXT NOT NULL DEFAULT 'heuristic_estimate',
9998                    baseline_kind TEXT NOT NULL DEFAULT 'selected_candidates',
9999                    confidence TEXT NOT NULL DEFAULT 'inferred',
10000                    calculation_trace TEXT NOT NULL DEFAULT 'heuristic=ceil(chars_or_bytes/4)'
10001                );
10002                INSERT INTO usage_events(
10003                    session_id,
10004                    command,
10005                    estimated_tokens_without_projectatlas,
10006                    estimated_tokens_with_projectatlas,
10007                    estimated_tokens_saved
10008                )
10009                VALUES('legacy-session', 'legacy', 100, 20, 80);
10010                ",
10011            )?;
10012        }
10013
10014        let database_before = fs::read(&db_path)?;
10015        let Err(open_error) = AtlasStore::open(&db_path) else {
10016            return Err(io::Error::other("unsupported schema unexpectedly opened").into());
10017        };
10018        require_eq(
10019            &matches!(
10020                open_error,
10021                DbError::SchemaVersion {
10022                    found: 7,
10023                    expected: SCHEMA_VERSION,
10024                }
10025            ),
10026            &true,
10027            "unsupported schema rejection",
10028        )?;
10029        require_eq(
10030            &fs::read(&db_path)?,
10031            &database_before,
10032            "unsupported schema bytes remain unchanged",
10033        )?;
10034        Ok(())
10035    }
10036
10037    #[test]
10038    fn set_project_root_requires_existing_root_and_preserves_binding() -> Result<(), Box<dyn Error>>
10039    {
10040        let temp = tempfile::tempdir()?;
10041        let mut store = AtlasStore::in_memory()?;
10042        let missing = temp.path().join("missing-root");
10043        let Err(missing_error) = store.set_project_root(&missing) else {
10044            return Err(io::Error::other("missing project root was accepted").into());
10045        };
10046        require(
10047            matches!(missing_error, DbError::ProjectRootIdentity(_)),
10048            "missing project root returned the wrong error",
10049        )?;
10050        require_eq(
10051            &store.project_root()?,
10052            &None,
10053            "missing-root rejection changed metadata",
10054        )?;
10055        require_eq(
10056            &store.project_root_identity()?,
10057            &None,
10058            "missing-root rejection changed native identity",
10059        )?;
10060        require_eq(
10061            &store.project_instance_id()?,
10062            &None,
10063            "missing-root rejection changed project identity",
10064        )?;
10065
10066        let root = temp.path().join("workspace").join("example");
10067        fs::create_dir_all(&root)?;
10068        store.set_project_root(&root)?;
10069        require_eq(
10070            &store.project_root()?,
10071            &Some(normalize_native_path_display(&root)),
10072            "project root metadata",
10073        )?;
10074
10075        #[cfg(windows)]
10076        let extended = PathBuf::from(format!(r"\\?\{}", root.display()));
10077        #[cfg(windows)]
10078        store.set_project_root(&extended)?;
10079        #[cfg(windows)]
10080        require_eq(
10081            &store.project_root()?,
10082            &Some(normalize_native_path_display(&root)),
10083            "windows extended project root metadata",
10084        )?;
10085        let other = temp.path().join("other");
10086        fs::create_dir(&other)?;
10087        let Err(rebind_error) = store.set_project_root(&other) else {
10088            return Err(io::Error::other("project identity was rebound implicitly").into());
10089        };
10090        require_eq(
10091            &matches!(rebind_error, DbError::ProjectRootMismatch { .. }),
10092            &true,
10093            "project identity rebind rejection",
10094        )?;
10095        Ok(())
10096    }
10097
10098    #[test]
10099    fn ordinary_root_admission_rejects_missing_and_regular_paths_without_mutation()
10100    -> Result<(), Box<dyn Error>> {
10101        let temp = tempfile::tempdir()?;
10102        let root = temp.path().join("root");
10103        let missing = temp.path().join("missing-root");
10104        let regular = temp.path().join("regular-root");
10105        fs::create_dir(&root)?;
10106        fs::write(&regular, b"not a directory")?;
10107        let database = temp.path().join("projectatlas.db");
10108        drop(AtlasStore::open_for_project(&database, &root)?);
10109
10110        for (label, candidate) in [
10111            ("missing", missing.as_path()),
10112            ("regular", regular.as_path()),
10113        ] {
10114            let before = fs::read(&database)?;
10115            if AtlasStore::open_for_project(&database, candidate).is_ok() {
10116                return Err(
10117                    io::Error::other(format!("ordinary {label} root admission succeeded")).into(),
10118                );
10119            }
10120            if fs::read(&database)? != before {
10121                return Err(io::Error::other(format!(
10122                    "ordinary {label} root admission mutated the database"
10123                ))
10124                .into());
10125            }
10126            if verify_project_database(&database, candidate).is_ok() {
10127                return Err(
10128                    io::Error::other(format!("verify admitted ordinary {label} root")).into(),
10129                );
10130            }
10131            if fs::read(&database)? != before {
10132                return Err(io::Error::other(format!(
10133                    "verify of ordinary {label} root mutated the database"
10134                ))
10135                .into());
10136            }
10137        }
10138        Ok(())
10139    }
10140
10141    #[test]
10142    fn malformed_project_root_identity_is_refused_without_mutation() -> Result<(), Box<dyn Error>> {
10143        let temp = tempfile::tempdir()?;
10144        let root = temp.path().join("root");
10145        fs::create_dir(&root)?;
10146        let database = temp.path().join("projectatlas.db");
10147        drop(AtlasStore::open_for_project(&database, &root)?);
10148        let connection = Connection::open(&database)?;
10149        connection.execute(
10150            "UPDATE project_root_identity SET root = ?1 WHERE singleton = 1",
10151            [vec![1_u8, 1_u8, b'x']],
10152        )?;
10153        drop(connection);
10154        let before = fs::read(&database)?;
10155
10156        if AtlasStore::open(&database).is_ok()
10157            || AtlasStore::open_for_project(&database, &root).is_ok()
10158            || verify_project_database(&database, &root).is_ok()
10159        {
10160            return Err(io::Error::other("malformed project-root identity was admitted").into());
10161        }
10162        if fs::read(&database)? != before {
10163            return Err(io::Error::other(
10164                "malformed project-root identity refusal mutated the database",
10165            )
10166            .into());
10167        }
10168        Ok(())
10169    }
10170
10171    #[test]
10172    fn read_project_root_read_only_does_not_change_database_bytes() -> Result<(), Box<dyn Error>> {
10173        let temp = tempfile::tempdir()?;
10174        let root = temp.path().join("repo with spaces");
10175        let atlas_dir = root.join(".projectatlas");
10176        fs::create_dir_all(&atlas_dir)?;
10177        let db_path = atlas_dir.join("projectatlas.db");
10178        {
10179            let mut store = AtlasStore::open(&db_path)?;
10180            store.set_project_root(&root)?;
10181            store
10182                .connection
10183                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
10184        }
10185        let wal_path = db_path.with_extension("db-wal");
10186        let shm_path = db_path.with_extension("db-shm");
10187        for sidecar_path in [&wal_path, &shm_path] {
10188            match fs::remove_file(sidecar_path) {
10189                Ok(()) => {}
10190                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10191                Err(error) => return Err(error.into()),
10192            }
10193        }
10194        let database_before = fs::read(&db_path)?;
10195
10196        require_eq(
10197            &read_project_root_read_only(&db_path)?,
10198            &Some(normalize_native_path_display(&root)),
10199            "read-only project root",
10200        )?;
10201        require_eq(
10202            &read_legacy_project_root_candidate_read_only(&db_path)?,
10203            &None,
10204            "current schema has no legacy recovery candidate",
10205        )?;
10206        require_eq(
10207            &fs::read(&db_path)?,
10208            &database_before,
10209            "read-only project-root database bytes",
10210        )?;
10211        Ok(())
10212    }
10213
10214    #[test]
10215    fn read_project_root_read_only_observes_active_wal_state() -> Result<(), Box<dyn Error>> {
10216        let temp = tempfile::tempdir()?;
10217        let root = temp.path().join("repo-Δ");
10218        let atlas_dir = root.join(".projectatlas");
10219        fs::create_dir_all(&atlas_dir)?;
10220        let db_path = atlas_dir.join("projectatlas.db");
10221        let mut store = AtlasStore::open(&db_path)?;
10222        store
10223            .connection
10224            .execute_batch("PRAGMA wal_autocheckpoint = 0")?;
10225        store.set_project_root(&root)?;
10226
10227        require_eq(
10228            &sqlite_sidecar_path(&db_path, "-wal").exists(),
10229            &true,
10230            "active WAL exists",
10231        )?;
10232        require_eq(
10233            &read_project_root_read_only(&db_path)?,
10234            &Some(normalize_native_path_display(&root)),
10235            "WAL-aware read-only project root",
10236        )?;
10237        Ok(())
10238    }
10239
10240    #[test]
10241    fn validated_write_transaction_revalidates_before_operation_and_rolls_back()
10242    -> Result<(), Box<dyn Error>> {
10243        let temp = tempfile::tempdir()?;
10244        let root = temp.path().join("repository");
10245        fs::create_dir_all(&root)?;
10246        let db_path = temp.path().join("projectatlas.db");
10247        let store = AtlasStore::open_for_project(&db_path, &root)?;
10248        let expected_identity = store
10249            .validated_project_instance_id
10250            .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10251        let sentinel_key = "validated_write_transaction_test";
10252
10253        with_validated_write_transaction(
10254            &store.connection,
10255            store.validated_project_root.as_deref(),
10256            Some(expected_identity),
10257            |connection| set_metadata(connection, sentinel_key, "committed"),
10258        )?;
10259        require_eq(
10260            &store.connection.query_row(
10261                "SELECT value FROM metadata WHERE key = ?1",
10262                [sentinel_key],
10263                |row| row.get::<_, String>(0),
10264            )?,
10265            &"committed".to_string(),
10266            "validated write positive control",
10267        )?;
10268
10269        let Err(operation_error) = with_validated_write_transaction(
10270            &store.connection,
10271            store.validated_project_root.as_deref(),
10272            Some(expected_identity),
10273            |connection| -> DbResult<()> {
10274                set_metadata(connection, sentinel_key, "rolled-back")?;
10275                Err(DbError::TelemetryIdentityUnavailable)
10276            },
10277        ) else {
10278            return Err(io::Error::other("failing validated write unexpectedly committed").into());
10279        };
10280        require_eq(
10281            &matches!(operation_error, DbError::TelemetryIdentityUnavailable),
10282            &true,
10283            "validated write operation error",
10284        )?;
10285        require_eq(
10286            &store.connection.query_row(
10287                "SELECT value FROM metadata WHERE key = ?1",
10288                [sentinel_key],
10289                |row| row.get::<_, String>(0),
10290            )?,
10291            &"committed".to_string(),
10292            "validated write rollback",
10293        )?;
10294
10295        with_validated_native_write_transaction(
10296            &store.connection,
10297            store.validated_project_root_identity.as_ref(),
10298            Some(expected_identity),
10299            |connection| set_metadata(connection, sentinel_key, "native-committed"),
10300        )?;
10301        require_eq(
10302            &store.connection.query_row(
10303                "SELECT value FROM metadata WHERE key = ?1",
10304                [sentinel_key],
10305                |row| row.get::<_, String>(0),
10306            )?,
10307            &"native-committed".to_string(),
10308            "native validated write positive control",
10309        )?;
10310
10311        let future_schema = schema::SCHEMA_VERSION + 1;
10312        let newer_owner = Connection::open(&db_path)?;
10313        newer_owner.execute(
10314            "UPDATE metadata SET value = ?2 WHERE key = ?1",
10315            params![schema::SCHEMA_VERSION_KEY, future_schema.to_string()],
10316        )?;
10317        let operation_invoked = Cell::new(false);
10318        let Err(binding_error) = with_validated_native_write_transaction(
10319            &store.connection,
10320            store.validated_project_root_identity.as_ref(),
10321            Some(expected_identity),
10322            |connection| {
10323                operation_invoked.set(true);
10324                set_metadata(connection, sentinel_key, "wrong-schema")
10325            },
10326        ) else {
10327            return Err(io::Error::other("newer schema accepted a validated write").into());
10328        };
10329        require_eq(
10330            &matches!(
10331                binding_error,
10332                DbError::SchemaVersion { found, expected }
10333                    if found == future_schema && expected == schema::SCHEMA_VERSION
10334            ),
10335            &true,
10336            "validated write schema recheck",
10337        )?;
10338        require_eq(
10339            &operation_invoked.get(),
10340            &false,
10341            "validated write closure invocation",
10342        )?;
10343        require_eq(
10344            &newer_owner.query_row(
10345                "SELECT value FROM metadata WHERE key = ?1",
10346                [sentinel_key],
10347                |row| row.get::<_, String>(0),
10348            )?,
10349            &"native-committed".to_string(),
10350            "newer-schema sentinel value",
10351        )?;
10352        Ok(())
10353    }
10354
10355    #[test]
10356    fn read_only_telemetry_revalidates_project_identity_before_write() -> Result<(), Box<dyn Error>>
10357    {
10358        let temp = tempfile::tempdir()?;
10359        let root = temp.path().join("repository");
10360        fs::create_dir_all(&root)?;
10361        let db_path = temp.path().join("projectatlas.db");
10362        drop(AtlasStore::open_for_project(&db_path, &root)?);
10363
10364        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10365        reader.finish_index_read_snapshot()?;
10366        AtlasStore::transition_project_root(&db_path, &root, ProjectRootTransition::Detach)?;
10367
10368        let event = usage_from_estimates(
10369            "identity-race",
10370            "summary",
10371            Some("src/lib.rs".to_string()),
10372            None,
10373            100,
10374            20,
10375        );
10376        let Err(error) = reader.record_usage(&event) else {
10377            return Err(io::Error::other("telemetry wrote through replaced identity").into());
10378        };
10379        require_eq(
10380            &matches!(error, DbError::ProjectRootTransitionChanged { .. }),
10381            &true,
10382            "telemetry project identity recheck",
10383        )?;
10384        let current = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10385        require_eq(
10386            &current.token_overview(Some("identity-race"))?.calls,
10387            &0,
10388            "telemetry refused after identity replacement",
10389        )?;
10390        Ok(())
10391    }
10392
10393    #[test]
10394    fn read_only_recovery_requires_native_identity_before_telemetry_write()
10395    -> Result<(), Box<dyn Error>> {
10396        let temp = tempfile::tempdir()?;
10397        let root = temp.path().join("repository");
10398        fs::create_dir_all(&root)?;
10399        let database = temp.path().join("projectatlas.db");
10400        let event = usage_from_estimates(
10401            "rooted-telemetry",
10402            "summary",
10403            Some("src/lib.rs".to_string()),
10404            None,
10405            100,
10406            20,
10407        );
10408        let rooted_instance = UsageInstanceId::from_bytes([7; 16])?;
10409        let native_root_row;
10410        {
10411            let store = AtlasStore::open_for_project(&database, &root)?;
10412            native_root_row = store.connection.query_row(
10413                "SELECT codec_version, root
10414                   FROM project_root_identity
10415                  WHERE singleton = 1",
10416                [],
10417                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
10418            )?;
10419            store.record_usage(&event)?;
10420            store.record_usage_for_instance(
10421                rooted_instance,
10422                UsageInstanceOwner::LibraryHandle,
10423                &event,
10424                false,
10425            )?;
10426            store.seal_usage_instance(rooted_instance)?;
10427            require_eq(
10428                &store.token_overview(Some("rooted-telemetry"))?.calls,
10429                &2,
10430                "rooted telemetry positive write",
10431            )?;
10432            store
10433                .connection
10434                .execute("DELETE FROM project_root_identity", [])?;
10435            store
10436                .connection
10437                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10438        }
10439
10440        let file_snapshot = || -> Result<BTreeMap<std::ffi::OsString, Vec<u8>>, io::Error> {
10441            let mut files = BTreeMap::new();
10442            for entry in fs::read_dir(temp.path())? {
10443                let entry = entry?;
10444                if entry.file_type()?.is_file() {
10445                    files.insert(entry.file_name(), fs::read(entry.path())?);
10446                }
10447            }
10448            Ok(files)
10449        };
10450        let reader = AtlasStore::open_read_only(&database)?;
10451        require_eq(
10452            &reader.project_root_identity()?,
10453            &None,
10454            "rootless recovery has no native identity",
10455        )?;
10456        require_eq(
10457            &reader.validated_project_instance_id.is_some(),
10458            &true,
10459            "rootless recovery retains project identity",
10460        )?;
10461        require_eq(
10462            &reader.project_root()?,
10463            &Some(normalize_native_path_display(&root)),
10464            "rootless recovery retains legacy display root",
10465        )?;
10466        let overview_before = reader.token_overview(Some("rooted-telemetry"))?;
10467        let files_before = file_snapshot()?;
10468
10469        let missing_root_event = usage_from_estimates(
10470            "rootless-telemetry",
10471            "summary",
10472            Some("src/main.rs".to_string()),
10473            None,
10474            80,
10475            20,
10476        );
10477        let Err(error) = reader.record_usage(&missing_root_event) else {
10478            return Err(io::Error::other("rootless recovery recorded direct telemetry").into());
10479        };
10480        require_eq(
10481            &matches!(error, DbError::ProjectRootIdentityMissing),
10482            &true,
10483            "rootless direct telemetry refusal",
10484        )?;
10485
10486        let Err(error) = reader.record_usage_for_instance(
10487            UsageInstanceId::from_bytes([8; 16])?,
10488            UsageInstanceOwner::LibraryHandle,
10489            &missing_root_event,
10490            false,
10491        ) else {
10492            return Err(io::Error::other("rootless recovery recorded instance telemetry").into());
10493        };
10494        require_eq(
10495            &matches!(error, DbError::ProjectRootIdentityMissing),
10496            &true,
10497            "rootless instance telemetry refusal",
10498        )?;
10499
10500        let Err(error) = reader.record_usage_for_worktree_instance(
10501            UsageInstanceId::from_bytes([9; 16])?,
10502            UsageInstanceOwner::McpProcess,
10503            1,
10504            &missing_root_event,
10505            false,
10506        ) else {
10507            return Err(io::Error::other("rootless recovery recorded worktree telemetry").into());
10508        };
10509        require_eq(
10510            &matches!(error, DbError::ProjectRootIdentityMissing),
10511            &true,
10512            "rootless worktree telemetry refusal",
10513        )?;
10514
10515        let Err(error) = reader.seal_usage_instance(UsageInstanceId::from_bytes([10; 16])?) else {
10516            return Err(io::Error::other("rootless recovery sealed telemetry").into());
10517        };
10518        require_eq(
10519            &matches!(error, DbError::ProjectRootIdentityMissing),
10520            &true,
10521            "rootless seal telemetry refusal",
10522        )?;
10523
10524        require_eq(
10525            &reader.token_overview(Some("rooted-telemetry"))?,
10526            &overview_before,
10527            "rootless telemetry rows remain unchanged",
10528        )?;
10529        require_eq(
10530            &file_snapshot()?,
10531            &files_before,
10532            "rootless telemetry files remain unchanged",
10533        )?;
10534        drop(reader);
10535
10536        let repair = Connection::open(&database)?;
10537        schema::configure_writable(&repair)?;
10538        repair.execute(
10539            "INSERT INTO project_root_identity(singleton, codec_version, root)
10540             VALUES(1, ?1, ?2)",
10541            params![native_root_row.0, native_root_row.1],
10542        )?;
10543        drop(repair);
10544        let repaired = AtlasStore::open_for_project(&database, &root)?;
10545        repaired.record_usage(&usage_from_estimates(
10546            "repaired-telemetry",
10547            "summary",
10548            None,
10549            None,
10550            60,
10551            20,
10552        ))?;
10553        require_eq(
10554            &repaired.token_overview(Some("repaired-telemetry"))?.calls,
10555            &1,
10556            "repaired telemetry retry",
10557        )?;
10558        Ok(())
10559    }
10560
10561    #[test]
10562    fn telemetry_contention_uses_ancillary_busy_budget() -> Result<(), Box<dyn Error>> {
10563        let temp = tempfile::tempdir()?;
10564        let root = temp.path().join("repository");
10565        fs::create_dir_all(&root)?;
10566        let db_path = temp.path().join("projectatlas.db");
10567        drop(AtlasStore::open_for_project(&db_path, &root)?);
10568
10569        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10570        let writer = Connection::open(&db_path)?;
10571        writer.execute_batch("BEGIN IMMEDIATE")?;
10572        let started = Instant::now();
10573        let result = reader.record_usage(&usage_from_estimates(
10574            "contended",
10575            "overview",
10576            None,
10577            None,
10578            100,
10579            20,
10580        ));
10581        let elapsed = started.elapsed();
10582        writer.execute_batch("ROLLBACK")?;
10583
10584        let Err(error) = result else {
10585            return Err(
10586                io::Error::other("contended telemetry write unexpectedly succeeded").into(),
10587            );
10588        };
10589        require_eq(
10590            &error.is_write_unavailable(),
10591            &true,
10592            "contended telemetry error kind",
10593        )?;
10594        require_eq(
10595            &(elapsed < Duration::from_millis(500)),
10596            &true,
10597            "ancillary telemetry busy latency",
10598        )?;
10599        require_eq(
10600            &reader.token_overview(Some("contended"))?.calls,
10601            &0,
10602            "contended telemetry rollback",
10603        )?;
10604        Ok(())
10605    }
10606
10607    #[cfg(unix)]
10608    #[test]
10609    fn read_only_telemetry_does_not_recreate_a_removed_database() -> Result<(), Box<dyn Error>> {
10610        let temp = tempfile::tempdir()?;
10611        let root = temp.path().join("repository");
10612        fs::create_dir_all(&root)?;
10613        let db_path = temp.path().join("projectatlas.db");
10614        drop(AtlasStore::open_for_project(&db_path, &root)?);
10615
10616        let reader = AtlasStore::open_read_only_for_project(&db_path, &root)?;
10617        fs::remove_file(&db_path)?;
10618        let event = usage_from_estimates(
10619            "removed-database",
10620            "summary",
10621            Some("src/lib.rs".to_string()),
10622            None,
10623            100,
10624            20,
10625        );
10626        if reader.record_usage(&event).is_ok() {
10627            return Err(io::Error::other("telemetry reopened a removed database").into());
10628        }
10629        require_eq(
10630            &db_path.exists(),
10631            &false,
10632            "telemetry must not recreate a removed database",
10633        )?;
10634        Ok(())
10635    }
10636
10637    #[test]
10638    fn read_only_store_opens_checkpointed_wal_without_existing_sidecars()
10639    -> Result<(), Box<dyn Error>> {
10640        let temp = tempfile::tempdir()?;
10641        let root = temp.path().join("repo with spaces");
10642        let atlas_dir = root.join(".projectatlas");
10643        fs::create_dir_all(&atlas_dir)?;
10644        let db_path = atlas_dir.join("projectatlas.db");
10645        {
10646            let mut store = AtlasStore::open(&db_path)?;
10647            store.set_project_root(&root)?;
10648            store
10649                .connection
10650                .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
10651        }
10652        for sidecar_path in [
10653            sqlite_sidecar_path(&db_path, "-wal"),
10654            sqlite_sidecar_path(&db_path, "-shm"),
10655        ] {
10656            match fs::remove_file(sidecar_path) {
10657                Ok(()) => {}
10658                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10659                Err(error) => return Err(error.into()),
10660            }
10661        }
10662
10663        let store = AtlasStore::open_read_only(&db_path)?;
10664        require_eq(
10665            &store.project_root()?,
10666            &Some(normalize_native_path_display(&root)),
10667            "read-only checkpointed project root",
10668        )?;
10669        store.finish_index_read_snapshot()?;
10670        Ok(())
10671    }
10672
10673    #[test]
10674    fn partial_scan_updates_and_absents_paths() -> Result<(), Box<dyn Error>> {
10675        let mut store = AtlasStore::in_memory()?;
10676        store.replace_scan(&[
10677            test_file_node("src/a.rs", "hash-a"),
10678            test_file_node("src/b.rs", "hash-b"),
10679        ])?;
10680        store.upsert_scan_nodes(&[test_file_node("src/a.rs", "hash-a2")])?;
10681        let updated = store
10682            .load_node_by_path("src/a.rs")?
10683            .ok_or_else(|| io::Error::other("updated node missing"))?;
10684        require_eq(
10685            &updated.node.content_hash,
10686            &Some("hash-a2".to_string()),
10687            "partial content hash",
10688        )?;
10689        require_eq(
10690            &store.load_node_by_path("src/b.rs")?.is_some(),
10691            &true,
10692            "unrelated node remains indexed",
10693        )?;
10694        store.mark_paths_absent(&["src/b.rs".to_string()])?;
10695        require_eq(
10696            &store.load_node_by_path("src/b.rs")?.is_none(),
10697            &true,
10698            "absent path is no longer returned",
10699        )?;
10700        Ok(())
10701    }
10702
10703    #[test]
10704    fn approved_purpose_survives_incremental_file_hash_changes() -> Result<(), Box<dyn Error>> {
10705        let mut store = AtlasStore::in_memory()?;
10706        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
10707        store.set_purpose(
10708            "src/main.rs",
10709            "Application entry point",
10710            PurposeSource::Agent,
10711        )?;
10712        store.upsert_scan_nodes(&[test_file_node("src/main.rs", "hash-b")])?;
10713
10714        let node = store
10715            .load_node_by_path("src/main.rs")?
10716            .ok_or_else(|| io::Error::other("changed node missing"))?;
10717        require_eq(
10718            &node.purpose.status,
10719            &PurposeStatus::Approved,
10720            "changed approved file purpose status",
10721        )?;
10722        require_eq(
10723            &node.purpose.agent_reviewed(),
10724            &true,
10725            "changed approved file remains agent reviewed",
10726        )?;
10727        Ok(())
10728    }
10729
10730    #[test]
10731    fn ranked_nodes_are_loaded_bounded_from_sql() -> Result<(), Box<dyn Error>> {
10732        let mut store = AtlasStore::in_memory()?;
10733        let mut gradle_task_node = test_file_node("build.gradle.kts", "hash-gradle");
10734        gradle_task_node.extension = Some(".kts".to_string());
10735        gradle_task_node.language = Some("kotlin".to_string());
10736        store.replace_scan(&[
10737            test_folder_node("src/auth"),
10738            test_folder_node("src/ui"),
10739            test_file_node("src/auth/login.rs", "hash-login"),
10740            test_file_node("src/ui/button.rs", "hash-button"),
10741            gradle_task_node,
10742        ])?;
10743        store.set_purpose(
10744            "src/auth",
10745            "Authentication workflow folder",
10746            PurposeSource::Agent,
10747        )?;
10748        store.set_purpose("src/ui", "User interface folder", PurposeSource::Agent)?;
10749        store.set_node_summary("src/auth/login.rs", "rust source defining login flow")?;
10750
10751        let folders = store.load_ranked_nodes("authentication", NodeKind::Folder, None, 1, 0)?;
10752        require_eq(&folders.len(), &1, "bounded folder ranking")?;
10753        require_eq(
10754            &folders[0].node.path,
10755            &"src/auth".to_string(),
10756            "semantic folder ranking",
10757        )?;
10758
10759        let files = store.load_ranked_nodes("login", NodeKind::File, Some("src/auth"), 10, 0)?;
10760        require_eq(&files.len(), &1, "folder-constrained file ranking")?;
10761        require_eq(
10762            &files[0].node.path,
10763            &"src/auth/login.rs".to_string(),
10764            "ranked file path",
10765        )?;
10766        store.replace_symbol_graph(&SymbolGraph {
10767            path: "build.gradle.kts".to_string(),
10768            language: Some("kotlin".to_string()),
10769            parser: ParserKind::TreeSitter,
10770            symbols: vec![CodeSymbol {
10771                path: "build.gradle.kts".to_string(),
10772                language: Some("kotlin".to_string()),
10773                name: "bootRunE2E".to_string(),
10774                kind: SymbolKind::Function,
10775                signature: "tasks.register<BootRun>(\"bootRunE2E\")".to_string(),
10776                exported: false,
10777                documentation: None,
10778                line_start: 1,
10779                line_end: 1,
10780                source_selector: None,
10781                parent: None,
10782                parser: ParserKind::TreeSitter,
10783                detail: Some("gradle-kotlin-dsl-task".to_string()),
10784            }],
10785            relations: Vec::new(),
10786        })?;
10787        let gradle_files = store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
10788        require_eq(&gradle_files.len(), &1, "symbol-ranked file count")?;
10789        require_eq(
10790            &gradle_files[0].node.path,
10791            &"build.gradle.kts".to_string(),
10792            "symbol-ranked file path",
10793        )?;
10794        store.clear_symbol_graph_for_path("build.gradle.kts")?;
10795        let cleared_gradle_files =
10796            store.load_ranked_nodes("bootRunE2E", NodeKind::File, None, 10, 0)?;
10797        require_eq(
10798            &cleared_gradle_files.len(),
10799            &0,
10800            "cleared symbol-ranked file count",
10801        )?;
10802        Ok(())
10803    }
10804
10805    #[test]
10806    fn ranked_node_admission_preserves_dominant_tiers_before_candidate_cap()
10807    -> Result<(), Box<dyn Error>> {
10808        let mut store = AtlasStore::in_memory()?;
10809        let mut nodes = vec![
10810            test_file_node("needle", "hash-exact"),
10811            test_file_node("deep/needle", "hash-name"),
10812            test_file_node("reviewed.rs", "hash-reviewed"),
10813        ];
10814        nodes.extend(
10815            (0..130).map(|index| test_file_node(&format!("weak/needle-{index:03}.rs"), "hash")),
10816        );
10817        store.replace_scan(&nodes)?;
10818        store.set_purpose(
10819            "reviewed.rs",
10820            "Own needle responsibility",
10821            PurposeSource::Agent,
10822        )?;
10823        for index in 0..130 {
10824            let path = format!("weak/needle-{index:03}.rs");
10825            store.set_suggested_purpose(&path, "Generated needle suggestion")?;
10826            store.set_node_summary(&path, "Observed needle summary")?;
10827        }
10828
10829        let selected = store.load_ranked_nodes("needle", NodeKind::File, None, 100, 0)?;
10830        require_eq(&selected.len(), &100, "bounded adversarial candidate count")?;
10831        require_eq(
10832            &selected[..3]
10833                .iter()
10834                .map(|node| node.node.path.as_str())
10835                .collect::<Vec<_>>(),
10836            &vec!["needle", "deep/needle", "reviewed.rs"],
10837            "pre-cap exact path basename and reviewed-purpose admission",
10838        )?;
10839        Ok(())
10840    }
10841
10842    #[test]
10843    fn folder_like_filters_treat_wildcards_as_literal_path_text() -> Result<(), Box<dyn Error>> {
10844        let mut store = AtlasStore::in_memory()?;
10845        store.replace_scan(&[
10846            test_folder_node("src/a%b"),
10847            test_folder_node("src/axb"),
10848            test_folder_node("src/a_b"),
10849            test_folder_node("src/acb"),
10850            test_file_node("src/a%b/target.rs", "hash-percent-target"),
10851            test_file_node("src/axb/false.rs", "hash-percent-false"),
10852            test_file_node("src/a_b/target.rs", "hash-underscore-target"),
10853            test_file_node("src/acb/false.rs", "hash-underscore-false"),
10854        ])?;
10855        for path in [
10856            "src/a%b/target.rs",
10857            "src/axb/false.rs",
10858            "src/a_b/target.rs",
10859            "src/acb/false.rs",
10860        ] {
10861            store.set_node_summary(path, "needle indexed summary")?;
10862        }
10863
10864        let percent_files =
10865            store.load_ranked_nodes("needle", NodeKind::File, Some("src/a%b"), 10, 0)?;
10866        require_eq(&percent_files.len(), &1, "percent folder ranked count")?;
10867        require_eq(
10868            &percent_files[0].node.path,
10869            &"src/a%b/target.rs".to_string(),
10870            "percent folder ranked path",
10871        )?;
10872        require_eq(
10873            &store.source_file_byte_count(Some("src/a%b"))?,
10874            &12,
10875            "percent folder byte count",
10876        )?;
10877
10878        let mut visited = Vec::new();
10879        store.visit_file_token_estimates(Some("src/a_b"), |path, _size| {
10880            visited.push(path);
10881            Ok(true)
10882        })?;
10883        require_eq(
10884            &visited,
10885            &vec!["src/a_b/target.rs".to_string()],
10886            "underscore folder token paths",
10887        )?;
10888
10889        store.mark_paths_absent(&["src/a%b".to_string(), "src/a_b".to_string()])?;
10890        require_eq(
10891            &store.load_node_by_path("src/axb/false.rs")?.is_some(),
10892            &true,
10893            "percent-like sibling remains indexed",
10894        )?;
10895        require_eq(
10896            &store.load_node_by_path("src/acb/false.rs")?.is_some(),
10897            &true,
10898            "underscore-like sibling remains indexed",
10899        )?;
10900        require_eq(
10901            &store.load_node_by_path("src/a%b/target.rs")?.is_none(),
10902            &true,
10903            "percent folder target removed",
10904        )?;
10905        require_eq(
10906            &store.load_node_by_path("src/a_b/target.rs")?.is_none(),
10907            &true,
10908            "underscore folder target removed",
10909        )?;
10910        Ok(())
10911    }
10912
10913    #[test]
10914    fn sql_health_findings_match_resolution_ids() -> Result<(), Box<dyn Error>> {
10915        let mut store = AtlasStore::in_memory()?;
10916        store.replace_scan(&[
10917            test_file_node("src/a.rs", "hash-a"),
10918            test_file_node("src/b.rs", "hash-b"),
10919        ])?;
10920        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
10921        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
10922
10923        let findings = store.unresolved_health_findings(&[])?;
10924        let duplicate = findings
10925            .iter()
10926            .find(|finding| finding.category == "duplicate-purpose")
10927            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
10928        store.resolve_health_finding(&HealthResolution {
10929            finding_id: duplicate.id.clone(),
10930            category: duplicate.category.clone(),
10931            path: duplicate.path.clone(),
10932            related_path: duplicate.related_path.clone(),
10933            rationale: "Intentional mirror for test.".to_string(),
10934        })?;
10935        let remaining = store.unresolved_health_findings(&store.resolved_health_ids()?)?;
10936        require_eq(&remaining.is_empty(), &true, "resolved SQL health finding")?;
10937        Ok(())
10938    }
10939
10940    #[test]
10941    fn unresolved_health_findings_page_filters_and_bounds_rows() -> Result<(), Box<dyn Error>> {
10942        let mut store = AtlasStore::in_memory()?;
10943        store.replace_scan(&[
10944            test_folder_node("."),
10945            test_folder_node("src"),
10946            test_file_node("src/a.rs", "hash-a"),
10947            test_file_node("docs/a.rs", "hash-doc"),
10948        ])?;
10949        let query = HealthQuery {
10950            start_index: 1,
10951            limit: 1,
10952            category: Some("missing-purpose".to_string()),
10953            severity: Some(Severity::Warning),
10954            path_prefix: Some("src".to_string()),
10955            summary_only: false,
10956            scope: HealthScope::all(),
10957        };
10958
10959        let page = store.unresolved_health_findings_page(&[], &query)?;
10960        require_eq(&page.unfiltered_total, &4, "unfiltered health total")?;
10961        require_eq(&page.total, &2, "filtered health total")?;
10962        require_eq(&page.returned, &1, "returned health rows")?;
10963        require_eq(
10964            &page.findings[0].path,
10965            &"src/a.rs".to_string(),
10966            "paged path",
10967        )?;
10968
10969        let summary_page = store.unresolved_health_findings_page(
10970            &[],
10971            &HealthQuery {
10972                summary_only: true,
10973                ..query
10974            },
10975        )?;
10976        require_eq(&summary_page.total, &2, "summary-only total")?;
10977        require_eq(
10978            &summary_page.findings.is_empty(),
10979            &true,
10980            "summary-only rows",
10981        )?;
10982        Ok(())
10983    }
10984
10985    #[test]
10986    fn unresolved_health_findings_page_skips_resolved_lifecycle_rows_before_paging()
10987    -> Result<(), Box<dyn Error>> {
10988        let mut store = AtlasStore::in_memory()?;
10989        store.replace_scan(&[
10990            test_file_node("src/a.rs", "hash-a"),
10991            test_file_node("src/b.rs", "hash-b"),
10992            test_file_node("src/c.rs", "hash-c"),
10993            test_file_node("src/d.rs", "hash-d"),
10994        ])?;
10995        store.resolve_health_finding(&HealthResolution {
10996            finding_id: finding_id("missing-purpose", "src/b.rs", None),
10997            category: "missing-purpose".to_string(),
10998            path: "src/b.rs".to_string(),
10999            related_path: None,
11000            rationale: "Resolved for pagination regression.".to_string(),
11001        })?;
11002
11003        let page = store.unresolved_health_findings_page_current(&HealthQuery {
11004            start_index: 0,
11005            limit: 2,
11006            category: Some("missing-purpose".to_string()),
11007            severity: Some(Severity::Warning),
11008            path_prefix: Some("src".to_string()),
11009            summary_only: false,
11010            scope: HealthScope::all(),
11011        })?;
11012
11013        require_eq(&page.total, &3, "filtered unresolved missing total")?;
11014        require_eq(&page.returned, &2, "returned unresolved missing rows")?;
11015        require_eq(
11016            &page
11017                .findings
11018                .iter()
11019                .map(|finding| finding.path.as_str())
11020                .collect::<Vec<_>>(),
11021            &vec!["src/a.rs", "src/c.rs"],
11022            "resolved row skipped before limit",
11023        )?;
11024        Ok(())
11025    }
11026
11027    #[test]
11028    fn stored_health_resolution_filter_stays_indexed_and_bind_bounded() -> Result<(), Box<dyn Error>>
11029    {
11030        const HISTORICAL_RESOLUTIONS: usize = 1_500;
11031
11032        let temp = tempfile::tempdir()?;
11033        let root = temp.path().join("repository");
11034        fs::create_dir_all(root.join("src"))?;
11035        let database = temp.path().join("projectatlas.db");
11036        let mut store = AtlasStore::open_for_project(&database, &root)?;
11037        store.replace_scan(&[test_file_node("src/current.rs", "hash-current")])?;
11038        {
11039            let transaction = store.connection.transaction()?;
11040            {
11041                let mut statement = transaction.prepare(
11042                    "
11043                    INSERT INTO health_resolutions(
11044                        finding_id, category, path, related_path, rationale, resolved_by
11045                    )
11046                    VALUES(?1, 'missing-purpose', ?2, NULL, 'historical', 'agent')
11047                    ",
11048                )?;
11049                for index in 0..HISTORICAL_RESOLUTIONS {
11050                    let path = format!("removed/{index}.rs");
11051                    statement.execute(params![finding_id("missing-purpose", &path, None), path])?;
11052                }
11053            }
11054            transaction.commit()?;
11055        }
11056
11057        drop(store);
11058        let store = AtlasStore::open_read_only_for_project(&database, &root)?;
11059        let page = store.unresolved_health_findings_page_current(&HealthQuery {
11060            start_index: 0,
11061            limit: 2,
11062            category: Some("missing-purpose".to_string()),
11063            severity: Some(Severity::Warning),
11064            path_prefix: Some("src".to_string()),
11065            summary_only: false,
11066            scope: HealthScope::all(),
11067        })?;
11068        require_eq(&page.total, &1, "current unresolved total")?;
11069        require_eq(
11070            &page.findings[0].path,
11071            &"src/current.rs".to_string(),
11072            "current unresolved path",
11073        )?;
11074
11075        let (where_clause, values) = purpose_status_where_clause(
11076            PURPOSE_HEALTH_SPECS[0],
11077            None,
11078            HealthResolutionFilter::Stored,
11079            HealthScope::all(),
11080        );
11081        require_eq(&values.len(), &1, "stored filter bind count")?;
11082        let plan_sql = format!(
11083            "EXPLAIN QUERY PLAN
11084             SELECT COUNT(*)
11085             FROM nodes n
11086             JOIN purposes p ON p.node_id = n.id
11087             WHERE {where_clause}"
11088        );
11089        let mut statement = store.connection.prepare(&plan_sql)?;
11090        let rows = statement.query_map(params_from_iter(values), |row| row.get::<_, String>(3))?;
11091        let mut plan = Vec::new();
11092        for row in rows {
11093            plan.push(row?);
11094        }
11095        if !plan.iter().any(|detail| {
11096            detail.contains("health_resolutions")
11097                && detail.contains("finding_id")
11098                && detail.contains("SEARCH")
11099        }) {
11100            return Err(io::Error::other(format!(
11101                "stored resolution anti-lookup did not use the finding-id index: {plan:?}"
11102            ))
11103            .into());
11104        }
11105        store.finish_index_read_snapshot()?;
11106        Ok(())
11107    }
11108
11109    #[test]
11110    fn unresolved_health_findings_page_streams_duplicate_and_temp_rows()
11111    -> Result<(), Box<dyn Error>> {
11112        let mut store = AtlasStore::in_memory()?;
11113        store.replace_scan(&[
11114            test_folder_node("."),
11115            test_folder_node("tmp"),
11116            test_folder_node("src"),
11117            test_folder_node("src/tmp"),
11118            test_file_node("src/a.rs", "hash-a"),
11119            test_file_node("src/b.rs", "hash-b"),
11120        ])?;
11121        store.set_purpose(".", "Repository root", PurposeSource::Agent)?;
11122        store.set_purpose("src", "Source folder", PurposeSource::Agent)?;
11123        store.set_purpose("tmp", "Temporary output", PurposeSource::Agent)?;
11124        store.set_purpose("src/tmp", "Source temporary output", PurposeSource::Agent)?;
11125        store.set_purpose("src/a.rs", "Shared implementation", PurposeSource::Agent)?;
11126        store.set_purpose("src/b.rs", "Shared implementation", PurposeSource::Agent)?;
11127
11128        let duplicate_page = store.unresolved_health_findings_page(
11129            &[],
11130            &HealthQuery {
11131                start_index: 0,
11132                limit: 1,
11133                category: Some("duplicate-purpose".to_string()),
11134                severity: Some(Severity::Warning),
11135                path_prefix: Some("src".to_string()),
11136                summary_only: false,
11137                scope: HealthScope::all(),
11138            },
11139        )?;
11140        require_eq(&duplicate_page.total, &1, "duplicate total")?;
11141        require_eq(&duplicate_page.returned, &1, "duplicate returned")?;
11142        require_eq(
11143            &duplicate_page.findings[0].category,
11144            &"duplicate-purpose".to_string(),
11145            "duplicate category",
11146        )?;
11147
11148        let temp_page = store.unresolved_health_findings_page(
11149            &[],
11150            &HealthQuery {
11151                start_index: 0,
11152                limit: 1,
11153                category: Some("repeated-temporary-folder".to_string()),
11154                severity: Some(Severity::Warning),
11155                path_prefix: Some(".".to_string()),
11156                summary_only: false,
11157                scope: HealthScope::all(),
11158            },
11159        )?;
11160        require_eq(&temp_page.total, &1, "temp total")?;
11161        require_eq(&temp_page.returned, &1, "temp returned")?;
11162        require_eq(
11163            &temp_page.findings[0].category,
11164            &"repeated-temporary-folder".to_string(),
11165            "temp category",
11166        )?;
11167        Ok(())
11168    }
11169
11170    #[test]
11171    fn unresolved_health_findings_page_source_only_filters_asset_noise()
11172    -> Result<(), Box<dyn Error>> {
11173        let mut store = AtlasStore::in_memory()?;
11174        let asset_file = Node {
11175            path: "assets/logo.png".to_string(),
11176            kind: NodeKind::File,
11177            parent_path: Some("assets".to_string()),
11178            extension: Some(".png".to_string()),
11179            language: None,
11180            size_bytes: Some(42),
11181            mtime_ns: Some(10),
11182            content_hash: Some("hash-logo".to_string()),
11183        };
11184        store.replace_scan(&[
11185            test_folder_node("."),
11186            test_folder_node("src"),
11187            test_file_node("src/main.rs", "hash-main"),
11188            test_folder_node("assets"),
11189            asset_file,
11190        ])?;
11191
11192        let page = store.unresolved_health_findings_page(
11193            &[],
11194            &HealthQuery {
11195                start_index: 0,
11196                limit: 10,
11197                category: Some("missing-purpose".to_string()),
11198                severity: Some(Severity::Warning),
11199                path_prefix: Some(".".to_string()),
11200                summary_only: false,
11201                scope: HealthScope::source_only(),
11202            },
11203        )?;
11204
11205        require_eq(&page.unfiltered_total, &5, "all unresolved rows")?;
11206        require_eq(&page.total, &3, "source-only missing total")?;
11207        require_eq(
11208            &page
11209                .findings
11210                .iter()
11211                .map(|finding| finding.path.as_str())
11212                .collect::<Vec<_>>(),
11213            &vec![".", "src", "src/main.rs"],
11214            "source-only paths",
11215        )?;
11216        Ok(())
11217    }
11218
11219    #[test]
11220    fn high_impact_purpose_queue_filters_low_priority_files() -> Result<(), Box<dyn Error>> {
11221        let mut store = AtlasStore::in_memory()?;
11222        store.replace_scan(&[
11223            test_folder_node("."),
11224            test_folder_node("src"),
11225            test_file_node("src/main.rs", "hash-main"),
11226            test_file_node("src/helper.rs", "hash-helper"),
11227            test_file_node("build.gradle.kts", "hash-gradle"),
11228        ])?;
11229
11230        let page = store.unresolved_health_findings_page(
11231            &[],
11232            &HealthQuery {
11233                start_index: 0,
11234                limit: 10,
11235                category: Some("missing-purpose".to_string()),
11236                severity: Some(Severity::Warning),
11237                path_prefix: Some(".".to_string()),
11238                summary_only: false,
11239                scope: HealthScope::purpose_default(),
11240            },
11241        )?;
11242
11243        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11244        require_eq(&page.total, &4, "default actionable rows")?;
11245        require_eq(
11246            &page
11247                .findings
11248                .iter()
11249                .map(|finding| finding.path.as_str())
11250                .collect::<Vec<_>>(),
11251            &vec![".", "src", "build.gradle.kts", "src/main.rs"],
11252            "folder-first high-impact queue paths",
11253        )?;
11254
11255        let broad_page = store.unresolved_health_findings_page(
11256            &[],
11257            &HealthQuery {
11258                start_index: 0,
11259                limit: 10,
11260                category: Some("missing-purpose".to_string()),
11261                severity: Some(Severity::Warning),
11262                path_prefix: Some(".".to_string()),
11263                summary_only: false,
11264                scope: HealthScope::all(),
11265            },
11266        )?;
11267        require_eq(&broad_page.total, &5, "explicit broad queue rows")?;
11268        Ok(())
11269    }
11270
11271    #[test]
11272    fn high_impact_purpose_queue_keeps_asset_only_folders_without_asset_files()
11273    -> Result<(), Box<dyn Error>> {
11274        let mut store = AtlasStore::in_memory()?;
11275        let asset_file = Node {
11276            path: "assets/logo.svg".to_string(),
11277            kind: NodeKind::File,
11278            parent_path: Some("assets".to_string()),
11279            extension: Some(".svg".to_string()),
11280            language: None,
11281            size_bytes: Some(42),
11282            mtime_ns: Some(10),
11283            content_hash: Some("hash-logo".to_string()),
11284        };
11285        store.replace_scan(&[
11286            test_folder_node("."),
11287            test_folder_node("assets"),
11288            test_folder_node("src"),
11289            asset_file,
11290            test_file_node("src/helper.rs", "hash-helper"),
11291        ])?;
11292
11293        let page = store.unresolved_health_findings_page(
11294            &[],
11295            &HealthQuery {
11296                start_index: 0,
11297                limit: 10,
11298                category: Some("missing-purpose".to_string()),
11299                severity: Some(Severity::Warning),
11300                path_prefix: Some(".".to_string()),
11301                summary_only: false,
11302                scope: HealthScope::purpose_default(),
11303            },
11304        )?;
11305
11306        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11307        require_eq(&page.total, &3, "all folders without asset files")?;
11308        require_eq(
11309            &page
11310                .findings
11311                .iter()
11312                .map(|finding| finding.path.as_str())
11313                .collect::<Vec<_>>(),
11314            &vec![".", "assets", "src"],
11315            "folder-first default queue keeps asset-only folders",
11316        )?;
11317        Ok(())
11318    }
11319
11320    #[test]
11321    fn high_impact_purpose_queue_pages_folders_before_files() -> Result<(), Box<dyn Error>> {
11322        let mut store = AtlasStore::in_memory()?;
11323        store.replace_scan(&[
11324            test_folder_node("."),
11325            test_folder_node("src"),
11326            test_file_node("Cargo.toml", "hash-cargo"),
11327            test_file_node("package.json", "hash-package"),
11328            test_file_node("pyproject.toml", "hash-python"),
11329        ])?;
11330
11331        let page = store.unresolved_health_findings_page(
11332            &[],
11333            &HealthQuery {
11334                start_index: 0,
11335                limit: 2,
11336                category: Some("missing-purpose".to_string()),
11337                severity: Some(Severity::Warning),
11338                path_prefix: Some(".".to_string()),
11339                summary_only: false,
11340                scope: HealthScope::purpose_default(),
11341            },
11342        )?;
11343
11344        require_eq(&page.total, &5, "default actionable total")?;
11345        require_eq(
11346            &page
11347                .findings
11348                .iter()
11349                .map(|finding| finding.path.as_str())
11350                .collect::<Vec<_>>(),
11351            &vec![".", "src"],
11352            "small page keeps folders first",
11353        )?;
11354        Ok(())
11355    }
11356
11357    #[test]
11358    fn high_impact_purpose_queue_omits_low_value_stale_files() -> Result<(), Box<dyn Error>> {
11359        let mut store = AtlasStore::in_memory()?;
11360        store.replace_scan(&[
11361            test_file_node("src/helper.rs", "hash-a"),
11362            test_file_node("Cargo.toml", "hash-cargo"),
11363            test_file_node("package.json", "hash-package"),
11364        ])?;
11365        store.set_purpose(
11366            "src/helper.rs",
11367            "Reviewed helper implementation.",
11368            PurposeSource::Agent,
11369        )?;
11370        store.set_purpose(
11371            "Cargo.toml",
11372            "Reviewed Cargo manifest.",
11373            PurposeSource::Agent,
11374        )?;
11375        store.connection.execute(
11376            "UPDATE purposes
11377                SET status = 'stale'
11378              WHERE node_id IN (
11379                  SELECT id FROM nodes WHERE path IN ('src/helper.rs', 'Cargo.toml')
11380              )",
11381            [],
11382        )?;
11383        store.replace_scan(&[
11384            test_file_node("src/helper.rs", "hash-b"),
11385            test_file_node("Cargo.toml", "hash-cargo-new"),
11386            test_file_node("package.json", "hash-package"),
11387        ])?;
11388
11389        let page = store.unresolved_health_findings_page(
11390            &[],
11391            &HealthQuery {
11392                start_index: 0,
11393                limit: 1,
11394                category: None,
11395                severity: Some(Severity::Warning),
11396                path_prefix: Some(".".to_string()),
11397                summary_only: false,
11398                scope: HealthScope::purpose_default(),
11399            },
11400        )?;
11401
11402        require_eq(&page.total, &2, "default actionable total")?;
11403        require_eq(&page.returned, &1, "small page returned")?;
11404        require_eq(
11405            &page.findings[0].category,
11406            &"stale-purpose".to_string(),
11407            "stale high-impact file is still prioritized",
11408        )?;
11409        require_eq(
11410            &page.findings[0].path,
11411            &"Cargo.toml".to_string(),
11412            "stale high-impact file path",
11413        )?;
11414
11415        let broad_page = store.unresolved_health_findings_page(
11416            &[],
11417            &HealthQuery {
11418                start_index: 0,
11419                limit: 10,
11420                category: Some(CATEGORY_STALE_PURPOSE.to_string()),
11421                severity: Some(Severity::Warning),
11422                path_prefix: Some(".".to_string()),
11423                summary_only: false,
11424                scope: HealthScope::purpose_with_source_files(),
11425            },
11426        )?;
11427        require_eq(&broad_page.total, &2, "broad source stale rows")?;
11428        require_eq(
11429            &health_paths(&broad_page),
11430            &vec!["Cargo.toml", "src/helper.rs"],
11431            "broad scope includes low-value stale source files",
11432        )?;
11433        Ok(())
11434    }
11435
11436    #[test]
11437    fn include_assets_queue_includes_asset_files_not_low_priority_source()
11438    -> Result<(), Box<dyn Error>> {
11439        let mut store = AtlasStore::in_memory()?;
11440        let asset_file = Node {
11441            path: "assets/logo.svg".to_string(),
11442            kind: NodeKind::File,
11443            parent_path: Some("assets".to_string()),
11444            extension: Some(".svg".to_string()),
11445            language: None,
11446            size_bytes: Some(42),
11447            mtime_ns: Some(10),
11448            content_hash: Some("hash-logo".to_string()),
11449        };
11450        store.replace_scan(&[
11451            test_folder_node("."),
11452            test_folder_node("assets"),
11453            test_folder_node("src"),
11454            asset_file,
11455            test_file_node("src/helper.rs", "hash-helper"),
11456        ])?;
11457
11458        let page = store.unresolved_health_findings_page(
11459            &[],
11460            &HealthQuery {
11461                start_index: 0,
11462                limit: 10,
11463                category: Some("missing-purpose".to_string()),
11464                severity: Some(Severity::Warning),
11465                path_prefix: Some(".".to_string()),
11466                summary_only: false,
11467                scope: HealthScope::purpose_with_assets(),
11468            },
11469        )?;
11470
11471        require_eq(&page.unfiltered_total, &5, "all missing rows")?;
11472        require_eq(
11473            &page.total,
11474            &4,
11475            "assets included without broad source cleanup",
11476        )?;
11477        require_eq(
11478            &page
11479                .findings
11480                .iter()
11481                .map(|finding| finding.path.as_str())
11482                .collect::<Vec<_>>(),
11483            &vec![".", "assets", "src", "assets/logo.svg"],
11484            "asset files included and low-priority source omitted",
11485        )?;
11486        Ok(())
11487    }
11488
11489    #[test]
11490    fn legacy_human_stale_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11491        let mut store = AtlasStore::in_memory()?;
11492        store.replace_scan(&[test_file_node("src/helper.rs", "hash-a")])?;
11493        store.set_purpose(
11494            "src/helper.rs",
11495            "Legacy reviewed helper implementation.",
11496            PurposeSource::Agent,
11497        )?;
11498        store.connection.execute(
11499            "
11500            UPDATE purposes
11501            SET source = 'human', status = 'stale'
11502            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/helper.rs')
11503            ",
11504            [],
11505        )?;
11506        store.replace_scan(&[test_file_node("src/helper.rs", "hash-b")])?;
11507
11508        let page = store.unresolved_health_findings_page(
11509            &[],
11510            &HealthQuery {
11511                start_index: 0,
11512                limit: 10,
11513                category: Some("stale-purpose".to_string()),
11514                severity: Some(Severity::Warning),
11515                path_prefix: Some(".".to_string()),
11516                summary_only: false,
11517                scope: HealthScope::purpose_default(),
11518            },
11519        )?;
11520
11521        require_eq(&page.total, &1, "legacy reviewed stale row total")?;
11522        require_eq(
11523            &page.findings[0].path,
11524            &"src/helper.rs".to_string(),
11525            "legacy reviewed stale file",
11526        )?;
11527        Ok(())
11528    }
11529
11530    #[test]
11531    fn stale_imported_files_remain_in_default_queue() -> Result<(), Box<dyn Error>> {
11532        let mut store = AtlasStore::in_memory()?;
11533        store.replace_scan(&[
11534            test_file_node("src/imported.rs", "hash-a"),
11535            test_file_node("Cargo.toml", "hash-cargo"),
11536        ])?;
11537        store.set_purpose(
11538            "src/imported.rs",
11539            "Imported helper implementation.",
11540            PurposeSource::Imported,
11541        )?;
11542        store.connection.execute(
11543            "UPDATE purposes
11544                SET status = 'stale'
11545              WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/imported.rs')",
11546            [],
11547        )?;
11548        store.replace_scan(&[
11549            test_file_node("src/imported.rs", "hash-b"),
11550            test_file_node("Cargo.toml", "hash-cargo"),
11551        ])?;
11552
11553        let page = store.unresolved_health_findings_page(
11554            &[],
11555            &HealthQuery {
11556                start_index: 0,
11557                limit: 10,
11558                category: None,
11559                severity: Some(Severity::Warning),
11560                path_prefix: Some(".".to_string()),
11561                summary_only: false,
11562                scope: HealthScope::purpose_default(),
11563            },
11564        )?;
11565
11566        require_eq(
11567            &page.total,
11568            &2,
11569            "default queue includes stale imported file",
11570        )?;
11571        require_eq(
11572            &health_paths(&page),
11573            &vec!["src/imported.rs", "Cargo.toml"],
11574            "stale imported file is queued before high-impact files",
11575        )?;
11576        require_eq(
11577            &page.findings[0].category,
11578            &"stale-purpose".to_string(),
11579            "stale imported finding category",
11580        )?;
11581        Ok(())
11582    }
11583
11584    #[test]
11585    fn duplicate_purpose_health_is_contextual_for_folders() -> Result<(), Box<dyn Error>> {
11586        let mut store = AtlasStore::in_memory()?;
11587        store.replace_scan(&[
11588            test_folder_node("."),
11589            test_folder_node("customers"),
11590            test_folder_node("customers/service"),
11591            test_folder_node("settings"),
11592            test_folder_node("settings/service"),
11593        ])?;
11594        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11595        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11596
11597        let page = store.unresolved_health_findings_page(
11598            &[],
11599            &HealthQuery {
11600                start_index: 0,
11601                limit: 10,
11602                category: Some("duplicate-purpose".to_string()),
11603                severity: Some(Severity::Warning),
11604                path_prefix: Some(".".to_string()),
11605                summary_only: false,
11606                scope: HealthScope::all(),
11607            },
11608        )?;
11609
11610        require_eq(&page.total, &0, "folder duplicates scoped by parent")?;
11611        Ok(())
11612    }
11613
11614    #[test]
11615    fn contextual_folder_duplicate_identity_matches_unpaged_health_and_resolution()
11616    -> Result<(), Box<dyn Error>> {
11617        let mut store = AtlasStore::in_memory()?;
11618        store.replace_scan(&[
11619            test_folder_node("."),
11620            test_folder_node("customers"),
11621            test_folder_node("customers/service"),
11622            test_folder_node("settings"),
11623            test_folder_node("settings/service"),
11624            test_folder_node("settings/worker"),
11625        ])?;
11626        store.set_purpose("customers/service", "Service layer", PurposeSource::Agent)?;
11627        store.set_purpose("settings/service", "Service layer", PurposeSource::Agent)?;
11628        store.set_purpose("settings/worker", "Service layer", PurposeSource::Agent)?;
11629
11630        let page = store.unresolved_health_findings_page(
11631            &[],
11632            &HealthQuery {
11633                start_index: 0,
11634                limit: 10,
11635                category: Some("duplicate-purpose".to_string()),
11636                severity: Some(Severity::Warning),
11637                path_prefix: Some(".".to_string()),
11638                summary_only: false,
11639                scope: HealthScope::all(),
11640            },
11641        )?;
11642        require_eq(&page.total, &1, "contextual duplicate total")?;
11643        let paged_finding = page
11644            .findings
11645            .first()
11646            .ok_or_else(|| io::Error::other("paged duplicate missing"))?;
11647        require_eq(
11648            &paged_finding.path,
11649            &"settings/worker".to_string(),
11650            "paged duplicate path",
11651        )?;
11652        require_eq(
11653            &paged_finding.related_path,
11654            &Some("settings/service".to_string()),
11655            "paged related path",
11656        )?;
11657
11658        let unpaged_duplicates = store
11659            .unresolved_health_findings(&[])?
11660            .into_iter()
11661            .filter(|finding| finding.category == "duplicate-purpose")
11662            .collect::<Vec<_>>();
11663        require_eq(
11664            &unpaged_duplicates,
11665            &page.findings,
11666            "unpaged duplicate identity",
11667        )?;
11668
11669        store.resolve_health_finding(&HealthResolution {
11670            finding_id: paged_finding.id.clone(),
11671            category: paged_finding.category.clone(),
11672            path: paged_finding.path.clone(),
11673            related_path: paged_finding.related_path.clone(),
11674            rationale: "Settings service and worker intentionally share a layer purpose."
11675                .to_string(),
11676        })?;
11677        let has_remaining_duplicate = store
11678            .unresolved_health_findings(&store.resolved_health_ids()?)?
11679            .into_iter()
11680            .any(|finding| finding.category == "duplicate-purpose");
11681        require_eq(
11682            &has_remaining_duplicate,
11683            &false,
11684            "resolved contextual duplicate",
11685        )?;
11686        Ok(())
11687    }
11688
11689    #[test]
11690    fn agent_review_required_scope_expands_from_low_to_strict() -> Result<(), Box<dyn Error>> {
11691        let mut store = AtlasStore::in_memory()?;
11692        let asset_file = Node {
11693            path: "assets/logo.svg".to_string(),
11694            kind: NodeKind::File,
11695            parent_path: Some("assets".to_string()),
11696            extension: Some(".svg".to_string()),
11697            language: None,
11698            size_bytes: Some(42),
11699            mtime_ns: Some(10),
11700            content_hash: Some("hash-logo".to_string()),
11701        };
11702        store.replace_scan(&[
11703            test_folder_node("."),
11704            test_folder_node("assets"),
11705            test_folder_node("src"),
11706            test_file_node("Cargo.toml", "hash-cargo"),
11707            test_file_node("src/detail.rs", "hash-detail"),
11708            asset_file,
11709        ])?;
11710        for (path, purpose) in [
11711            (".", "Imported repository root"),
11712            ("assets", "Imported asset folder"),
11713            ("src", "Imported Rust source folder"),
11714            ("Cargo.toml", "Imported Rust manifest"),
11715            ("src/detail.rs", "Imported implementation detail"),
11716            ("assets/logo.svg", "Imported SVG brand asset"),
11717        ] {
11718            store.set_purpose(path, purpose, PurposeSource::Imported)?;
11719        }
11720
11721        let low = store.unresolved_health_findings_page(
11722            &[],
11723            &HealthQuery {
11724                start_index: 0,
11725                limit: 20,
11726                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
11727                severity: Some(Severity::Warning),
11728                path_prefix: Some(".".to_string()),
11729                summary_only: false,
11730                scope: HealthScope::purpose_default(),
11731            },
11732        )?;
11733        require_eq(
11734            &health_paths(&low),
11735            &vec![".", "assets", "src", "Cargo.toml"],
11736            "low purpose review scope",
11737        )?;
11738        require_eq(
11739            &low.unfiltered_total,
11740            &6,
11741            "agent-review findings are counted once in unfiltered total",
11742        )?;
11743
11744        let asset_scope = store.unresolved_health_findings_page(
11745            &[],
11746            &HealthQuery {
11747                scope: HealthScope::purpose_with_assets(),
11748                ..low_query()
11749            },
11750        )?;
11751        require_eq(
11752            &health_paths(&asset_scope),
11753            &vec![".", "assets", "src", "Cargo.toml", "assets/logo.svg"],
11754            "asset purpose review scope",
11755        )?;
11756
11757        let medium = store.unresolved_health_findings_page(
11758            &[],
11759            &HealthQuery {
11760                scope: HealthScope::purpose_with_source_files(),
11761                ..low_query()
11762            },
11763        )?;
11764        require_eq(
11765            &health_paths(&medium),
11766            &vec![".", "assets", "src", "Cargo.toml", "src/detail.rs"],
11767            "medium purpose review scope",
11768        )?;
11769
11770        let strict = store.unresolved_health_findings_page(
11771            &[],
11772            &HealthQuery {
11773                scope: HealthScope::purpose_strict(),
11774                ..low_query()
11775            },
11776        )?;
11777        require_eq(
11778            &health_paths(&strict),
11779            &vec![
11780                ".",
11781                "assets",
11782                "src",
11783                "Cargo.toml",
11784                "assets/logo.svg",
11785                "src/detail.rs",
11786            ],
11787            "strict purpose review scope",
11788        )?;
11789
11790        let all = store.unresolved_health_findings_page(
11791            &[],
11792            &HealthQuery {
11793                scope: HealthScope::all(),
11794                ..low_query()
11795            },
11796        )?;
11797        require_eq(
11798            &health_paths(&all),
11799            &health_paths(&strict),
11800            "all health scope should include every purpose review candidate",
11801        )?;
11802        Ok(())
11803    }
11804
11805    #[test]
11806    fn replace_scan_preserves_curated_purposes_and_reconciles_changed_paths()
11807    -> Result<(), Box<dyn Error>> {
11808        let mut store = AtlasStore::in_memory()?;
11809        store.replace_scan(&[
11810            test_folder_node("."),
11811            test_folder_node("src"),
11812            test_file_node("src/main.rs", "hash-a"),
11813        ])?;
11814        store.set_purpose(".", "Agent-reviewed repository root", PurposeSource::Agent)?;
11815        store.set_purpose(
11816            "src",
11817            "Agent-reviewed Rust source folder",
11818            PurposeSource::Agent,
11819        )?;
11820        store.set_purpose(
11821            "src/main.rs",
11822            "Agent-reviewed Rust entry point",
11823            PurposeSource::Agent,
11824        )?;
11825
11826        store.replace_scan(&[
11827            test_folder_node("."),
11828            test_folder_node("src"),
11829            test_file_node("src/main.rs", "hash-a"),
11830            test_file_node("src/new.rs", "hash-new"),
11831        ])?;
11832        let nodes = store.load_nodes_by_paths(&[
11833            ".".to_string(),
11834            "src".to_string(),
11835            "src/main.rs".to_string(),
11836            "src/new.rs".to_string(),
11837        ])?;
11838        let by_path = nodes
11839            .iter()
11840            .map(|node| (node.node.path.as_str(), node))
11841            .collect::<HashMap<_, _>>();
11842        require_eq(
11843            &by_path["src/main.rs"].purpose.purpose,
11844            &Some("Agent-reviewed Rust entry point".to_string()),
11845            "unchanged file purpose preserved",
11846        )?;
11847        require_eq(
11848            &by_path["src/main.rs"].purpose.status,
11849            &PurposeStatus::Approved,
11850            "unchanged file purpose stays approved",
11851        )?;
11852        require_eq(
11853            &by_path["src/new.rs"].purpose.status,
11854            &PurposeStatus::Missing,
11855            "new file starts missing",
11856        )?;
11857
11858        store.replace_scan(&[
11859            test_folder_node("."),
11860            test_folder_node("src"),
11861            test_file_node("src/main.rs", "hash-b"),
11862            test_file_node("src/new.rs", "hash-new"),
11863        ])?;
11864        let changed = store
11865            .load_nodes_by_paths(&["src/main.rs".to_string()])?
11866            .pop()
11867            .ok_or_else(|| io::Error::other("changed node missing"))?;
11868        require_eq(
11869            &changed.purpose.purpose,
11870            &Some("Agent-reviewed Rust entry point".to_string()),
11871            "changed file purpose text preserved",
11872        )?;
11873        require_eq(
11874            &changed.purpose.status,
11875            &PurposeStatus::Approved,
11876            "changed file purpose stays approved",
11877        )?;
11878
11879        store.replace_scan(&[test_folder_node("."), test_folder_node("src")])?;
11880        let removed = store.load_nodes_by_paths(&["src/main.rs".to_string()])?;
11881        require_eq(&removed.is_empty(), &true, "removed file is inactive")?;
11882
11883        let dormant = store.connection.query_row(
11884            "SELECT n.exists_now, p.purpose, p.status
11885               FROM nodes AS n
11886               JOIN purposes AS p ON p.node_id = n.id
11887              WHERE n.path = 'src/main.rs'",
11888            [],
11889            |row| {
11890                Ok((
11891                    row.get::<_, i64>(0)?,
11892                    row.get::<_, Option<String>>(1)?,
11893                    row.get::<_, String>(2)?,
11894                ))
11895            },
11896        )?;
11897        require_eq(
11898            &dormant,
11899            &(
11900                0,
11901                Some("Agent-reviewed Rust entry point".to_string()),
11902                PurposeStatus::Approved.as_str().to_string(),
11903            ),
11904            "removed file keeps a dormant approved purpose",
11905        )?;
11906
11907        store.replace_scan(&[
11908            test_folder_node("."),
11909            test_folder_node("src"),
11910            test_file_node("src/renamed.rs", "hash-b"),
11911        ])?;
11912        let renamed = store
11913            .load_node_by_path("src/renamed.rs")?
11914            .ok_or_else(|| io::Error::other("renamed path missing"))?;
11915        require_eq(
11916            &renamed.purpose.status,
11917            &PurposeStatus::Missing,
11918            "rename does not transfer approval",
11919        )?;
11920
11921        store.replace_scan(&[
11922            test_folder_node("."),
11923            test_folder_node("src"),
11924            test_file_node("src/main.rs", "hash-c"),
11925        ])?;
11926        let reactivated = store
11927            .load_node_by_path("src/main.rs")?
11928            .ok_or_else(|| io::Error::other("reactivated path missing"))?;
11929        require_eq(
11930            &reactivated.purpose.purpose,
11931            &Some("Agent-reviewed Rust entry point".to_string()),
11932            "exact-path reactivation restores the dormant purpose",
11933        )?;
11934        require_eq(
11935            &reactivated.purpose.status,
11936            &PurposeStatus::Approved,
11937            "exact-path reactivation restores approval",
11938        )?;
11939        Ok(())
11940    }
11941
11942    #[test]
11943    fn file_token_estimates_are_visited_without_loading_nodes() -> Result<(), Box<dyn Error>> {
11944        let mut store = AtlasStore::in_memory()?;
11945        store.replace_scan(&[
11946            test_file_node("src/a.rs", "hash-a"),
11947            test_file_node("tests/b.rs", "hash-b"),
11948        ])?;
11949
11950        let mut visited = Vec::new();
11951        store.visit_file_token_estimates(Some("src"), |path, size_bytes| {
11952            visited.push((path, size_bytes));
11953            Ok(true)
11954        })?;
11955        require_eq(
11956            &visited,
11957            &vec![("src/a.rs".to_string(), Some(12))],
11958            "folder-scoped token estimate rows",
11959        )?;
11960        Ok(())
11961    }
11962
11963    #[test]
11964    fn indexed_file_text_replaces_and_clears_stale_rows() -> Result<(), Box<dyn Error>> {
11965        let mut store = AtlasStore::in_memory()?;
11966        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
11967        store.replace_file_texts_for_paths(
11968            &["src/main.rs".to_string()],
11969            &[IndexedFileText {
11970                path: "src/main.rs".to_string(),
11971                content_hash: Some("hash-a".to_string()),
11972                byte_count: "needle old\n".len(),
11973                line_count: 1,
11974                content: "needle old\n".to_string(),
11975            }],
11976        )?;
11977        let texts = store.load_file_texts_for_search(Some("needle"), true)?;
11978        require_eq(&texts.len(), &1, "indexed text row count")?;
11979
11980        store.replace_file_texts_for_paths(&["src/main.rs".to_string()], &[])?;
11981        let missing = store.load_file_text("src/main.rs")?;
11982        require_eq(&missing.is_none(), &true, "cleared stale indexed text")?;
11983        Ok(())
11984    }
11985
11986    #[test]
11987    fn indexed_file_text_search_can_stop_without_collecting_all_rows() -> Result<(), Box<dyn Error>>
11988    {
11989        let mut store = AtlasStore::in_memory()?;
11990        store.replace_scan(&[
11991            test_file_node("src/a.rs", "hash-a"),
11992            test_file_node("src/b.rs", "hash-b"),
11993        ])?;
11994        store.replace_file_texts_for_paths(
11995            &["src/a.rs".to_string(), "src/b.rs".to_string()],
11996            &[
11997                IndexedFileText {
11998                    path: "src/a.rs".to_string(),
11999                    content_hash: Some("hash-a".to_string()),
12000                    byte_count: "needle first\n".len(),
12001                    line_count: 1,
12002                    content: "needle first\n".to_string(),
12003                },
12004                IndexedFileText {
12005                    path: "src/b.rs".to_string(),
12006                    content_hash: Some("hash-b".to_string()),
12007                    byte_count: "needle second\n".len(),
12008                    line_count: 1,
12009                    content: "needle second\n".to_string(),
12010                },
12011            ],
12012        )?;
12013
12014        let mut visited = Vec::new();
12015        store.visit_file_texts_for_search(Some("needle"), true, |text| {
12016            visited.push(text.path);
12017            Ok(false)
12018        })?;
12019        require_eq(&visited, &vec!["src/a.rs".to_string()], "early stop rows")?;
12020        Ok(())
12021    }
12022
12023    #[test]
12024    fn file_text_fts_candidates_are_bounded_scoped_safe_and_metadata_only()
12025    -> Result<(), Box<dyn Error>> {
12026        let mut store = AtlasStore::in_memory()?;
12027        store.replace_scan(&[
12028            test_file_node("src/a.rs", "hash-a"),
12029            test_file_node("src/b.rs", "hash-b"),
12030            test_file_node("tests/c.rs", "hash-c"),
12031        ])?;
12032        store.replace_file_texts_for_paths(
12033            &[
12034                "src/a.rs".to_string(),
12035                "src/b.rs".to_string(),
12036                "tests/c.rs".to_string(),
12037            ],
12038            &[
12039                IndexedFileText {
12040                    path: "src/a.rs".to_string(),
12041                    content_hash: Some("hash-a".to_string()),
12042                    byte_count: 13,
12043                    line_count: 1,
12044                    content: "needle alpha\n".to_string(),
12045                },
12046                IndexedFileText {
12047                    path: "src/b.rs".to_string(),
12048                    content_hash: Some("hash-b".to_string()),
12049                    byte_count: 19,
12050                    line_count: 1,
12051                    content: "prefixneedlesuffix\n".to_string(),
12052                },
12053                IndexedFileText {
12054                    path: "tests/c.rs".to_string(),
12055                    content_hash: Some("hash-c".to_string()),
12056                    byte_count: 32,
12057                    line_count: 1,
12058                    content: "needle gamma AND NOT NEAR terms\n".to_string(),
12059                },
12060            ],
12061        )?;
12062
12063        let bounded = store.query_file_text_fts_candidates(
12064            &FileTextFtsQuery {
12065                literal_token: "needle",
12066                path_prefix: None,
12067                limit: 1,
12068            },
12069            None,
12070        )?;
12071        require_eq(&bounded.candidates.len(), &1, "bounded FTS row count")?;
12072        require_eq(&bounded.overflow, &true, "bounded FTS overflow")?;
12073        require(
12074            bounded.candidates[0].bm25.is_finite(),
12075            "FTS rank was non-finite",
12076        )?;
12077        let zero_limit = store.query_file_text_fts_candidates(
12078            &FileTextFtsQuery {
12079                literal_token: "needle",
12080                path_prefix: None,
12081                limit: 0,
12082            },
12083            None,
12084        )?;
12085        require_eq(
12086            &zero_limit,
12087            &FileTextFtsPage {
12088                candidates: Vec::new(),
12089                overflow: true,
12090            },
12091            "zero-limit FTS overflow",
12092        )?;
12093
12094        let scoped = store.query_file_text_fts_candidates(
12095            &FileTextFtsQuery {
12096                literal_token: "needle",
12097                path_prefix: Some("src/"),
12098                limit: 10,
12099            },
12100            None,
12101        )?;
12102        require_eq(
12103            &scoped
12104                .candidates
12105                .iter()
12106                .map(|candidate| candidate.path.as_str())
12107                .collect::<Vec<_>>(),
12108            &vec!["src/a.rs", "src/b.rs"],
12109            "path-scoped FTS rank order",
12110        )?;
12111        let exact_path = store.query_file_text_fts_candidates(
12112            &FileTextFtsQuery {
12113                literal_token: "needle",
12114                path_prefix: Some("src/a.rs"),
12115                limit: 10,
12116            },
12117            None,
12118        )?;
12119        require_eq(
12120            &exact_path.candidates[0].path,
12121            &"src/a.rs".to_string(),
12122            "exact-path FTS scope",
12123        )?;
12124        for reserved_token in ["AND", "NOT", "NEAR"] {
12125            let reserved = store.query_file_text_fts_candidates(
12126                &FileTextFtsQuery {
12127                    literal_token: reserved_token,
12128                    path_prefix: Some("tests/c.rs"),
12129                    limit: 10,
12130                },
12131                None,
12132            )?;
12133            require_eq(
12134                &reserved.candidates.len(),
12135                &1,
12136                "quoted FTS reserved-token candidate",
12137            )?;
12138        }
12139        let mut plan_statement = store.connection.prepare(
12140            "EXPLAIN QUERY PLAN
12141             SELECT f.path, f.content_hash, f.byte_count, f.line_count, bm25(file_text_fts)
12142             FROM file_text_fts
12143             JOIN file_texts AS f ON f.rowid = file_text_fts.rowid
12144             WHERE file_text_fts MATCH ?1
12145             ORDER BY bm25(file_text_fts), f.path
12146             LIMIT ?2",
12147        )?;
12148        let plan_details = plan_statement
12149            .query_map(params!["needle", 11], |row| row.get::<_, String>(3))?
12150            .collect::<Result<Vec<_>, _>>()?;
12151        require(
12152            plan_details
12153                .iter()
12154                .any(|detail| detail.contains("VIRTUAL TABLE INDEX")),
12155            "FTS candidate query did not use the virtual-table index",
12156        )?;
12157        require(
12158            plan_details
12159                .iter()
12160                .any(|detail| detail.contains("INTEGER PRIMARY KEY")),
12161            "FTS candidate query did not hydrate metadata by file_texts rowid",
12162        )?;
12163
12164        for unsafe_token in ["ab", "foo_bar", "needle\" OR content:*", "néedle"] {
12165            let result = store.query_file_text_fts_candidates(
12166                &FileTextFtsQuery {
12167                    literal_token: unsafe_token,
12168                    path_prefix: None,
12169                    limit: 10,
12170                },
12171                None,
12172            );
12173            require(
12174                matches!(result, Err(DbError::FileTextFtsTokenUnsafe { .. })),
12175                "unsafe FTS token was accepted",
12176            )?;
12177        }
12178        let over_cap = store.query_file_text_fts_candidates(
12179            &FileTextFtsQuery {
12180                literal_token: "needle",
12181                path_prefix: None,
12182                limit: MAX_FILE_TEXT_FTS_CANDIDATES + 1,
12183            },
12184            None,
12185        );
12186        require(
12187            matches!(over_cap, Err(DbError::FileTextFtsCandidateLimit { .. })),
12188            "over-cap FTS request was accepted",
12189        )?;
12190
12191        store.connection.execute(
12192            "UPDATE file_texts SET content = x'80' WHERE path = 'src/a.rs'",
12193            [],
12194        )?;
12195        let metadata_only = store.query_file_text_fts_candidates(
12196            &FileTextFtsQuery {
12197                literal_token: "needle",
12198                path_prefix: Some("src/a.rs"),
12199                limit: 1,
12200            },
12201            None,
12202        )?;
12203        require_eq(
12204            &metadata_only.candidates[0].byte_count,
12205            &13,
12206            "metadata-only candidate byte count",
12207        )?;
12208        require(
12209            store.load_file_text("src/a.rs").is_err(),
12210            "content corruption fixture did not prove candidate pre-hydration",
12211        )?;
12212        Ok(())
12213    }
12214
12215    #[test]
12216    fn file_text_fts_mutations_are_atomic_and_state_reports_identity_drift()
12217    -> Result<(), Box<dyn Error>> {
12218        let mut store = AtlasStore::in_memory()?;
12219        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12220        let invalid_metadata = IndexedFileText {
12221            path: "src/main.rs".to_string(),
12222            content_hash: Some("hash-invalid".to_string()),
12223            byte_count: 0,
12224            line_count: 1,
12225            content: "not empty\n".to_string(),
12226        };
12227        require(
12228            matches!(
12229                store.replace_file_texts_for_paths(
12230                    std::slice::from_ref(&invalid_metadata.path),
12231                    std::slice::from_ref(&invalid_metadata),
12232                ),
12233                Err(DbError::FileTextMetadataMismatch {
12234                    field: "byte_count",
12235                    ..
12236                })
12237            ),
12238            "invalid file-text byte metadata was accepted",
12239        )?;
12240        require_eq(
12241            &store.load_file_text(&invalid_metadata.path)?,
12242            &None,
12243            "invalid metadata write rollback",
12244        )?;
12245        let invalid_line_count = IndexedFileText {
12246            byte_count: invalid_metadata.content.len(),
12247            line_count: 0,
12248            ..invalid_metadata.clone()
12249        };
12250        require(
12251            matches!(
12252                store.replace_file_texts_for_paths(
12253                    std::slice::from_ref(&invalid_line_count.path),
12254                    std::slice::from_ref(&invalid_line_count),
12255                ),
12256                Err(DbError::FileTextMetadataMismatch {
12257                    field: "line_count",
12258                    ..
12259                })
12260            ),
12261            "invalid file-text line metadata was accepted",
12262        )?;
12263        let original = IndexedFileText {
12264            path: "src/main.rs".to_string(),
12265            content_hash: Some("hash-a".to_string()),
12266            byte_count: "needle old\n".len(),
12267            line_count: 1,
12268            content: "needle old\n".to_string(),
12269        };
12270        store.replace_file_texts_for_paths(
12271            std::slice::from_ref(&original.path),
12272            std::slice::from_ref(&original),
12273        )?;
12274        require_eq(
12275            &store.file_text_fts_state()?,
12276            &FileTextFtsState {
12277                source_rows: 1,
12278                indexed_rows: 1,
12279                synchronized: true,
12280            },
12281            "initial FTS synchronization state",
12282        )?;
12283
12284        store.connection.execute_batch(
12285            "CREATE TRIGGER fail_file_text_insert
12286             BEFORE INSERT ON file_texts
12287             BEGIN
12288                 SELECT RAISE(ABORT, 'injected file-text failure');
12289             END;",
12290        )?;
12291        let replacement = IndexedFileText {
12292            path: original.path.clone(),
12293            content_hash: Some("hash-b".to_string()),
12294            byte_count: 11,
12295            line_count: 1,
12296            content: "beacon new\n".to_string(),
12297        };
12298        require(
12299            store
12300                .replace_file_texts_for_paths(
12301                    std::slice::from_ref(&replacement.path),
12302                    std::slice::from_ref(&replacement),
12303                )
12304                .is_err(),
12305            "fault-injected replacement unexpectedly committed",
12306        )?;
12307        store
12308            .connection
12309            .execute_batch("DROP TRIGGER fail_file_text_insert")?;
12310        require_eq(
12311            &store.load_file_text(&original.path)?,
12312            &Some(original.clone()),
12313            "rolled-back authoritative text",
12314        )?;
12315        require_eq(
12316            &store
12317                .query_file_text_fts_candidates(
12318                    &FileTextFtsQuery {
12319                        literal_token: "needle",
12320                        path_prefix: None,
12321                        limit: 10,
12322                    },
12323                    None,
12324                )?
12325                .candidates
12326                .len(),
12327            &1,
12328            "rolled-back FTS document",
12329        )?;
12330        require_eq(
12331            &store.file_text_fts_state()?.synchronized,
12332            &true,
12333            "rollback FTS synchronization",
12334        )?;
12335
12336        store.replace_file_texts_for_paths(
12337            std::slice::from_ref(&replacement.path),
12338            std::slice::from_ref(&replacement),
12339        )?;
12340        require_eq(
12341            &store
12342                .query_file_text_fts_candidates(
12343                    &FileTextFtsQuery {
12344                        literal_token: "needle",
12345                        path_prefix: None,
12346                        limit: 10,
12347                    },
12348                    None,
12349                )?
12350                .candidates
12351                .len(),
12352            &0,
12353            "replaced token removed from FTS",
12354        )?;
12355        require_eq(
12356            &store
12357                .query_file_text_fts_candidates(
12358                    &FileTextFtsQuery {
12359                        literal_token: "beacon",
12360                        path_prefix: None,
12361                        limit: 10,
12362                    },
12363                    None,
12364                )?
12365                .candidates
12366                .len(),
12367            &1,
12368            "replacement token added to FTS",
12369        )?;
12370
12371        store.connection.execute(
12372            "INSERT INTO file_text_fts(file_text_fts, rowid, content)
12373             SELECT 'delete', rowid, content FROM file_texts WHERE path = ?1",
12374            [&replacement.path],
12375        )?;
12376        require_eq(
12377            &store.file_text_fts_state()?,
12378            &FileTextFtsState {
12379                source_rows: 1,
12380                indexed_rows: 0,
12381                synchronized: false,
12382            },
12383            "desynchronized FTS identity state",
12384        )?;
12385        store.connection.execute(
12386            "INSERT INTO file_text_fts(file_text_fts) VALUES('rebuild')",
12387            [],
12388        )?;
12389        let (source_revision, projection_revision) =
12390            load_file_text_fts_revisions(&store.connection)?.ok_or_else(|| {
12391                io::Error::other("FTS revisions disappeared after synchronized writes")
12392            })?;
12393        require_eq(
12394            &source_revision,
12395            &projection_revision,
12396            "synchronized FTS revisions",
12397        )?;
12398        set_metadata(
12399            &store.connection,
12400            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12401            &projection_revision.saturating_sub(1).to_string(),
12402        )?;
12403        require_eq(
12404            &store.file_text_fts_ready()?,
12405            &false,
12406            "incomplete FTS revision readiness",
12407        )?;
12408        set_metadata(
12409            &store.connection,
12410            FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12411            &source_revision.to_string(),
12412        )?;
12413        require_eq(
12414            &store.file_text_fts_ready()?,
12415            &true,
12416            "restored FTS revision readiness",
12417        )?;
12418        store.mark_paths_absent(&["src".to_string()])?;
12419        require_eq(
12420            &store.file_text_fts_state()?,
12421            &FileTextFtsState {
12422                source_rows: 0,
12423                indexed_rows: 0,
12424                synchronized: true,
12425            },
12426            "absent-path FTS synchronization",
12427        )?;
12428
12429        store.replace_scan(&[test_file_node("src/main.rs", "hash-c")])?;
12430        let rescanned = IndexedFileText {
12431            path: "src/main.rs".to_string(),
12432            content_hash: Some("hash-c".to_string()),
12433            byte_count: 13,
12434            line_count: 1,
12435            content: "rescan token\n".to_string(),
12436        };
12437        store.replace_file_texts_for_paths(
12438            std::slice::from_ref(&rescanned.path),
12439            std::slice::from_ref(&rescanned),
12440        )?;
12441        store.replace_scan(&[])?;
12442        require_eq(
12443            &store.file_text_fts_state()?,
12444            &FileTextFtsState {
12445                source_rows: 0,
12446                indexed_rows: 0,
12447                synchronized: true,
12448            },
12449            "full-scan deletion FTS synchronization",
12450        )?;
12451        Ok(())
12452    }
12453
12454    #[cfg(windows)]
12455    #[test]
12456    fn file_text_fts_migration_backfills_existing_text_and_survives_reopen()
12457    -> Result<(), Box<dyn Error>> {
12458        let temp = tempfile::tempdir()?;
12459        let root = temp.path().join("project");
12460        fs::create_dir(&root)?;
12461        let database = temp.path().join("atlas.db");
12462        let mut store = AtlasStore::open_for_project(&database, &root)?;
12463        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12464        store.replace_file_texts_for_paths(
12465            &["src/main.rs".to_string()],
12466            &[IndexedFileText {
12467                path: "src/main.rs".to_string(),
12468                content_hash: Some("hash-a".to_string()),
12469                byte_count: 17,
12470                line_count: 1,
12471                content: "migration needle\n".to_string(),
12472            }],
12473        )?;
12474        store.connection.execute_batch(
12475            "DROP TABLE file_content_classifications;
12476             DROP INDEX idx_nodes_path_kind;
12477             DROP TABLE usage_instance_worktree_origins;
12478             DROP TABLE worktree_usage_aggregates;
12479             DROP TABLE worktree_registrations;
12480             DROP TABLE usage_aggregate_revisions;
12481             DROP TABLE project_root_identity;
12482             DROP TABLE IF EXISTS graph_identity_rejections;",
12483        )?;
12484        crate::schema::recreate_disposable_graph_projection(&store.connection, false)?;
12485        crate::schema::recreate_pre_selector_symbol_storage_for_test(&store.connection)?;
12486        store.connection.execute_batch(
12487            "DROP INDEX idx_symbol_import_alias_lookup;
12488             DROP TABLE file_text_fts;",
12489        )?;
12490        store.connection.execute(
12491            "DELETE FROM metadata WHERE key IN (?1, ?2)",
12492            params![
12493                FILE_TEXT_FTS_SOURCE_REVISION_KEY,
12494                FILE_TEXT_FTS_PROJECTION_REVISION_KEY,
12495            ],
12496        )?;
12497        set_metadata(
12498            &store.connection,
12499            SCHEMA_VERSION_KEY,
12500            &crate::schema::COVERAGE_DISCOVERY_SCHEMA_VERSION.to_string(),
12501        )?;
12502        drop(store);
12503
12504        let migrated = AtlasStore::open_for_project(&database, &root)?;
12505        require_eq(
12506            &migrated.file_text_fts_state()?,
12507            &FileTextFtsState {
12508                source_rows: 1,
12509                indexed_rows: 1,
12510                synchronized: true,
12511            },
12512            "migrated FTS synchronization state",
12513        )?;
12514        require_eq(
12515            &migrated
12516                .query_file_text_fts_candidates(
12517                    &FileTextFtsQuery {
12518                        literal_token: "needle",
12519                        path_prefix: None,
12520                        limit: 10,
12521                    },
12522                    None,
12523                )?
12524                .candidates[0]
12525                .path,
12526            &"src/main.rs".to_string(),
12527            "migrated FTS backfill candidate",
12528        )?;
12529        drop(migrated);
12530
12531        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
12532        require_eq(
12533            &reopened.file_text_fts_state()?.synchronized,
12534            &true,
12535            "reopened FTS synchronization",
12536        )?;
12537        require_eq(
12538            &reopened
12539                .query_file_text_fts_candidates(
12540                    &FileTextFtsQuery {
12541                        literal_token: "migration",
12542                        path_prefix: Some("src"),
12543                        limit: 1,
12544                    },
12545                    None,
12546                )?
12547                .candidates
12548                .len(),
12549            &1,
12550            "reopened FTS candidate count",
12551        )?;
12552        Ok(())
12553    }
12554
12555    #[test]
12556    fn sqlite_read_progress_interrupts_active_repository_traversal_and_clears_handler()
12557    -> Result<(), Box<dyn Error>> {
12558        let store = AtlasStore::in_memory()?;
12559        let control = IndexWorkControl::with_deadline(
12560            projectatlas_core::IndexCancellation::new(),
12561            Instant::now() + std::time::Duration::from_millis(50),
12562        );
12563        let interrupted = with_sqlite_read_progress(
12564            &store.connection,
12565            Some(&control),
12566            IndexWorkStage::RepositoryTraversal,
12567            || {
12568                store
12569                    .connection
12570                    .query_row(
12571                        "WITH RECURSIVE numbers(value) AS (
12572                             VALUES(1)
12573                             UNION ALL
12574                             SELECT value + 1 FROM numbers WHERE value < 100000000
12575                         )
12576                         SELECT SUM(value) FROM numbers",
12577                        [],
12578                        |row| row.get::<_, i64>(0),
12579                    )
12580                    .map_err(DbError::from)
12581            },
12582        );
12583        require(
12584            matches!(
12585                interrupted,
12586                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12587                    stage: IndexWorkStage::RepositoryTraversal
12588                }))
12589            ),
12590            "active SQLite traversal was not interrupted with its typed deadline",
12591        )?;
12592        require_eq(
12593            &store
12594                .connection
12595                .query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?,
12596            &1,
12597            "cleared repository traversal progress handler",
12598        )?;
12599        store
12600            .connection
12601            .execute("CREATE TEMP TABLE follow_up(value INTEGER NOT NULL)", [])?;
12602        store
12603            .connection
12604            .execute("INSERT INTO follow_up(value) VALUES(1)", [])?;
12605        Ok(())
12606    }
12607
12608    #[cfg(feature = "sqlite-progress-test-observer")]
12609    #[test]
12610    fn sqlite_read_progress_propagates_cancellation_during_an_active_batch()
12611    -> Result<(), Box<dyn Error>> {
12612        use crate::sqlite_progress_test_observer::{
12613            SqliteReadProgressEvent, observe_sqlite_read_progress,
12614        };
12615        use std::cell::Cell;
12616        use std::rc::Rc;
12617
12618        let store = AtlasStore::in_memory()?;
12619        let cancellation = projectatlas_core::IndexCancellation::new();
12620        let control = IndexWorkControl::new(cancellation.clone(), None);
12621        let callback_seen = Rc::new(Cell::new(false));
12622        let cancelled = observe_sqlite_read_progress(
12623            {
12624                let callback_seen = Rc::clone(&callback_seen);
12625                move |event| {
12626                    if matches!(
12627                        event,
12628                        SqliteReadProgressEvent::CallbackEntered {
12629                            stage: IndexWorkStage::RepositoryTraversal
12630                        }
12631                    ) {
12632                        callback_seen.set(true);
12633                        cancellation.cancel();
12634                    }
12635                }
12636            },
12637            || {
12638                with_sqlite_read_progress(
12639                    &store.connection,
12640                    Some(&control),
12641                    IndexWorkStage::RepositoryTraversal,
12642                    || {
12643                        store
12644                            .connection
12645                            .query_row(
12646                                "WITH RECURSIVE numbers(value) AS (
12647                                     VALUES(1)
12648                                     UNION ALL
12649                                     SELECT value + 1 FROM numbers WHERE value < 100000000
12650                                 )
12651                                 SELECT SUM(value) FROM numbers",
12652                                [],
12653                                |row| row.get::<_, i64>(0),
12654                            )
12655                            .map_err(DbError::from)
12656                    },
12657                )
12658            },
12659        );
12660        require(
12661            callback_seen.get()
12662                && matches!(
12663                    cancelled,
12664                    Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
12665                        stage: IndexWorkStage::RepositoryTraversal
12666                    }))
12667                ),
12668            "active SQLite batch did not propagate typed cancellation",
12669        )?;
12670        store.connection.execute(
12671            "CREATE TEMP TABLE cancellation_follow_up(value INTEGER)",
12672            [],
12673        )?;
12674        Ok(())
12675    }
12676
12677    #[test]
12678    fn fallback_admission_precedes_content_decode_and_clears_progress_handlers()
12679    -> Result<(), Box<dyn Error>> {
12680        let mut store = AtlasStore::in_memory()?;
12681        store.replace_scan(&[test_file_node("src/good.rs", "hash-good")])?;
12682        store.replace_file_texts_for_paths(
12683            &["src/good.rs".to_string()],
12684            &[IndexedFileText {
12685                path: "src/good.rs".to_string(),
12686                content_hash: Some("hash-good".to_string()),
12687                byte_count: 12,
12688                line_count: 1,
12689                content: "needle good\n".to_string(),
12690            }],
12691        )?;
12692        store.connection.execute_batch(
12693            "INSERT INTO nodes(path, kind, exists_now) VALUES('src/bad.rs', 'file', 1);
12694             INSERT INTO file_content_classifications(path, classification)
12695                VALUES('src/bad.rs', 'source');
12696             INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
12697                VALUES('src/bad.rs', 'hash-bad', 1, 1, x'80');",
12698        )?;
12699
12700        let mut admitted = Vec::new();
12701        let mut visited = Vec::new();
12702        store.visit_file_texts_for_fallback(
12703            Some("src"),
12704            None,
12705            |metadata| {
12706                admitted.push((metadata.path.clone(), metadata.classification));
12707                Ok(if metadata.path == "src/bad.rs" {
12708                    FileTextAdmission::Skip
12709                } else {
12710                    FileTextAdmission::Read
12711                })
12712            },
12713            |text| {
12714                visited.push(text.path);
12715                Ok(true)
12716            },
12717        )?;
12718        require_eq(
12719            &admitted,
12720            &vec![
12721                ("src/bad.rs".to_string(), ContentClassification::Source),
12722                ("src/good.rs".to_string(), ContentClassification::Opaque),
12723            ],
12724            "fallback metadata admission rows",
12725        )?;
12726        require_eq(
12727            &visited,
12728            &vec!["src/good.rs".to_string()],
12729            "fallback decoded rows",
12730        )?;
12731        let mut scoped_plan = store.connection.prepare(&format!(
12732            "EXPLAIN QUERY PLAN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"
12733        ))?;
12734        let scoped_plan_details = scoped_plan
12735            .query_map(params!["src", "src/", "src0"], |row| {
12736                row.get::<_, String>(3)
12737            })?
12738            .collect::<Result<Vec<_>, _>>()?;
12739        require(
12740            scoped_plan_details
12741                .iter()
12742                .any(|detail| detail.contains("sqlite_autoindex_file_texts_1"))
12743                && scoped_plan_details.iter().any(|detail| {
12744                    detail.contains("sqlite_autoindex_file_content_classifications_1")
12745                })
12746                && scoped_plan_details.iter().all(|detail| {
12747                    !detail.contains("SCAN text") && !detail.contains("SCAN classification")
12748                }),
12749            "path-scoped fallback did not use bounded binary index ranges",
12750        )?;
12751        let mut scoped_opcodes = store
12752            .connection
12753            .prepare(&format!("EXPLAIN {FILE_TEXT_FALLBACK_SCOPED_METADATA_SQL}"))?;
12754        let scoped_opcode_rows = scoped_opcodes
12755            .query_map(params!["src", "src/", "src0"], |row| {
12756                Ok((
12757                    row.get::<_, String>(1)?,
12758                    row.get::<_, i64>(2)?,
12759                    row.get::<_, i64>(3)?,
12760                    row.get::<_, Option<String>>(5)?,
12761                ))
12762            })?
12763            .collect::<Result<Vec<_>, _>>()?;
12764        let file_text_table_cursors = scoped_opcode_rows
12765            .iter()
12766            .filter(|(opcode, _, _, shape)| opcode == "DeferredSeek" && shape.is_some())
12767            .map(|(_, _, table_cursor, _)| *table_cursor)
12768            .collect::<Vec<_>>();
12769        require(
12770            !file_text_table_cursors.is_empty()
12771                && scoped_opcode_rows
12772                    .iter()
12773                    .all(|(opcode, cursor, column, _)| {
12774                        opcode != "Column"
12775                            || !file_text_table_cursors.contains(cursor)
12776                            || *column != 4
12777                    }),
12778            &format!(
12779                "fallback metadata cursor read source content before admission: {scoped_opcode_rows:?}"
12780            ),
12781        )?;
12782        require(
12783            store
12784                .visit_file_texts_for_fallback(
12785                    Some("src/bad.rs"),
12786                    None,
12787                    |_| Ok(FileTextAdmission::Read),
12788                    |_| Ok(true),
12789                )
12790                .is_err(),
12791            "invalid source content decoded without error",
12792        )?;
12793
12794        let success_cancellation = projectatlas_core::IndexCancellation::new();
12795        let success_control = IndexWorkControl::new(success_cancellation.clone(), None);
12796        store.query_file_text_fts_candidates(
12797            &FileTextFtsQuery {
12798                literal_token: "needle",
12799                path_prefix: None,
12800                limit: 10,
12801            },
12802            Some(&success_control),
12803        )?;
12804        success_cancellation.cancel();
12805        let recursive_sum = store.connection.query_row(
12806            "WITH RECURSIVE numbers(value) AS (
12807                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
12808             ) SELECT SUM(value) FROM numbers",
12809            [],
12810            |row| row.get::<_, i64>(0),
12811        )?;
12812        require_eq(
12813            &recursive_sum,
12814            &12_502_500,
12815            "cleared success progress handler",
12816        )?;
12817
12818        let cancellation = projectatlas_core::IndexCancellation::new();
12819        let control = IndexWorkControl::new(cancellation.clone(), None);
12820        let mut rows_seen = 0;
12821        let cancelled = store.visit_file_texts_for_fallback(
12822            Some("src"),
12823            Some(&control),
12824            |_| {
12825                rows_seen += 1;
12826                cancellation.cancel();
12827                Ok(FileTextAdmission::Skip)
12828            },
12829            |_| Ok(true),
12830        );
12831        require_eq(&rows_seen, &1, "rows before fallback cancellation")?;
12832        require(
12833            matches!(
12834                cancelled,
12835                Err(DbError::IndexWork(IndexWorkFailure::Cancelled {
12836                    stage: IndexWorkStage::TextIndex
12837                }))
12838            ),
12839            "fallback cancellation was not typed",
12840        )?;
12841        let post_error_sum = store.connection.query_row(
12842            "WITH RECURSIVE numbers(value) AS (
12843                 VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 5000
12844             ) SELECT SUM(value) FROM numbers",
12845            [],
12846            |row| row.get::<_, i64>(0),
12847        )?;
12848        require_eq(
12849            &post_error_sum,
12850            &12_502_500,
12851            "cleared error progress handler",
12852        )?;
12853
12854        let expired = IndexWorkControl::with_deadline(
12855            projectatlas_core::IndexCancellation::new(),
12856            Instant::now(),
12857        );
12858        let deadline = store.query_file_text_fts_candidates(
12859            &FileTextFtsQuery {
12860                literal_token: "needle",
12861                path_prefix: None,
12862                limit: 10,
12863            },
12864            Some(&expired),
12865        );
12866        require(
12867            matches!(
12868                deadline,
12869                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
12870                    stage: IndexWorkStage::TextIndex
12871                }))
12872            ),
12873            "FTS deadline was not typed",
12874        )?;
12875        store.connection.execute(
12876            "DELETE FROM file_content_classifications WHERE path = 'src/good.rs'",
12877            [],
12878        )?;
12879        let missing_classification = store.visit_file_texts_for_fallback(
12880            Some("src/good.rs"),
12881            None,
12882            |_| Ok(FileTextAdmission::Read),
12883            |_| Ok(true),
12884        );
12885        require(
12886            matches!(
12887                missing_classification,
12888                Err(DbError::FileContentClassificationMissing { .. })
12889            ),
12890            "fallback omitted a text row whose predecode classification was missing",
12891        )?;
12892        Ok(())
12893    }
12894
12895    #[test]
12896    fn suggested_purpose_is_not_approved() -> Result<(), Box<dyn Error>> {
12897        let mut store = AtlasStore::in_memory()?;
12898        let node = Node {
12899            path: "src/main.rs".to_string(),
12900            kind: NodeKind::File,
12901            parent_path: normalized_parent("src/main.rs"),
12902            extension: Some(".rs".to_string()),
12903            language: Some("rust".to_string()),
12904            size_bytes: Some(12),
12905            mtime_ns: Some(10),
12906            content_hash: Some("abc".to_string()),
12907        };
12908        store.replace_scan(&[node])?;
12909        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
12910        let nodes = store.load_nodes()?;
12911        require_eq(
12912            &nodes[0].purpose.source,
12913            &PurposeSource::Generated,
12914            "suggested source",
12915        )?;
12916        require_eq(
12917            &nodes[0].purpose.status,
12918            &PurposeStatus::Suggested,
12919            "suggested status",
12920        )?;
12921        store.set_purpose(
12922            "src/main.rs",
12923            "Application entry point",
12924            PurposeSource::Agent,
12925        )?;
12926        let nodes = store.load_nodes()?;
12927        require_eq(
12928            &nodes[0].purpose.status,
12929            &PurposeStatus::Approved,
12930            "agent-approved status",
12931        )?;
12932        store.set_suggested_purpose("src/main.rs", "Late generated suggestion")?;
12933        let nodes = store.load_nodes()?;
12934        require_eq(
12935            &nodes[0].purpose.purpose,
12936            &Some("Application entry point".to_string()),
12937            "approved purpose survives a late suggestion",
12938        )?;
12939        require_eq(
12940            &nodes[0].purpose.source,
12941            &PurposeSource::Agent,
12942            "approved purpose source survives a late suggestion",
12943        )?;
12944        require_eq(
12945            &nodes[0].purpose.status,
12946            &PurposeStatus::Approved,
12947            "approved purpose status survives a late suggestion",
12948        )?;
12949
12950        store.connection.execute(
12951            "
12952            UPDATE purposes
12953            SET status = ?1
12954            WHERE node_id = (SELECT id FROM nodes WHERE path = ?2)
12955            ",
12956            params![PurposeStatus::Stale.as_str(), "src/main.rs"],
12957        )?;
12958        store.set_suggested_purpose("src/main.rs", "Later generated suggestion")?;
12959        let nodes = store.load_nodes()?;
12960        require_eq(
12961            &nodes[0].purpose.purpose,
12962            &Some("Application entry point".to_string()),
12963            "stale reviewed purpose survives a late suggestion",
12964        )?;
12965        require_eq(
12966            &nodes[0].purpose.source,
12967            &PurposeSource::Agent,
12968            "stale reviewed source survives a late suggestion",
12969        )?;
12970        require_eq(
12971            &nodes[0].purpose.status,
12972            &PurposeStatus::Stale,
12973            "stale reviewed status survives a late suggestion",
12974        )?;
12975        Ok(())
12976    }
12977
12978    #[test]
12979    fn agent_reviewed_marker_depends_on_agent_approved_source() -> Result<(), Box<dyn Error>> {
12980        let mut store = AtlasStore::in_memory()?;
12981        store.replace_scan(&[test_file_node("src/main.rs", "hash-a")])?;
12982
12983        store.set_suggested_purpose("src/main.rs", "Maybe application entry point")?;
12984        let nodes = store.load_nodes()?;
12985        require_eq(
12986            &nodes[0].purpose.agent_reviewed(),
12987            &false,
12988            "generated suggestion is not agent reviewed",
12989        )?;
12990
12991        store.set_purpose(
12992            "src/main.rs",
12993            "Imported application entry point",
12994            PurposeSource::Imported,
12995        )?;
12996        let nodes = store.load_nodes()?;
12997        require_eq(
12998            &nodes[0].purpose.agent_reviewed(),
12999            &false,
13000            "imported purpose is not agent reviewed",
13001        )?;
13002
13003        store.set_purpose(
13004            "src/main.rs",
13005            "Agent-reviewed application entry point",
13006            PurposeSource::Agent,
13007        )?;
13008        let nodes = store.load_nodes()?;
13009        require_eq(
13010            &nodes[0].purpose.agent_reviewed(),
13011            &true,
13012            "agent-approved purpose is agent reviewed",
13013        )?;
13014
13015        store.connection.execute(
13016            "
13017            UPDATE purposes
13018            SET source = 'human'
13019            WHERE node_id = (SELECT id FROM nodes WHERE path = 'src/main.rs')
13020            ",
13021            [],
13022        )?;
13023        let nodes = store.load_nodes()?;
13024        require_eq(
13025            &nodes[0].purpose.source,
13026            &PurposeSource::Agent,
13027            "legacy human source normalizes to agent",
13028        )?;
13029        require_eq(
13030            &nodes[0].purpose.agent_reviewed(),
13031            &true,
13032            "legacy approved human row remains reviewed",
13033        )?;
13034
13035        store.replace_scan(&[test_file_node("src/main.rs", "hash-b")])?;
13036        let nodes = store.load_nodes()?;
13037        require_eq(
13038            &nodes[0].purpose.status,
13039            &PurposeStatus::Approved,
13040            "changed reviewed purpose stays approved",
13041        )?;
13042        require_eq(
13043            &nodes[0].purpose.agent_reviewed(),
13044            &true,
13045            "changed approved purpose remains agent reviewed",
13046        )?;
13047        Ok(())
13048    }
13049
13050    #[test]
13051    fn purpose_curation_batch_coalesces_current_unapproved_rows() -> Result<(), Box<dyn Error>> {
13052        let temp = tempfile::tempdir()?;
13053        let root = temp.path().join("project");
13054        fs::create_dir(&root)?;
13055        let database = temp.path().join("projectatlas.db");
13056        let mut store = AtlasStore::open_for_project(&database, &root)?;
13057        store.replace_scan(&[
13058            test_file_node("src/a.rs", "hash-a"),
13059            test_file_node("src/b.rs", "hash-b"),
13060            test_file_node("src/accepted.rs", "hash-accepted"),
13061        ])?;
13062        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13063        store.set_purpose("src/accepted.rs", "Accepted purpose", PurposeSource::Agent)?;
13064
13065        let selected = vec![
13066            "src/b.rs".to_string(),
13067            "src/a.rs".to_string(),
13068            "src/a.rs".to_string(),
13069            "src/accepted.rs".to_string(),
13070        ];
13071        let first = store.load_purpose_curation_batch(" issue   308 ", &selected)?;
13072        let second = store.load_purpose_curation_batch(
13073            "issue 308",
13074            &selected.iter().rev().cloned().collect::<Vec<_>>(),
13075        )?;
13076        require_eq(&first.task, &"issue 308".to_string(), "normalized task")?;
13077        require_eq(&first.work_key, &second.work_key, "deterministic batch key")?;
13078        require_eq(&first.items, &second.items, "deterministic candidate rows")?;
13079        require_eq(&first.items.len(), &2, "coalesced unapproved row count")?;
13080        require_eq(
13081            &first
13082                .items
13083                .iter()
13084                .map(|item| item.node.node.path.as_str())
13085                .collect::<Vec<_>>(),
13086            &vec!["src/a.rs", "src/b.rs"],
13087            "sorted actionable paths",
13088        )?;
13089        require_eq(
13090            &first
13091                .items
13092                .iter()
13093                .all(|item| item.work_key.len() == 64 && item.state_token.len() == 64),
13094            &true,
13095            "bounded opaque item identities",
13096        )?;
13097
13098        let page = store.purpose_curation_findings_page_current(&HealthQuery {
13099            start_index: 0,
13100            limit: 20,
13101            category: None,
13102            severity: Some(Severity::Warning),
13103            path_prefix: None,
13104            summary_only: false,
13105            scope: HealthScope::all(),
13106        })?;
13107        require_eq(&page.total, &2, "actionable queue count")?;
13108        require_eq(
13109            &page
13110                .findings
13111                .iter()
13112                .any(|finding| finding.path == "src/accepted.rs"),
13113            &false,
13114            "accepted purpose omitted from automatic curation",
13115        )?;
13116
13117        store.set_purpose(
13118            "src/accepted.rs",
13119            "Deliberately corrected purpose",
13120            PurposeSource::Agent,
13121        )?;
13122        let corrected = store
13123            .load_node_by_path("src/accepted.rs")?
13124            .ok_or_else(|| io::Error::other("corrected purpose path disappeared"))?;
13125        require_eq(
13126            &corrected.purpose.purpose,
13127            &Some("Deliberately corrected purpose".to_string()),
13128            "explicit accepted-purpose correction",
13129        )?;
13130        Ok(())
13131    }
13132
13133    #[test]
13134    fn explicit_purpose_rollback_reports_storage_failure() -> Result<(), Box<dyn Error>> {
13135        let temp = tempfile::tempdir()?;
13136        let root = temp.path().join("project");
13137        fs::create_dir(&root)?;
13138        let database = temp.path().join("projectatlas.db");
13139        let mut store = AtlasStore::open_for_project(&database, &root)?;
13140        store.replace_scan(&[test_file_node("source.rs", "hash")])?;
13141        let before_revision = store.authored_purpose_revision()?;
13142        let transaction = store.begin_purpose_mutation()?;
13143        store.set_purpose("source.rs", "Rejected purpose", PurposeSource::Agent)?;
13144        store.connection.execute_batch("ROLLBACK")?;
13145
13146        require(
13147            matches!(transaction.rollback(), Err(DbError::Sqlite(_))),
13148            "explicit purpose rollback suppressed its SQLite failure",
13149        )?;
13150        require_eq(
13151            &store.authored_purpose_revision()?,
13152            &before_revision,
13153            "manually rolled-back purpose revision",
13154        )?;
13155        require(
13156            store
13157                .load_node_by_path("source.rs")?
13158                .is_none_or(|node| node.purpose.purpose.as_deref() != Some("Rejected purpose")),
13159            "manual rollback retained the rejected purpose",
13160        )
13161    }
13162
13163    #[test]
13164    fn conditional_purpose_batch_is_atomic_and_rejects_changed_work() -> Result<(), Box<dyn Error>>
13165    {
13166        let temp = tempfile::tempdir()?;
13167        let root = temp.path().join("project");
13168        fs::create_dir(&root)?;
13169        let database = temp.path().join("projectatlas.db");
13170        let mut store = AtlasStore::open_for_project(&database, &root)?;
13171        let nodes = vec![
13172            test_file_node("src/a.rs", "hash-a"),
13173            test_file_node("src/b.rs", "hash-b"),
13174            test_file_node("src/changed.rs", "hash-changed"),
13175            test_file_node("src/accepted.rs", "hash-accepted"),
13176            test_file_node("src/generation.rs", "hash-generation"),
13177            test_file_node("src/rollback-a.rs", "hash-rollback-a"),
13178            test_file_node("src/rollback-b.rs", "hash-rollback-b"),
13179        ];
13180        store.replace_scan(&nodes)?;
13181        store.set_suggested_purpose("src/b.rs", "Generated B suggestion")?;
13182        store.set_suggested_purpose("src/changed.rs", "Original suggestion")?;
13183        require_eq(
13184            &store.authored_purpose_revision()?,
13185            &0,
13186            "suggestions do not advance authored-purpose revision",
13187        )?;
13188        let task = "issue-308-purpose-curation";
13189
13190        let apply_batch = store
13191            .load_purpose_curation_batch(task, &["src/a.rs".to_string(), "src/b.rs".to_string()])?;
13192        let requests = apply_batch
13193            .items
13194            .iter()
13195            .map(|candidate| {
13196                conditional_purpose_request(
13197                    task,
13198                    candidate,
13199                    &format!("Reviewed {}", candidate.node.node.path),
13200                )
13201            })
13202            .collect::<Vec<_>>();
13203        let applied = store.conditionally_set_purposes(&requests)?;
13204        require_eq(
13205            &applied
13206                .iter()
13207                .map(|result| result.state)
13208                .collect::<Vec<_>>(),
13209            &vec![
13210                PurposeConditionalApplyState::Applied,
13211                PurposeConditionalApplyState::Applied,
13212            ],
13213            "one-transaction batch outcomes",
13214        )?;
13215        require_eq(
13216            &applied.iter().all(|result| {
13217                result.current_purpose.as_ref().is_some_and(|purpose| {
13218                    purpose.status == PurposeStatus::Approved
13219                        && purpose.source == PurposeSource::Agent
13220                })
13221            }),
13222            &true,
13223            "applied transaction-owned purpose metadata",
13224        )?;
13225        require_eq(
13226            &store.authored_purpose_revision()?,
13227            &1,
13228            "one accepted batch advances one authored-purpose revision",
13229        )?;
13230
13231        let changed = store
13232            .load_purpose_curation_batch(task, &["src/changed.rs".to_string()])?
13233            .items
13234            .into_iter()
13235            .next()
13236            .ok_or_else(|| io::Error::other("changed candidate missing"))?;
13237        store.set_suggested_purpose("src/changed.rs", "Concurrent suggestion")?;
13238        let changed_result = store
13239            .conditionally_set_purposes(&[conditional_purpose_request(
13240                task,
13241                &changed,
13242                "Stale curator answer",
13243            )])?
13244            .pop()
13245            .ok_or_else(|| io::Error::other("changed conditional result missing"))?;
13246        require_eq(
13247            &changed_result.state,
13248            &PurposeConditionalApplyState::Stale,
13249            "changed suggestion state",
13250        )?;
13251        require_eq(
13252            &changed_result
13253                .current_purpose
13254                .as_ref()
13255                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13256            &Some((
13257                PurposeStatus::Suggested,
13258                PurposeSource::Generated,
13259                Some("Concurrent suggestion"),
13260            )),
13261            "stale transaction-owned purpose metadata",
13262        )?;
13263        require_eq(
13264            &store.authored_purpose_revision()?,
13265            &1,
13266            "stale conditional purpose leaves revision unchanged",
13267        )?;
13268
13269        let accepted = store
13270            .load_purpose_curation_batch(task, &["src/accepted.rs".to_string()])?
13271            .items
13272            .into_iter()
13273            .next()
13274            .ok_or_else(|| io::Error::other("accepted candidate missing"))?;
13275        store.set_purpose(
13276            "src/accepted.rs",
13277            "Concurrent accepted purpose",
13278            PurposeSource::Agent,
13279        )?;
13280        require_eq(
13281            &store.authored_purpose_revision()?,
13282            &2,
13283            "explicit accepted correction advances the authored-purpose revision",
13284        )?;
13285        store.set_purpose(
13286            "src/accepted.rs",
13287            "Concurrent accepted purpose",
13288            PurposeSource::Agent,
13289        )?;
13290        require_eq(
13291            &store.authored_purpose_revision()?,
13292            &2,
13293            "identical accepted purpose leaves the authored-purpose revision unchanged",
13294        )?;
13295        let accepted_result = store
13296            .conditionally_set_purposes(&[conditional_purpose_request(
13297                task,
13298                &accepted,
13299                "Stale curator overwrite",
13300            )])?
13301            .pop()
13302            .ok_or_else(|| io::Error::other("accepted conditional result missing"))?;
13303        require_eq(
13304            &accepted_result.state,
13305            &PurposeConditionalApplyState::Accepted,
13306            "accepted concurrent state",
13307        )?;
13308        require_eq(
13309            &accepted_result
13310                .current_purpose
13311                .as_ref()
13312                .map(|purpose| (purpose.status, purpose.source, purpose.purpose.as_deref())),
13313            &Some((
13314                PurposeStatus::Approved,
13315                PurposeSource::Agent,
13316                Some("Concurrent accepted purpose"),
13317            )),
13318            "accepted transaction-owned purpose metadata",
13319        )?;
13320        require_eq(
13321            &store.authored_purpose_revision()?,
13322            &2,
13323            "accepted conditional no-op leaves revision unchanged",
13324        )?;
13325
13326        let unavailable_result = store
13327            .conditionally_set_purposes(&[PurposeConditionalApplyRequest {
13328                task: task.to_string(),
13329                path: "src/unavailable.rs".to_string(),
13330                work_key: "unavailable-work".to_string(),
13331                state_token: "unavailable-state".to_string(),
13332                purpose: "Unavailable purpose".to_string(),
13333            }])?
13334            .pop()
13335            .ok_or_else(|| io::Error::other("unavailable conditional result missing"))?;
13336        require_eq(
13337            &unavailable_result.state,
13338            &PurposeConditionalApplyState::PathUnavailable,
13339            "unavailable path state",
13340        )?;
13341        require_eq(
13342            &unavailable_result.current_purpose,
13343            &None,
13344            "unavailable transaction-owned purpose metadata",
13345        )?;
13346
13347        let generation = store
13348            .load_purpose_curation_batch(task, &["src/generation.rs".to_string()])?
13349            .items
13350            .into_iter()
13351            .next()
13352            .ok_or_else(|| io::Error::other("generation candidate missing"))?;
13353        let project = store
13354            .project_instance_id()?
13355            .ok_or_else(|| io::Error::other("project identity missing"))?;
13356        {
13357            let mut publication = store.begin_index_publication("purpose-curation-test")?;
13358            publication.begin_scan_replacement()?;
13359            publication.upsert_scan_node_batch(&nodes)?;
13360            publication.finish_scan_replacement()?;
13361            publication.replace_repository_graph(project, &[], &[], &[], &[])?;
13362            publication.complete()?;
13363        }
13364        let generation_state = store.conditionally_set_purpose(
13365            task,
13366            "src/generation.rs",
13367            &generation.work_key,
13368            &generation.state_token,
13369            "Prior-generation answer",
13370        )?;
13371        require_eq(
13372            &generation_state,
13373            &PurposeConditionalApplyState::Stale,
13374            "generation-bound state",
13375        )?;
13376
13377        let current = store.load_nodes_by_paths(&[
13378            "src/changed.rs".to_string(),
13379            "src/accepted.rs".to_string(),
13380            "src/generation.rs".to_string(),
13381        ])?;
13382        let purposes = current
13383            .iter()
13384            .map(|node| {
13385                (
13386                    node.node.path.as_str(),
13387                    node.purpose.purpose.as_deref(),
13388                    node.purpose.status,
13389                )
13390            })
13391            .collect::<Vec<_>>();
13392        require_eq(
13393            &purposes,
13394            &vec![
13395                (
13396                    "src/accepted.rs",
13397                    Some("Concurrent accepted purpose"),
13398                    PurposeStatus::Approved,
13399                ),
13400                (
13401                    "src/changed.rs",
13402                    Some("Concurrent suggestion"),
13403                    PurposeStatus::Suggested,
13404                ),
13405                ("src/generation.rs", None, PurposeStatus::Missing),
13406            ],
13407            "conflicting rows remain untouched",
13408        )?;
13409
13410        let rollback_batch = store.load_purpose_curation_batch(
13411            task,
13412            &[
13413                "src/rollback-a.rs".to_string(),
13414                "src/rollback-b.rs".to_string(),
13415            ],
13416        )?;
13417        let rollback_requests = rollback_batch
13418            .items
13419            .iter()
13420            .map(|candidate| {
13421                conditional_purpose_request(
13422                    task,
13423                    candidate,
13424                    &format!("Reviewed {}", candidate.node.node.path),
13425                )
13426            })
13427            .collect::<Vec<_>>();
13428        store.connection.execute_batch(
13429            "
13430            CREATE TEMP TRIGGER abort_second_conditional_purpose_update
13431            BEFORE UPDATE ON purposes
13432            WHEN OLD.node_id = (
13433                SELECT id FROM nodes WHERE path = 'src/rollback-b.rs'
13434            )
13435            BEGIN
13436                SELECT RAISE(ABORT, 'forced conditional purpose rollback');
13437            END;
13438            ",
13439        )?;
13440        if store.conditionally_set_purposes(&rollback_requests).is_ok() {
13441            return Err(io::Error::other("injected conditional batch failure succeeded").into());
13442        }
13443        drop(store);
13444        let reopened = AtlasStore::open_for_project(&database, &root)?;
13445        let rolled_back = reopened.load_nodes_by_paths(&[
13446            "src/rollback-a.rs".to_string(),
13447            "src/rollback-b.rs".to_string(),
13448        ])?;
13449        require_eq(
13450            &rolled_back
13451                .iter()
13452                .map(|node| node.purpose.status)
13453                .collect::<Vec<_>>(),
13454            &vec![PurposeStatus::Missing, PurposeStatus::Missing],
13455            "failed conditional batch rolled back after reopen",
13456        )?;
13457        Ok(())
13458    }
13459
13460    #[test]
13461    fn concurrent_purpose_curators_cannot_overwrite_the_winner() -> Result<(), Box<dyn Error>> {
13462        let temp = tempfile::tempdir()?;
13463        let root = temp.path().join("project");
13464        fs::create_dir(&root)?;
13465        let database = temp.path().join("projectatlas.db");
13466        let mut store = AtlasStore::open_for_project(&database, &root)?;
13467        store.replace_scan(&[test_file_node("src/main.rs", "hash-main")])?;
13468        let candidate = store
13469            .load_purpose_curation_batch("concurrent-curators", &["src/main.rs".to_string()])?
13470            .items
13471            .into_iter()
13472            .next()
13473            .ok_or_else(|| io::Error::other("concurrent candidate missing"))?;
13474        drop(store);
13475
13476        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
13477        let mut handles = Vec::new();
13478        for purpose in ["First reviewed purpose", "Second reviewed purpose"] {
13479            let database = database.clone();
13480            let root = root.clone();
13481            let barrier = std::sync::Arc::clone(&barrier);
13482            let candidate = candidate.clone();
13483            handles.push(std::thread::spawn(move || {
13484                let store = AtlasStore::open_for_project(&database, &root)?;
13485                barrier.wait();
13486                store.conditionally_set_purpose(
13487                    "concurrent-curators",
13488                    "src/main.rs",
13489                    &candidate.work_key,
13490                    &candidate.state_token,
13491                    purpose,
13492                )
13493            }));
13494        }
13495        let outcomes = handles
13496            .into_iter()
13497            .map(|handle| {
13498                handle
13499                    .join()
13500                    .map_err(|_panic| io::Error::other("curator thread panicked"))?
13501                    .map_err(io::Error::other)
13502            })
13503            .collect::<Result<Vec<_>, io::Error>>()?;
13504        require_eq(
13505            &outcomes
13506                .iter()
13507                .filter(|state| **state == PurposeConditionalApplyState::Applied)
13508                .count(),
13509            &1,
13510            "single concurrent winner",
13511        )?;
13512        require_eq(
13513            &outcomes
13514                .iter()
13515                .filter(|state| **state == PurposeConditionalApplyState::Accepted)
13516                .count(),
13517            &1,
13518            "accepted concurrent loser",
13519        )?;
13520
13521        let reopened = AtlasStore::open_for_project(&database, &root)?;
13522        let node = reopened
13523            .load_node_by_path("src/main.rs")?
13524            .ok_or_else(|| io::Error::other("concurrent path disappeared"))?;
13525        require_eq(
13526            &node.purpose.status,
13527            &PurposeStatus::Approved,
13528            "persisted concurrent winner status",
13529        )?;
13530        require_eq(
13531            &matches!(
13532                node.purpose.purpose.as_deref(),
13533                Some("First reviewed purpose" | "Second reviewed purpose")
13534            ),
13535            &true,
13536            "persisted concurrent winner value",
13537        )?;
13538        Ok(())
13539    }
13540
13541    #[test]
13542    fn purpose_curation_hydration_uses_existing_unique_indexes() -> Result<(), Box<dyn Error>> {
13543        let mut store = AtlasStore::in_memory()?;
13544        store.replace_scan(&[
13545            test_file_node("src/a.rs", "hash-a"),
13546            test_file_node("src/b.rs", "hash-b"),
13547        ])?;
13548        let sql = format!("EXPLAIN QUERY PLAN {}", load_nodes_by_paths_sql(2));
13549        let mut statement = store.connection.prepare(&sql)?;
13550        let details = statement
13551            .query_map(params!["src/a.rs", "src/b.rs"], |row| {
13552                row.get::<_, String>(3)
13553            })?
13554            .collect::<Result<Vec<_>, _>>()?;
13555        let plan = details.join("\n");
13556        require_eq(
13557            &plan.contains("SEARCH n USING INDEX sqlite_autoindex_nodes_"),
13558            &true,
13559            "unique path index query plan",
13560        )?;
13561        require_eq(
13562            &plan.contains("SEARCH p USING INTEGER PRIMARY KEY"),
13563            &true,
13564            "purpose primary-key query plan",
13565        )?;
13566        require_eq(&plan.contains("SCAN n"), &false, "no node-table scan")?;
13567        Ok(())
13568    }
13569
13570    #[test]
13571    fn updates_content_summary_without_approving_purpose() -> Result<(), Box<dyn Error>> {
13572        let mut store = AtlasStore::in_memory()?;
13573        let node = Node {
13574            path: "src/lib.rs".to_string(),
13575            kind: NodeKind::File,
13576            parent_path: normalized_parent("src/lib.rs"),
13577            extension: Some(".rs".to_string()),
13578            language: Some("rust".to_string()),
13579            size_bytes: Some(24),
13580            mtime_ns: Some(10),
13581            content_hash: Some("def".to_string()),
13582        };
13583        store.replace_scan(&[node])?;
13584        store.set_node_summary(
13585            "src/lib.rs",
13586            "rust source defining library entry functions.",
13587        )?;
13588        let nodes = store.load_nodes()?;
13589        require_eq(
13590            &nodes[0].summary,
13591            &Some("rust source defining library entry functions.".to_string()),
13592            "updated content summary",
13593        )?;
13594        require_eq(
13595            &nodes[0].purpose.status,
13596            &PurposeStatus::Missing,
13597            "summary update does not approve purpose",
13598        )?;
13599        require_eq(
13600            &store.has_agent_approved_purpose()?,
13601            &false,
13602            "missing purpose does not activate graph hydration",
13603        )?;
13604        store.set_purpose(
13605            "src/lib.rs",
13606            "Owns the library entry points.",
13607            PurposeSource::Agent,
13608        )?;
13609        require_eq(
13610            &store.has_agent_approved_purpose()?,
13611            &true,
13612            "agent-approved purpose activates graph hydration",
13613        )?;
13614        Ok(())
13615    }
13616
13617    #[test]
13618    fn replaces_symbol_graph_idempotently() -> Result<(), Box<dyn Error>> {
13619        let mut store = AtlasStore::in_memory()?;
13620        let graph = SymbolGraph {
13621            path: "src/main.rs".to_string(),
13622            language: Some("rust".to_string()),
13623            parser: ParserKind::TreeSitter,
13624            symbols: vec![CodeSymbol {
13625                path: "src/main.rs".to_string(),
13626                language: Some("rust".to_string()),
13627                name: "main".to_string(),
13628                kind: SymbolKind::Function,
13629                signature: "fn main()".to_string(),
13630                exported: true,
13631                documentation: Some("Run the application.".to_string()),
13632                line_start: 1,
13633                line_end: 3,
13634                source_selector: None,
13635                parent: None,
13636                parser: ParserKind::TreeSitter,
13637                detail: Some("function_item".to_string()),
13638            }],
13639            relations: vec![SymbolRelation {
13640                path: "src/main.rs".to_string(),
13641                source_name: "main".to_string(),
13642                target_name: "println!".to_string(),
13643                kind: RelationKind::Calls,
13644                line: 2,
13645                context: "println!(\"hello\")".to_string(),
13646                parser: ParserKind::TreeSitter,
13647            }],
13648        };
13649
13650        store.replace_symbol_graph(&graph)?;
13651        store.replace_symbol_graph(&graph)?;
13652        let symbols = store.load_symbols(Some("src/main.rs"), Some("main"), 10)?;
13653        let relations = store.load_symbol_relations(Some("src/main.rs"), Some("println"), 10)?;
13654        let metadata = store
13655            .load_source_parse_metadata("src/main.rs")?
13656            .ok_or_else(|| io::Error::other("missing source parse metadata"))?;
13657        require_eq(&symbols.len(), &1, "symbol count after replace")?;
13658        require_eq(&relations.len(), &1, "relation count after replace")?;
13659        require_eq(&metadata.parser, &ParserKind::TreeSitter, "metadata parser")?;
13660        require_eq(&metadata.symbol_count, &1, "metadata symbol count")?;
13661        require_eq(&metadata.relation_count, &1, "metadata relation count")?;
13662        require_eq(&symbols[0].exported, &true, "exported metadata")?;
13663        require_eq(
13664            &symbols[0].documentation,
13665            &Some("Run the application.".to_string()),
13666            "documentation metadata",
13667        )?;
13668        Ok(())
13669    }
13670
13671    #[test]
13672    fn symbol_source_selectors_round_trip_all_read_paths_and_reopen() -> Result<(), Box<dyn Error>>
13673    {
13674        let temp = tempfile::tempdir()?;
13675        let root = temp.path().join("repository");
13676        fs::create_dir(&root)?;
13677        let database = temp.path().join("projectatlas.db");
13678        let path = "docs/guide.md";
13679        let expected = SymbolSourceSelector {
13680            byte_start: 17,
13681            byte_end: 31,
13682            column_start: 4,
13683            column_end: 2,
13684        };
13685        let graph = SymbolGraph {
13686            path: path.to_string(),
13687            language: Some("markdown".to_string()),
13688            parser: ParserKind::Structural,
13689            symbols: vec![
13690                CodeSymbol {
13691                    path: path.to_string(),
13692                    language: Some("markdown".to_string()),
13693                    name: "Overview".to_string(),
13694                    kind: SymbolKind::Heading,
13695                    signature: "overview#1".to_string(),
13696                    exported: false,
13697                    documentation: None,
13698                    line_start: 2,
13699                    line_end: 3,
13700                    source_selector: Some(expected),
13701                    parent: None,
13702                    parser: ParserKind::Structural,
13703                    detail: Some("level=2".to_string()),
13704                },
13705                CodeSymbol {
13706                    path: path.to_string(),
13707                    language: Some("markdown".to_string()),
13708                    name: "Compatibility".to_string(),
13709                    kind: SymbolKind::Heading,
13710                    signature: "compatibility#1".to_string(),
13711                    exported: false,
13712                    documentation: None,
13713                    line_start: 6,
13714                    line_end: 6,
13715                    source_selector: None,
13716                    parent: None,
13717                    parser: ParserKind::Structural,
13718                    detail: Some("level=2".to_string()),
13719                },
13720            ],
13721            relations: Vec::new(),
13722        };
13723
13724        let mut store = AtlasStore::open_for_project(&database, &root)?;
13725        store.replace_scan(&[test_file_node(path, "guide-hash")])?;
13726        store.replace_symbol_graph(&graph)?;
13727        let mut publication = store.begin_index_publication("symbol-source-selectors")?;
13728        publication.upsert_file_content_classification_batch(&[FileContentClassification {
13729            path: path.to_string(),
13730            classification: ContentClassification::Documentation,
13731        }])?;
13732        publication.complete()?;
13733        verify_symbol_source_selector_read_paths(&store, path, expected)?;
13734        drop(store);
13735
13736        let reopened = AtlasStore::open_for_project(&database, &root)?;
13737        verify_symbol_source_selector_read_paths(&reopened, path, expected)?;
13738        drop(reopened);
13739
13740        let read_only = AtlasStore::open_read_only_for_project(&database, &root)?;
13741        verify_symbol_source_selector_read_paths(&read_only, path, expected)?;
13742        read_only.finish_index_read_snapshot()?;
13743        Ok(())
13744    }
13745
13746    #[test]
13747    fn symbol_source_selector_constraints_and_corrupt_reads_fail_closed()
13748    -> Result<(), Box<dyn Error>> {
13749        let mut store = AtlasStore::in_memory()?;
13750        let path = "docs/guide.md";
13751        store.replace_symbol_graph(&SymbolGraph {
13752            path: path.to_string(),
13753            language: Some("markdown".to_string()),
13754            parser: ParserKind::Structural,
13755            symbols: vec![CodeSymbol {
13756                path: path.to_string(),
13757                language: Some("markdown".to_string()),
13758                name: "Overview".to_string(),
13759                kind: SymbolKind::Heading,
13760                signature: "overview#1".to_string(),
13761                exported: false,
13762                documentation: None,
13763                line_start: 2,
13764                line_end: 2,
13765                source_selector: None,
13766                parent: None,
13767                parser: ParserKind::Structural,
13768                detail: None,
13769            }],
13770            relations: Vec::new(),
13771        })?;
13772
13773        for invalid_update in [
13774            "UPDATE symbols SET source_byte_start = 0 WHERE path = 'docs/guide.md'",
13775            "UPDATE symbols SET source_byte_start = -1, source_byte_end = 4,
13776                                source_column_start = 0, source_column_end = 4
13777              WHERE path = 'docs/guide.md'",
13778            "UPDATE symbols SET source_byte_start = 5, source_byte_end = 4,
13779                                source_column_start = 0, source_column_end = 4
13780              WHERE path = 'docs/guide.md'",
13781            "UPDATE symbols SET source_byte_start = 0, source_byte_end = 4,
13782                                source_column_start = 5, source_column_end = 4
13783              WHERE path = 'docs/guide.md'",
13784        ] {
13785            require(
13786                store.connection.execute(invalid_update, []).is_err(),
13787                "invalid source selector bypassed its table constraint",
13788            )?;
13789        }
13790        store.connection.execute(
13791            "UPDATE symbols
13792                SET source_byte_start = 0, source_byte_end = 4,
13793                    source_column_start = 0, source_column_end = 4
13794              WHERE path = ?1",
13795            [path],
13796        )?;
13797        require_symbol_source_selectors(
13798            &store.load_symbols(Some(path), None, 10)?,
13799            Some(SymbolSourceSelector {
13800                byte_start: 0,
13801                byte_end: 4,
13802                column_start: 0,
13803                column_end: 4,
13804            }),
13805            "valid constrained selector",
13806        )?;
13807
13808        store.connection.execute_batch(
13809            "PRAGMA ignore_check_constraints = ON;
13810             UPDATE symbols
13811                SET source_byte_start = 0, source_byte_end = NULL,
13812                    source_column_start = 0, source_column_end = 4
13813              WHERE path = 'docs/guide.md';
13814             PRAGMA ignore_check_constraints = OFF;",
13815        )?;
13816        let corrupt = store.load_symbols(Some(path), None, 10);
13817        require(
13818            matches!(corrupt, Err(DbError::Sqlite(_))),
13819            "partially populated source selector did not fail closed",
13820        )?;
13821        Ok(())
13822    }
13823
13824    #[test]
13825    fn symbol_source_selector_write_failure_rolls_back_graph() -> Result<(), Box<dyn Error>> {
13826        let mut store = AtlasStore::in_memory()?;
13827        let path = "docs/guide.md";
13828        let previous = SymbolGraph {
13829            path: path.to_string(),
13830            language: Some("markdown".to_string()),
13831            parser: ParserKind::Structural,
13832            symbols: vec![batch_test_symbol(path, "previous", 1, 0)],
13833            relations: Vec::new(),
13834        };
13835        store.replace_symbol_graph(&previous)?;
13836
13837        let mut valid = batch_test_symbol(path, "valid", 2, 0);
13838        valid.source_selector = Some(SymbolSourceSelector {
13839            byte_start: 4,
13840            byte_end: 9,
13841            column_start: 0,
13842            column_end: 5,
13843        });
13844        let mut invalid = batch_test_symbol(path, "invalid", 3, 0);
13845        invalid.source_selector = Some(SymbolSourceSelector {
13846            byte_start: 12,
13847            byte_end: 11,
13848            column_start: 0,
13849            column_end: 5,
13850        });
13851        let replacement = SymbolGraph {
13852            path: path.to_string(),
13853            language: Some("markdown".to_string()),
13854            parser: ParserKind::Structural,
13855            symbols: vec![valid, invalid],
13856            relations: Vec::new(),
13857        };
13858        let Err(error) = store.replace_symbol_graph(&replacement) else {
13859            return Err(io::Error::other("invalid selector replacement committed").into());
13860        };
13861        require(
13862            matches!(error, DbError::SymbolGraphRowShape { .. }),
13863            "invalid selector replacement returned the wrong error",
13864        )?;
13865        require_eq(
13866            &store.load_symbol_graphs_for_paths(&[path.to_string()])?,
13867            &vec![previous],
13868            "selector failure transaction rollback",
13869        )?;
13870        Ok(())
13871    }
13872
13873    #[test]
13874    fn classified_symbol_list_filters_before_limit_and_preserves_legacy_order()
13875    -> Result<(), Box<dyn Error>> {
13876        let mut store = AtlasStore::in_memory()?;
13877        let classified_paths = [
13878            (
13879                "config/first.toml",
13880                ContentClassification::ConfigurationData,
13881            ),
13882            ("docs/guide.md", ContentClassification::Documentation),
13883            ("other/notes.txt", ContentClassification::OtherText),
13884            ("src/lib.rs", ContentClassification::Source),
13885            ("src/second.rs", ContentClassification::Source),
13886        ];
13887        let nodes = classified_paths
13888            .iter()
13889            .enumerate()
13890            .map(|(index, (path, _))| test_file_node(path, &format!("hash-{index}")))
13891            .collect::<Vec<_>>();
13892        store.replace_scan(&nodes)?;
13893        for (index, (path, _)) in classified_paths.iter().enumerate() {
13894            store.replace_symbol_graph(&SymbolGraph {
13895                path: (*path).to_string(),
13896                language: Some("rust".to_string()),
13897                parser: ParserKind::TreeSitter,
13898                symbols: vec![batch_test_symbol(path, &format!("needle_{index}"), 1, 0)],
13899                relations: Vec::new(),
13900            })?;
13901        }
13902        let classifications = classified_paths
13903            .iter()
13904            .map(|(path, classification)| FileContentClassification {
13905                path: (*path).to_string(),
13906                classification: *classification,
13907            })
13908            .collect::<Vec<_>>();
13909        let mut publication = store.begin_index_publication("classified-symbol-list")?;
13910        publication.upsert_file_content_classification_batch(&classifications)?;
13911        publication.complete()?;
13912
13913        for (file, query, limit) in [
13914            (None, None, 3),
13915            (None, Some("needle"), 3),
13916            (Some("src/second.rs"), None, 10),
13917            (Some("src/second.rs"), Some("needle_4"), 10),
13918            (Some("src/second.rs"), Some("second"), 10),
13919            (None, None, 0),
13920        ] {
13921            let legacy = store.load_symbols(file, query, limit)?;
13922            let classified = store.load_classified_symbols(
13923                file,
13924                query,
13925                ContentSelection::UnspecifiedLegacy,
13926                limit,
13927            )?;
13928            require_eq(
13929                &classified
13930                    .into_iter()
13931                    .map(|row| row.symbol)
13932                    .collect::<Vec<_>>(),
13933                &legacy,
13934                "omitted classified symbol candidates and order",
13935            )?;
13936        }
13937        let omitted = store.load_classified_symbols(
13938            None,
13939            Some("needle"),
13940            ContentSelection::UnspecifiedLegacy,
13941            3,
13942        )?;
13943        require_eq(
13944            &omitted
13945                .iter()
13946                .map(|row| row.classification)
13947                .collect::<Vec<_>>(),
13948            &vec![
13949                ContentClassification::ConfigurationData,
13950                ContentClassification::Documentation,
13951                ContentClassification::OtherText,
13952            ],
13953            "additive legacy classifications",
13954        )?;
13955
13956        let source =
13957            store.load_classified_symbols(None, Some("needle"), ContentSelection::Source, 1)?;
13958        require_eq(
13959            &source.first().map(|row| row.symbol.path.as_str()),
13960            &Some("src/lib.rs"),
13961            "source selection before limit",
13962        )?;
13963        let documentation = store.load_classified_symbols(
13964            None,
13965            Some("needle"),
13966            ContentSelection::Documentation,
13967            1,
13968        )?;
13969        require_eq(
13970            &documentation.first().map(|row| row.symbol.path.as_str()),
13971            &Some("docs/guide.md"),
13972            "documentation selection before limit",
13973        )?;
13974        let both =
13975            store.load_classified_symbols(None, Some("needle"), ContentSelection::Both, 2)?;
13976        require_eq(
13977            &both
13978                .iter()
13979                .map(|row| row.symbol.path.as_str())
13980                .collect::<Vec<_>>(),
13981            &vec!["docs/guide.md", "src/lib.rs"],
13982            "combined selection order",
13983        )?;
13984        let file_query = store.load_classified_symbols(
13985            Some("src/second.rs"),
13986            Some("needle_4"),
13987            ContentSelection::Source,
13988            10,
13989        )?;
13990        require_eq(&file_query.len(), &1, "file/query classified symbol filter")?;
13991        Ok(())
13992    }
13993
13994    #[test]
13995    fn classified_symbol_list_rejects_missing_owner_and_uses_existing_indexes()
13996    -> Result<(), Box<dyn Error>> {
13997        let mut store = AtlasStore::in_memory()?;
13998        store.replace_scan(&[test_file_node("src/lib.rs", "hash")])?;
13999        store.replace_symbol_graph(&SymbolGraph {
14000            path: "src/lib.rs".to_string(),
14001            language: Some("rust".to_string()),
14002            parser: ParserKind::TreeSitter,
14003            symbols: vec![batch_test_symbol("src/lib.rs", "needle", 1, 0)],
14004            relations: Vec::new(),
14005        })?;
14006
14007        for selection in [
14008            ContentSelection::UnspecifiedLegacy,
14009            ContentSelection::Source,
14010            ContentSelection::Both,
14011        ] {
14012            let (sql, bindings) = classified_symbols_sql(None, Some("needle"), selection, 1);
14013            let mut statement = store
14014                .connection
14015                .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
14016            let plan = statement
14017                .query_map(params_from_iter(bindings.iter()), |row| {
14018                    row.get::<_, String>(3)
14019                })?
14020                .collect::<Result<Vec<_>, _>>()?
14021                .join("\n");
14022            require(
14023                plan.contains("idx_symbols_path")
14024                    && plan.contains("sqlite_autoindex_file_content_classifications_1")
14025                    && !plan.contains("SCAN classification"),
14026                &format!("classified symbol query missed existing indexes: {plan}"),
14027            )?;
14028        }
14029
14030        store.connection.execute(
14031            "DELETE FROM file_content_classifications WHERE path = 'src/lib.rs'",
14032            [],
14033        )?;
14034        for selection in [
14035            ContentSelection::UnspecifiedLegacy,
14036            ContentSelection::Source,
14037            ContentSelection::Both,
14038        ] {
14039            let Err(error) = store.load_classified_symbols(Some("src/lib.rs"), None, selection, 10)
14040            else {
14041                return Err(
14042                    io::Error::other("symbol without a file classification was accepted").into(),
14043                );
14044            };
14045            require(
14046                matches!(error, DbError::FileContentClassificationMissing { .. }),
14047                "missing symbol classification returned the wrong error",
14048            )?;
14049        }
14050        Ok(())
14051    }
14052
14053    #[test]
14054    fn import_alias_lookup_uses_kind_first_covering_index() -> Result<(), Box<dyn Error>> {
14055        let mut store = AtlasStore::in_memory()?;
14056        store.replace_symbol_graph(&SymbolGraph {
14057            path: "src/main.rs".to_string(),
14058            language: Some("rust".to_string()),
14059            parser: ParserKind::TreeSitter,
14060            symbols: Vec::new(),
14061            relations: vec![
14062                SymbolRelation {
14063                    path: "src/main.rs".to_string(),
14064                    source_name: "main".to_string(),
14065                    target_name: "use crate::service::run".to_string(),
14066                    kind: RelationKind::Imports,
14067                    line: 1,
14068                    context: "use crate::service::run;".to_string(),
14069                    parser: ParserKind::TreeSitter,
14070                },
14071                SymbolRelation {
14072                    path: "src/main.rs".to_string(),
14073                    source_name: "main".to_string(),
14074                    target_name: "use crate::other::Thing".to_string(),
14075                    kind: RelationKind::Imports,
14076                    line: 2,
14077                    context: "use crate::other::Thing;".to_string(),
14078                    parser: ParserKind::TreeSitter,
14079                },
14080            ],
14081        })?;
14082
14083        let relations =
14084            store.load_import_relations_matching_targets(&["service".to_string()], 10)?;
14085        require_eq(&relations.len(), &1, "matched import relation count")?;
14086        require_eq(
14087            &store
14088                .load_import_relations_for_path("src/main.rs", 10)?
14089                .len(),
14090            &2,
14091            "exact-path import relation count",
14092        )?;
14093        let mut statement = store.connection.prepare(
14094            "EXPLAIN QUERY PLAN
14095             SELECT path, source_name, target_name, line
14096             FROM symbol_relations INDEXED BY idx_symbol_import_alias_lookup
14097             WHERE kind = 'imports' AND target_name LIKE '%service%' ESCAPE '\\'
14098             ORDER BY path, line, source_name, target_name
14099             LIMIT 10",
14100        )?;
14101        let plan = statement
14102            .query_map([], |row| row.get::<_, String>(3))?
14103            .collect::<Result<Vec<_>, _>>()?
14104            .join("\n");
14105        require(
14106            plan.contains(
14107                "SEARCH symbol_relations USING COVERING INDEX idx_symbol_import_alias_lookup (kind=?)",
14108            ),
14109            &format!("import alias lookup missed kind-first covering index: {plan}"),
14110        )?;
14111        Ok(())
14112    }
14113
14114    #[test]
14115    fn file_scoped_symbol_kind_lookup_uses_path_index() -> Result<(), Box<dyn Error>> {
14116        let store = AtlasStore::in_memory()?;
14117        for sql in [
14118            "EXPLAIN QUERY PLAN
14119             SELECT path, language, name, kind, signature, line_start, line_end,
14120                    parent, parser, detail, exported, documentation,
14121                    source_byte_start, source_byte_end,
14122                    source_column_start, source_column_end
14123             FROM symbols INDEXED BY idx_symbols_path
14124             WHERE path = 'src/lib.rs' AND kind IN ('function')
14125             ORDER BY path, line_start, name
14126             LIMIT 25",
14127            "EXPLAIN QUERY PLAN
14128             SELECT COUNT(*) FROM symbols INDEXED BY idx_symbols_path
14129             WHERE path = 'src/lib.rs' AND kind IN ('function')",
14130        ] {
14131            let mut statement = store.connection.prepare(sql)?;
14132            let plan = statement
14133                .query_map([], |row| row.get::<_, String>(3))?
14134                .collect::<Result<Vec<_>, _>>()?
14135                .join("\n");
14136            require(
14137                plan.contains("SEARCH symbols USING INDEX idx_symbols_path (path=?)"),
14138                &format!("file-scoped symbol lookup missed exact-path index: {plan}"),
14139            )?;
14140        }
14141        Ok(())
14142    }
14143
14144    #[test]
14145    fn preserves_source_parse_and_fact_parser_provenance_independently()
14146    -> Result<(), Box<dyn Error>> {
14147        let temp = tempfile::tempdir()?;
14148        let database = temp.path().join("projectatlas.db");
14149        let mut store = AtlasStore::open(&database)?;
14150        let graph = SymbolGraph {
14151            path: "src/optional.lang".to_string(),
14152            language: Some("optional-language".to_string()),
14153            parser: ParserKind::Fallback,
14154            symbols: vec![CodeSymbol {
14155                path: "src/optional.lang".to_string(),
14156                language: Some("optional-language".to_string()),
14157                name: "entry".to_string(),
14158                kind: SymbolKind::Function,
14159                signature: "entry()".to_string(),
14160                exported: false,
14161                documentation: None,
14162                line_start: 1,
14163                line_end: 1,
14164                source_selector: None,
14165                parent: None,
14166                parser: ParserKind::Fallback,
14167                detail: None,
14168            }],
14169            relations: vec![SymbolRelation {
14170                path: "src/optional.lang".to_string(),
14171                source_name: "entry".to_string(),
14172                target_name: "helper".to_string(),
14173                kind: RelationKind::Calls,
14174                line: 1,
14175                context: "entry()".to_string(),
14176                parser: ParserKind::Fallback,
14177            }],
14178        };
14179        let metadata = SourceParseMetadata {
14180            path: graph.path.clone(),
14181            language: graph.language.clone(),
14182            parser: ParserKind::TreeSitter,
14183            symbol_count: graph.symbols.len(),
14184            relation_count: graph.relations.len(),
14185        };
14186
14187        store.replace_symbol_graph_with_metadata(&graph, &metadata)?;
14188
14189        let stored_metadata = store
14190            .load_source_parse_metadata(&graph.path)?
14191            .ok_or_else(|| io::Error::other("missing independent source parse metadata"))?;
14192        let symbols = store.load_symbols(Some(&graph.path), Some("entry"), 10)?;
14193        let relations = store.load_symbol_relations(Some(&graph.path), Some("helper"), 10)?;
14194        require_eq(
14195            &stored_metadata.parser,
14196            &ParserKind::TreeSitter,
14197            "grammar-backed source parser",
14198        )?;
14199        require_eq(
14200            &symbols[0].parser,
14201            &ParserKind::Fallback,
14202            "fallback symbol provenance",
14203        )?;
14204        require_eq(
14205            &relations[0].parser,
14206            &ParserKind::Fallback,
14207            "fallback relation provenance",
14208        )?;
14209
14210        let invalid_metadata = SourceParseMetadata {
14211            symbol_count: 0,
14212            ..metadata
14213        };
14214        let Err(error) = store.replace_symbol_graph_with_metadata(&graph, &invalid_metadata) else {
14215            return Err(io::Error::other("mismatched explicit metadata was accepted").into());
14216        };
14217        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14218            return Err(io::Error::other(format!(
14219                "mismatched explicit metadata returned the wrong error: {error}"
14220            ))
14221            .into());
14222        }
14223
14224        let empty_graph = SymbolGraph {
14225            path: "src/empty.optional".to_string(),
14226            language: Some("optional-language".to_string()),
14227            parser: ParserKind::Fallback,
14228            symbols: Vec::new(),
14229            relations: Vec::new(),
14230        };
14231        let empty_metadata = SourceParseMetadata {
14232            path: empty_graph.path.clone(),
14233            language: empty_graph.language.clone(),
14234            parser: ParserKind::TreeSitter,
14235            symbol_count: 0,
14236            relation_count: 0,
14237        };
14238        store.replace_symbol_graph_with_metadata(&empty_graph, &empty_metadata)?;
14239        drop(store);
14240
14241        let reader = AtlasStore::open_read_only(&database)?;
14242        let reopened_metadata = reader
14243            .load_source_parse_metadata(&graph.path)?
14244            .ok_or_else(|| io::Error::other("missing reopened source parse metadata"))?;
14245        let metadata_paths = reader.source_parse_metadata_paths_for_paths(&[
14246            "src/missing.optional".to_string(),
14247            empty_graph.path.clone(),
14248            graph.path.clone(),
14249            graph.path.clone(),
14250        ])?;
14251        let reopened_graphs =
14252            reader.load_symbol_graphs_for_paths(&[graph.path.clone(), empty_graph.path.clone()])?;
14253        require_eq(
14254            &reopened_metadata.parser,
14255            &ParserKind::TreeSitter,
14256            "reopened source parser provenance",
14257        )?;
14258        require_eq(
14259            &metadata_paths,
14260            &HashSet::from([graph.path.clone(), empty_graph.path.clone()]),
14261            "batched source parse metadata paths",
14262        )?;
14263        require_eq(
14264            &reopened_graphs,
14265            &vec![empty_graph, graph],
14266            "reopened fact graph provenance",
14267        )?;
14268        reader.finish_index_read_snapshot()?;
14269        Ok(())
14270    }
14271
14272    #[test]
14273    fn reconstructs_exact_symbol_graph_batches_from_disk_and_fails_closed()
14274    -> Result<(), Box<dyn Error>> {
14275        let temp = tempfile::tempdir()?;
14276        let db_path = temp.path().join("projectatlas.db");
14277        let mut store = AtlasStore::open(&db_path)?;
14278        store.replace_scan(&[
14279            test_file_node("src/a.rs", "hash-a"),
14280            test_file_node("src/b.rs", "hash-b"),
14281        ])?;
14282        let graph = SymbolGraph {
14283            path: "src/a.rs".to_string(),
14284            language: Some("rust".to_string()),
14285            parser: ParserKind::TreeSitter,
14286            symbols: vec![CodeSymbol {
14287                path: "src/a.rs".to_string(),
14288                language: Some("rust".to_string()),
14289                name: "owner".to_string(),
14290                kind: SymbolKind::Function,
14291                signature: "fn owner()".to_string(),
14292                exported: true,
14293                documentation: None,
14294                line_start: 1,
14295                line_end: 2,
14296                source_selector: None,
14297                parent: None,
14298                parser: ParserKind::TreeSitter,
14299                detail: Some("function_item".to_string()),
14300            }],
14301            relations: vec![SymbolRelation {
14302                path: "src/a.rs".to_string(),
14303                source_name: "owner".to_string(),
14304                target_name: "dependency".to_string(),
14305                kind: RelationKind::Calls,
14306                line: 2,
14307                context: "dependency()".to_string(),
14308                parser: ParserKind::TreeSitter,
14309            }],
14310        };
14311        store.replace_symbol_graph(&graph)?;
14312        let empty_graph = SymbolGraph {
14313            path: "src/b.rs".to_string(),
14314            language: Some("rust".to_string()),
14315            parser: ParserKind::TreeSitter,
14316            symbols: Vec::new(),
14317            relations: Vec::new(),
14318        };
14319        store.replace_symbol_graph(&empty_graph)?;
14320        drop(store);
14321
14322        let reader = AtlasStore::open_read_only(&db_path)?;
14323        let loaded = reader.load_symbol_graphs_for_paths(&[
14324            "src/b.rs".to_string(),
14325            "src/a.rs".to_string(),
14326            "src/a.rs".to_string(),
14327            "src/missing.rs".to_string(),
14328        ])?;
14329        require_eq(
14330            &loaded,
14331            &vec![graph, empty_graph],
14332            "batched symbol graph round trip",
14333        )?;
14334        for (table, expected_index) in [
14335            (
14336                "source_parse_metadata",
14337                "sqlite_autoindex_source_parse_metadata_1",
14338            ),
14339            ("symbols", "idx_symbols_path"),
14340            ("symbol_relations", "idx_symbol_relations_path"),
14341        ] {
14342            let sql = format!(
14343                "EXPLAIN QUERY PLAN SELECT path FROM {table}
14344                  WHERE path IN ('src/a.rs', 'src/b.rs')"
14345            );
14346            let mut statement = reader.connection.prepare(&sql)?;
14347            let plan = statement
14348                .query_map([], |row| row.get::<_, String>(3))?
14349                .collect::<Result<Vec<_>, _>>()?
14350                .join("\n");
14351            if !plan.contains(expected_index) {
14352                return Err(io::Error::other(format!(
14353                    "{table} batch lookup missed {expected_index}: {plan}"
14354                ))
14355                .into());
14356            }
14357        }
14358        reader.finish_index_read_snapshot()?;
14359        drop(reader);
14360
14361        let writer = AtlasStore::open(&db_path)?;
14362        writer.connection.execute(
14363            "UPDATE source_parse_metadata SET symbol_count = 2 WHERE path = 'src/a.rs'",
14364            [],
14365        )?;
14366        let Err(error) = writer.load_symbol_graphs_for_paths(&["src/a.rs".to_string()]) else {
14367            return Err(io::Error::other("metadata count corruption was accepted").into());
14368        };
14369        if !matches!(error, DbError::SymbolGraphRowShape { .. }) {
14370            return Err(io::Error::other(format!(
14371                "metadata count corruption returned the wrong error: {error}"
14372            ))
14373            .into());
14374        }
14375        Ok(())
14376    }
14377
14378    #[test]
14379    fn bounded_symbol_batch_streams_exact_paths_and_uses_path_index() -> Result<(), Box<dyn Error>>
14380    {
14381        let mut store = AtlasStore::in_memory()?;
14382        store.replace_scan(&[
14383            test_file_node("src/a.rs", "hash-a"),
14384            test_file_node("src/b.rs", "hash-b"),
14385        ])?;
14386        store.replace_symbol_graph(&SymbolGraph {
14387            path: "src/a.rs".to_string(),
14388            language: Some("rust".to_string()),
14389            parser: ParserKind::TreeSitter,
14390            symbols: vec![
14391                batch_test_symbol("src/a.rs", "alpha", 1, 32),
14392                batch_test_symbol("src/a.rs", "beta", 2, 32),
14393            ],
14394            relations: Vec::new(),
14395        })?;
14396        store.replace_symbol_graph(&SymbolGraph {
14397            path: "src/b.rs".to_string(),
14398            language: Some("rust".to_string()),
14399            parser: ParserKind::TreeSitter,
14400            symbols: vec![batch_test_symbol("src/b.rs", "gamma", 1, 32)],
14401            relations: Vec::new(),
14402        })?;
14403
14404        let complete = store.load_symbols_for_paths_bounded(
14405            &[
14406                "src/b.rs".to_string(),
14407                "src/a.rs".to_string(),
14408                "src/a.rs".to_string(),
14409            ],
14410            SymbolBatchReadBudget::new(2, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14411            None,
14412        )?;
14413        require_eq(
14414            &complete
14415                .rows
14416                .iter()
14417                .map(|symbol| symbol.name.as_str())
14418                .collect::<Vec<_>>(),
14419            &vec!["alpha", "beta", "gamma"],
14420            "deterministic exact-path symbol order",
14421        )?;
14422        require_eq(&complete.truncated, &false, "complete symbol batch")?;
14423        require_eq(&complete.reached_limit, &None, "complete batch limit")?;
14424        require_eq(&complete.work.requested_paths, &2, "unique admitted paths")?;
14425        require_eq(&complete.work.returned_rows, &3, "complete returned rows")?;
14426
14427        let row_limited = store.load_symbols_for_paths_bounded(
14428            &["src/b.rs".to_string(), "src/a.rs".to_string()],
14429            SymbolBatchReadBudget::new(2, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14430            None,
14431        )?;
14432        require_eq(
14433            &row_limited.reached_limit,
14434            &Some(SymbolBatchReadLimit::Rows),
14435            "row-bound classification",
14436        )?;
14437        require_eq(&row_limited.rows.len(), &1, "row-bound retained rows")?;
14438
14439        let byte_limited = store.load_symbols_for_paths_bounded(
14440            &["src/a.rs".to_string()],
14441            SymbolBatchReadBudget::new(1, 3, 1)?,
14442            None,
14443        )?;
14444        require_eq(
14445            &byte_limited.reached_limit,
14446            &Some(SymbolBatchReadLimit::DecodedBytes),
14447            "decoded-byte-bound classification",
14448        )?;
14449        require_eq(
14450            &byte_limited.rows.len(),
14451            &0,
14452            "tiny byte budget retained no partial row",
14453        )?;
14454        require_eq(
14455            &byte_limited.work.decoded_bytes,
14456            &0,
14457            "tiny byte budget retained no row payload",
14458        )?;
14459
14460        let gamma_bytes = code_symbol_decoded_bytes(&complete.rows[2])?;
14461        let exact_byte_boundary = store.load_symbols_for_paths_bounded(
14462            &["src/b.rs".to_string()],
14463            SymbolBatchReadBudget::new(1, 1, gamma_bytes)?,
14464            None,
14465        )?;
14466        require_eq(
14467            &exact_byte_boundary.rows.len(),
14468            &1,
14469            "exact decoded-byte boundary retained the complete row",
14470        )?;
14471        require_eq(
14472            &exact_byte_boundary.work.decoded_bytes,
14473            &gamma_bytes,
14474            "exact decoded-byte boundary accounting",
14475        )?;
14476
14477        let oversized_signature =
14478            "x".repeat(usize::try_from(MAX_SYMBOL_BATCH_DECODED_BYTES)?.saturating_add(1));
14479        store.connection.execute(
14480            "UPDATE symbols SET signature = ?1, line_start = 'invalid' WHERE path = 'src/b.rs'",
14481            [&oversized_signature],
14482        )?;
14483        let oversized_row = store.load_symbols_for_paths_bounded(
14484            &["src/b.rs".to_string()],
14485            SymbolBatchReadBudget::new(1, 1, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14486            None,
14487        )?;
14488        require_eq(
14489            &oversized_row.reached_limit,
14490            &Some(SymbolBatchReadLimit::DecodedBytes),
14491            "oversized persisted row was rejected before hydration",
14492        )?;
14493        require_eq(
14494            &oversized_row.rows.len(),
14495            &0,
14496            "oversized persisted row retained no allocation",
14497        )?;
14498        store.connection.execute(
14499            "UPDATE symbols SET signature = 'fn gamma()', line_start = 1 WHERE path = 'src/b.rs'",
14500            [],
14501        )?;
14502
14503        let many_paths = (0..65)
14504            .rev()
14505            .map(|index| format!("generated/{index:04}.rs"))
14506            .collect::<Vec<_>>();
14507        for index in 0..65 {
14508            let path = format!("generated/{index:04}.rs");
14509            store.replace_symbol_graph(&SymbolGraph {
14510                path: path.clone(),
14511                language: Some("rust".to_string()),
14512                parser: ParserKind::TreeSitter,
14513                symbols: vec![batch_test_symbol(
14514                    &path,
14515                    &format!("generated_{index:04}"),
14516                    1,
14517                    0,
14518                )],
14519                relations: Vec::new(),
14520            })?;
14521        }
14522        let path_limited = store.load_symbols_for_paths_bounded(
14523            &many_paths,
14524            SymbolBatchReadBudget::new(
14525                MAX_SYMBOL_BATCH_PATHS,
14526                MAX_SYMBOL_BATCH_ROWS,
14527                MAX_SYMBOL_BATCH_DECODED_BYTES,
14528            )?,
14529            None,
14530        )?;
14531        require_eq(
14532            &path_limited.reached_limit,
14533            &Some(SymbolBatchReadLimit::Paths),
14534            "path-bound classification",
14535        )?;
14536        require_eq(
14537            &path_limited.work.requested_paths,
14538            &MAX_SYMBOL_BATCH_PATHS,
14539            "bounded path admission",
14540        )?;
14541        require_eq(
14542            &path_limited.rows.len(),
14543            &usize::try_from(MAX_SYMBOL_BATCH_PATHS)?,
14544            "all admitted paths loaded across binding chunks",
14545        )?;
14546        require(
14547            path_limited
14548                .rows
14549                .iter()
14550                .any(|symbol| symbol.name == "generated_0063"),
14551            "last deterministically admitted path was not loaded",
14552        )?;
14553        require(
14554            path_limited
14555                .rows
14556                .iter()
14557                .all(|symbol| symbol.name != "generated_0064"),
14558            "path outside the deterministic admission set was loaded",
14559        )?;
14560
14561        let expired = IndexWorkControl::with_deadline(
14562            projectatlas_core::IndexCancellation::new(),
14563            Instant::now(),
14564        );
14565        let expired_result = store.load_symbols_for_paths_bounded(
14566            &["src/a.rs".to_string()],
14567            SymbolBatchReadBudget::new(1, 3, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
14568            Some(&expired),
14569        );
14570        require(
14571            matches!(
14572                expired_result,
14573                Err(DbError::IndexWork(IndexWorkFailure::DeadlineExceeded {
14574                    stage: IndexWorkStage::RepositoryTraversal
14575                }))
14576            ),
14577            "expired symbol control returned a partial batch instead of a typed error",
14578        )?;
14579
14580        let mut plan_statement = store.connection.prepare(
14581            "EXPLAIN QUERY PLAN
14582             SELECT
14583                 path,
14584                 language,
14585                 name,
14586                 kind,
14587                 signature,
14588                 line_start,
14589                 line_end,
14590                 parent,
14591                 parser,
14592                 detail,
14593                 exported,
14594                 documentation,
14595                 source_byte_start,
14596                 source_byte_end,
14597                 source_column_start,
14598                 source_column_end,
14599                 length(CAST(path AS BLOB))
14600                     + COALESCE(length(CAST(language AS BLOB)), 0)
14601                     + length(CAST(name AS BLOB))
14602                     + length(CAST(signature AS BLOB))
14603                     + COALESCE(length(CAST(parent AS BLOB)), 0)
14604                     + COALESCE(length(CAST(detail AS BLOB)), 0)
14605                     + COALESCE(length(CAST(documentation AS BLOB)), 0)
14606             FROM symbols
14607             WHERE path IN (?1, ?2)
14608             ORDER BY path, line_start, name
14609             LIMIT ?3",
14610        )?;
14611        let plan = plan_statement
14612            .query_map(params!["src/a.rs", "src/b.rs", 4], |row| {
14613                row.get::<_, String>(3)
14614            })?
14615            .collect::<Result<Vec<_>, _>>()?
14616            .join("\n");
14617        require(
14618            plan.contains("idx_symbols_path"),
14619            &format!("bounded symbol batch missed idx_symbols_path: {plan}"),
14620        )?;
14621        Ok(())
14622    }
14623
14624    #[test]
14625    fn full_scan_removal_clears_source_parse_metadata() -> Result<(), Box<dyn Error>> {
14626        let mut store = AtlasStore::in_memory()?;
14627        store.replace_scan(&[test_file_node("src/a.rs", "hash-a")])?;
14628        store.replace_symbol_graph(&SymbolGraph {
14629            path: "src/a.rs".to_string(),
14630            language: Some("rust".to_string()),
14631            parser: ParserKind::TreeSitter,
14632            symbols: Vec::new(),
14633            relations: Vec::new(),
14634        })?;
14635        require_eq(
14636            &store.load_source_parse_metadata("src/a.rs")?.is_some(),
14637            &true,
14638            "metadata exists before removal",
14639        )?;
14640
14641        store.replace_scan(&[test_file_node("src/b.rs", "hash-b")])?;
14642        require_eq(
14643            &store.load_source_parse_metadata("src/a.rs")?,
14644            &None,
14645            "metadata cleared after full scan removal",
14646        )?;
14647        Ok(())
14648    }
14649
14650    #[test]
14651    fn call_relations_are_limited_per_target() -> Result<(), Box<dyn Error>> {
14652        let mut store = AtlasStore::in_memory()?;
14653        let mut relations = Vec::new();
14654        for index in 0..5 {
14655            relations.push(SymbolRelation {
14656                path: format!("src/a{index}.rs"),
14657                source_name: format!("alpha_caller_{index}"),
14658                target_name: "alpha".to_string(),
14659                kind: RelationKind::Calls,
14660                line: index + 1,
14661                context: "alpha();".to_string(),
14662                parser: ParserKind::TreeSitter,
14663            });
14664        }
14665        relations.push(SymbolRelation {
14666            path: "src/z.rs".to_string(),
14667            source_name: "beta_caller".to_string(),
14668            target_name: "beta".to_string(),
14669            kind: RelationKind::Calls,
14670            line: 99,
14671            context: "beta();".to_string(),
14672            parser: ParserKind::TreeSitter,
14673        });
14674        store.replace_symbol_graph(&SymbolGraph {
14675            path: "src/main.rs".to_string(),
14676            language: Some("rust".to_string()),
14677            parser: ParserKind::TreeSitter,
14678            symbols: Vec::new(),
14679            relations,
14680        })?;
14681
14682        let loaded =
14683            store.load_call_relations_to_targets(&["alpha".to_string(), "beta".to_string()], 2)?;
14684        let alpha_count = loaded
14685            .iter()
14686            .filter(|relation| relation.target_name == "alpha")
14687            .count();
14688        let beta_count = loaded
14689            .iter()
14690            .filter(|relation| relation.target_name == "beta")
14691            .count();
14692        require_eq(&alpha_count, &2, "alpha per-target limit")?;
14693        require_eq(&beta_count, &1, "beta preserved despite alpha skew")?;
14694        let mut statement = store.connection.prepare(
14695            "EXPLAIN QUERY PLAN
14696             SELECT path, source_name, target_name, kind, line, context, parser
14697             FROM (
14698                 SELECT path, source_name, target_name, kind, line, context, parser,
14699                     ROW_NUMBER() OVER (
14700                         PARTITION BY target_name
14701                         ORDER BY path, line, source_name, target_name
14702                     ) AS target_row
14703                 FROM symbol_relations INDEXED BY idx_symbol_relations_target
14704                 WHERE kind = 'calls' AND target_name IN ('alpha', 'beta')
14705             )
14706             WHERE target_row <= 2
14707             ORDER BY path, line, source_name, target_name",
14708        )?;
14709        let plan = statement
14710            .query_map([], |row| row.get::<_, String>(3))?
14711            .collect::<Result<Vec<_>, _>>()?
14712            .join("\n");
14713        require(
14714            plan.contains(
14715                "SEARCH symbol_relations USING INDEX idx_symbol_relations_target (target_name=?)",
14716            ),
14717            &format!("call target lookup missed exact-target index: {plan}"),
14718        )?;
14719        Ok(())
14720    }
14721
14722    #[test]
14723    fn stores_health_resolution_ids() -> Result<(), Box<dyn Error>> {
14724        let mut store = AtlasStore::in_memory()?;
14725        store.replace_scan(&[
14726            test_file_node("src/a.rs", "hash-a"),
14727            test_file_node("src/b.rs", "hash-b"),
14728        ])?;
14729        store.set_purpose("src/a.rs", "Shared purpose", PurposeSource::Agent)?;
14730        store.set_purpose("src/b.rs", "Shared purpose", PurposeSource::Agent)?;
14731        let duplicate = store
14732            .unresolved_health_findings(&[])?
14733            .into_iter()
14734            .find(|finding| finding.category == "duplicate-purpose")
14735            .ok_or_else(|| io::Error::other("duplicate-purpose finding missing"))?;
14736        let duplicate_id = duplicate.id.clone();
14737        store.resolve_health_finding(&HealthResolution {
14738            finding_id: duplicate_id.clone(),
14739            category: duplicate.category,
14740            path: duplicate.path,
14741            related_path: duplicate.related_path,
14742            rationale: "Paths intentionally mirror agent skill variants.".to_string(),
14743        })?;
14744        let ids = store.resolved_health_ids()?;
14745        require_eq(&ids, &vec![duplicate_id], "resolved ids")?;
14746        Ok(())
14747    }
14748
14749    #[test]
14750    fn health_resolution_accepts_all_scope_agent_review_findings() -> Result<(), Box<dyn Error>> {
14751        let mut store = AtlasStore::in_memory()?;
14752        let mut asset_file = test_file_node("assets/logo.svg", "hash-logo");
14753        asset_file.extension = Some(".svg".to_string());
14754        asset_file.language = None;
14755        store.replace_scan(&[test_folder_node("assets"), asset_file])?;
14756        store.set_purpose(
14757            "assets/logo.svg",
14758            "Imported SVG brand asset purpose",
14759            PurposeSource::Imported,
14760        )?;
14761
14762        let page = store.unresolved_health_findings_page(
14763            &[],
14764            &HealthQuery {
14765                start_index: 0,
14766                limit: 20,
14767                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
14768                severity: Some(Severity::Warning),
14769                path_prefix: Some(".".to_string()),
14770                summary_only: false,
14771                scope: HealthScope::all(),
14772            },
14773        )?;
14774        let finding = page
14775            .findings
14776            .iter()
14777            .find(|finding| finding.path == "assets/logo.svg")
14778            .ok_or_else(|| io::Error::other("asset review finding missing"))?;
14779        store.resolve_health_finding(&HealthResolution {
14780            finding_id: finding.id.clone(),
14781            category: finding.category.clone(),
14782            path: finding.path.clone(),
14783            related_path: finding.related_path.clone(),
14784            rationale: "Asset purpose imported from legacy metadata and intentionally accepted."
14785                .to_string(),
14786        })?;
14787
14788        let remaining = store.unresolved_health_findings_page(
14789            &store.resolved_health_ids()?,
14790            &HealthQuery {
14791                start_index: 0,
14792                limit: 20,
14793                category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
14794                severity: Some(Severity::Warning),
14795                path_prefix: Some(".".to_string()),
14796                summary_only: false,
14797                scope: HealthScope::all(),
14798            },
14799        )?;
14800        require_eq(
14801            &health_paths(&remaining).contains(&"assets/logo.svg"),
14802            &false,
14803            "resolved all-scope asset review finding",
14804        )?;
14805        Ok(())
14806    }
14807
14808    #[test]
14809    fn purpose_set_reports_unindexed_path_without_sqlite_leak() -> Result<(), Box<dyn Error>> {
14810        let mut store = AtlasStore::in_memory()?;
14811        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
14812        let error = match store.set_purpose("no/such/file.rs", "Missing file", PurposeSource::Agent)
14813        {
14814            Ok(()) => return Err(io::Error::other("missing path should fail").into()),
14815            Err(error) => error,
14816        };
14817
14818        require_eq(
14819            &error.to_string().contains("no/such/file.rs"),
14820            &true,
14821            "path named in error",
14822        )?;
14823        require_eq(
14824            &error.to_string().contains("sqlite error"),
14825            &false,
14826            "raw sqlite error hidden",
14827        )?;
14828        store.replace_scan(&[])?;
14829        let error = match store.set_purpose("src/main.rs", "Removed file", PurposeSource::Agent) {
14830            Ok(()) => return Err(io::Error::other("stale indexed path should fail").into()),
14831            Err(error) => error,
14832        };
14833        require_eq(
14834            &error.to_string().contains("src/main.rs"),
14835            &true,
14836            "stale path named in error",
14837        )?;
14838        Ok(())
14839    }
14840
14841    #[test]
14842    fn health_resolution_requires_active_finding_tuple() -> Result<(), Box<dyn Error>> {
14843        let mut store = AtlasStore::in_memory()?;
14844        store.replace_scan(&[test_file_node("src/main.rs", "hash")])?;
14845        let error = match store.resolve_health_finding(&HealthResolution {
14846            finding_id: "missing-id".to_string(),
14847            category: "duplicate-purpose".to_string(),
14848            path: "no/such/file.rs".to_string(),
14849            related_path: None,
14850            rationale: "typo".to_string(),
14851        }) {
14852            Ok(()) => {
14853                return Err(io::Error::other("nonexistent health finding should fail").into());
14854            }
14855            Err(error) => error,
14856        };
14857
14858        require_eq(
14859            &error.to_string().contains("not active"),
14860            &true,
14861            "inactive finding rejected",
14862        )?;
14863        Ok(())
14864    }
14865
14866    /// Write released schema-8 source, authored, telemetry, and publication state.
14867    fn write_released_schema_eight_compatibility_fixture(
14868        db_path: &Path,
14869        root: &Path,
14870    ) -> Result<(), Box<dyn Error>> {
14871        write_schema_eight_compatibility_fixture(
14872            db_path,
14873            root,
14874            schema::create_released_schema_eight,
14875        )
14876    }
14877
14878    /// Write evolved released schema-8 source, authored, telemetry, and publication state.
14879    #[cfg(windows)]
14880    fn write_evolved_released_schema_eight_compatibility_fixture(
14881        db_path: &Path,
14882        root: &Path,
14883    ) -> Result<(), Box<dyn Error>> {
14884        write_schema_eight_compatibility_fixture(
14885            db_path,
14886            root,
14887            schema::create_evolved_released_schema_eight,
14888        )
14889    }
14890
14891    /// Write one captured schema-8 layout with representative durable state.
14892    fn write_schema_eight_compatibility_fixture(
14893        db_path: &Path,
14894        root: &Path,
14895        create_schema: fn(&Connection) -> DbResult<()>,
14896    ) -> Result<(), Box<dyn Error>> {
14897        let connection = Connection::open(db_path)?;
14898        create_schema(&connection)?;
14899        schema::configure_writable(&connection)?;
14900        connection.execute_batch("BEGIN IMMEDIATE")?;
14901        let write_result = (|| -> DbResult<()> {
14902            set_metadata(
14903                &connection,
14904                PROJECT_ROOT_KEY,
14905                &normalize_native_path_display(root),
14906            )?;
14907            set_metadata(&connection, "custom_setting", "preserved")?;
14908            set_metadata(&connection, INDEX_PUBLICATION_STATE_KEY, "complete")?;
14909            set_metadata(
14910                &connection,
14911                INDEX_PUBLICATION_FINGERPRINT_KEY,
14912                "untrusted-contract",
14913            )?;
14914            set_metadata(&connection, INDEX_PUBLICATION_GENERATION_KEY, "7")?;
14915            connection.execute_batch(
14916                "
14917                INSERT INTO nodes(
14918                    id, path, kind, parent_path, extension, language,
14919                    size_bytes, mtime_ns, content_hash
14920                )
14921                VALUES(1, 'src/lib.rs', 'file', 'src', '.rs', 'rust', 12, 10, 'hash-legacy');
14922
14923                INSERT INTO purposes(node_id, purpose, source, status, updated_by)
14924                VALUES(1, 'Schema compatibility source', 'agent', 'approved', 'agent');
14925
14926                INSERT INTO summaries(node_id, summary_level, subject, summary)
14927                VALUES(1, 'node', '', 'released schema source');
14928
14929                INSERT INTO file_texts(path, content_hash, byte_count, line_count, content)
14930                VALUES('src/lib.rs', 'hash-legacy', 6, 1, 'legacy');
14931                ",
14932            )?;
14933            connection.execute(
14934                "
14935                INSERT INTO health_resolutions(
14936                    finding_id, category, path, rationale
14937                )
14938                VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
14939                ",
14940                [CATEGORY_DUPLICATE_PURPOSE],
14941            )?;
14942            record_released_schema_eight_usage_event(
14943                &connection,
14944                &usage_from_estimates(
14945                    "schema-session",
14946                    "summary",
14947                    Some("src/lib.rs".to_string()),
14948                    None,
14949                    100,
14950                    20,
14951                ),
14952            )
14953        })();
14954        match write_result {
14955            Ok(()) => connection.execute_batch("COMMIT")?,
14956            Err(error) => {
14957                if let Err(rollback) = connection.execute_batch("ROLLBACK") {
14958                    return Err(DbError::TransactionRollback {
14959                        operation: Box::new(error),
14960                        rollback,
14961                    }
14962                    .into());
14963                }
14964                return Err(error.into());
14965            }
14966        }
14967        Ok(())
14968    }
14969
14970    /// Write representative source, authored, telemetry, and publication state at one schema.
14971    fn write_schema_compatibility_fixture(
14972        db_path: &Path,
14973        root: &Path,
14974        schema_version: i64,
14975        label: &str,
14976    ) -> Result<(), Box<dyn Error>> {
14977        let mut store = AtlasStore::open(db_path)?;
14978        store.set_project_root(root)?;
14979        populate_schema_compatibility_fixture(&mut store, label)?;
14980        set_metadata(
14981            &store.connection,
14982            SCHEMA_VERSION_KEY,
14983            &schema_version.to_string(),
14984        )?;
14985        store
14986            .connection
14987            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
14988        Ok(())
14989    }
14990
14991    /// Populate representative source, authored, telemetry, and publication state.
14992    pub(crate) fn populate_schema_compatibility_fixture(
14993        store: &mut AtlasStore,
14994        label: &str,
14995    ) -> Result<(), Box<dyn Error>> {
14996        {
14997            let mut publication = store.begin_index_publication("untrusted-contract")?;
14998            write_test_projection(&mut publication, label)?;
14999            publication.complete()?;
15000        }
15001        store.set_purpose(
15002            "src/lib.rs",
15003            "Schema compatibility source",
15004            PurposeSource::Agent,
15005        )?;
15006        store.connection.execute(
15007            "
15008            INSERT INTO health_resolutions(
15009                finding_id,
15010                category,
15011                path,
15012                rationale
15013            )
15014            VALUES('schema-review', ?1, 'src/lib.rs', 'Reviewed schema fixture')
15015            ",
15016            [CATEGORY_DUPLICATE_PURPOSE],
15017        )?;
15018        store.record_usage(&usage_from_estimates(
15019            "schema-session",
15020            "summary",
15021            Some("src/lib.rs".to_string()),
15022            None,
15023            100,
15024            20,
15025        ))?;
15026        set_metadata(&store.connection, "custom_setting", "preserved")?;
15027        Ok(())
15028    }
15029
15030    /// Build a representative Rust file node for store tests.
15031    fn test_file_node(path: &str, hash: &str) -> Node {
15032        Node {
15033            path: path.to_string(),
15034            kind: NodeKind::File,
15035            parent_path: normalized_parent(path),
15036            extension: Some(".rs".to_string()),
15037            language: Some("rust".to_string()),
15038            size_bytes: Some(12),
15039            mtime_ns: Some(10),
15040            content_hash: Some(hash.to_string()),
15041        }
15042    }
15043
15044    /// Build one persisted symbol with configurable retained payload.
15045    fn batch_test_symbol(
15046        path: &str,
15047        name: &str,
15048        line_start: usize,
15049        documentation_bytes: usize,
15050    ) -> CodeSymbol {
15051        CodeSymbol {
15052            path: path.to_string(),
15053            language: Some("rust".to_string()),
15054            name: name.to_string(),
15055            kind: SymbolKind::Function,
15056            signature: format!("fn {name}()"),
15057            exported: false,
15058            documentation: Some("d".repeat(documentation_bytes)),
15059            line_start,
15060            line_end: line_start.saturating_add(1),
15061            source_selector: None,
15062            parent: None,
15063            parser: ParserKind::TreeSitter,
15064            detail: Some("function_item".to_string()),
15065        }
15066    }
15067
15068    /// Assert the exact selector on the heading row and the legacy row remains absent.
15069    fn require_symbol_source_selectors(
15070        symbols: &[CodeSymbol],
15071        expected: Option<SymbolSourceSelector>,
15072        context: &str,
15073    ) -> Result<(), Box<dyn Error>> {
15074        let heading = symbols
15075            .iter()
15076            .find(|symbol| symbol.name == "Overview")
15077            .ok_or_else(|| io::Error::other(format!("{context} omitted the heading symbol")))?;
15078        require_eq(&heading.source_selector, &expected, context)?;
15079        if let Some(compatibility) = symbols.iter().find(|symbol| symbol.name == "Compatibility") {
15080            require_eq(
15081                &compatibility.source_selector,
15082                &None,
15083                &format!("{context} changed the selector-free symbol"),
15084            )?;
15085        }
15086        Ok(())
15087    }
15088
15089    /// Exercise every DB-owned symbol row decoder against one persisted selector.
15090    fn verify_symbol_source_selector_read_paths(
15091        store: &AtlasStore,
15092        path: &str,
15093        expected: SymbolSourceSelector,
15094    ) -> Result<(), Box<dyn Error>> {
15095        require_symbol_source_selectors(
15096            &store.load_symbols(Some(path), None, 10)?,
15097            Some(expected),
15098            "symbol list",
15099        )?;
15100        require_symbol_source_selectors(
15101            &store.load_symbols_by_kinds(path, &[SymbolKind::Heading], 10)?,
15102            Some(expected),
15103            "kind-filtered symbols",
15104        )?;
15105        require_symbol_source_selectors(
15106            &store.load_symbols_by_names(&["Overview".to_string()])?,
15107            Some(expected),
15108            "name-filtered symbols",
15109        )?;
15110        let named = store
15111            .load_symbol_by_name(path, "Overview")?
15112            .ok_or_else(|| io::Error::other("exact-name symbol lookup omitted the heading"))?;
15113        require_eq(
15114            &named.source_selector,
15115            &Some(expected),
15116            "single exact-name symbol",
15117        )?;
15118        require_symbol_source_selectors(
15119            &store.load_symbols_by_exact_file_and_name(path, "Overview")?,
15120            Some(expected),
15121            "exact-file-and-name symbols",
15122        )?;
15123        require_symbol_source_selectors(
15124            &store
15125                .load_symbols_for_paths_bounded(
15126                    &[path.to_string()],
15127                    SymbolBatchReadBudget::new(1, 10, MAX_SYMBOL_BATCH_DECODED_BYTES)?,
15128                    None,
15129                )?
15130                .rows,
15131            Some(expected),
15132            "bounded exact-path symbols",
15133        )?;
15134        let classified =
15135            store.load_classified_symbols(Some(path), None, ContentSelection::Documentation, 10)?;
15136        require(
15137            classified
15138                .iter()
15139                .all(|row| row.classification == ContentClassification::Documentation),
15140            "classified symbol read changed the owning file classification",
15141        )?;
15142        require_symbol_source_selectors(
15143            &classified
15144                .into_iter()
15145                .map(|row| row.symbol)
15146                .collect::<Vec<_>>(),
15147            Some(expected),
15148            "classified symbols",
15149        )?;
15150        let graphs = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
15151        require_eq(&graphs.len(), &1, "reconstructed graph count")?;
15152        require_symbol_source_selectors(
15153            &graphs[0].symbols,
15154            Some(expected),
15155            "reconstructed symbol graph",
15156        )?;
15157        Ok(())
15158    }
15159
15160    /// Copy one selected queue candidate into the public conditional-write contract.
15161    fn conditional_purpose_request(
15162        task: &str,
15163        candidate: &PurposeCurationCandidate,
15164        purpose: &str,
15165    ) -> PurposeConditionalApplyRequest {
15166        PurposeConditionalApplyRequest {
15167            task: task.to_string(),
15168            path: candidate.node.node.path.clone(),
15169            work_key: candidate.work_key.clone(),
15170            state_token: candidate.state_token.clone(),
15171            purpose: purpose.to_string(),
15172        }
15173    }
15174
15175    /// Replace every source-derived projection used by publication tests.
15176    fn write_test_projection(store: &mut AtlasStore, label: &str) -> DbResult<()> {
15177        let path = "src/lib.rs";
15178        let hash = format!("hash-{label}");
15179        store.replace_scan(&[test_file_node(path, &hash)])?;
15180        store.replace_file_texts_for_paths(
15181            &[path.to_string()],
15182            &[IndexedFileText {
15183                path: path.to_string(),
15184                content_hash: Some(hash),
15185                byte_count: label.len(),
15186                line_count: 1,
15187                content: label.to_string(),
15188            }],
15189        )?;
15190        store.replace_symbol_graph(&SymbolGraph {
15191            path: path.to_string(),
15192            language: Some("rust".to_string()),
15193            parser: ParserKind::TreeSitter,
15194            symbols: vec![CodeSymbol {
15195                path: path.to_string(),
15196                language: Some("rust".to_string()),
15197                name: format!("{label}_symbol"),
15198                kind: SymbolKind::Function,
15199                signature: format!("fn {label}_symbol()"),
15200                exported: true,
15201                documentation: None,
15202                line_start: 1,
15203                line_end: 1,
15204                source_selector: None,
15205                parent: None,
15206                parser: ParserKind::TreeSitter,
15207                detail: Some("function_item".to_string()),
15208            }],
15209            relations: vec![SymbolRelation {
15210                path: path.to_string(),
15211                source_name: format!("{label}_symbol"),
15212                target_name: format!("{label}_target"),
15213                kind: RelationKind::Calls,
15214                line: 1,
15215                context: format!("{label}_target();"),
15216                parser: ParserKind::TreeSitter,
15217            }],
15218        })?;
15219        store.set_node_summary(path, &format!("{label} summary"))?;
15220        Ok(())
15221    }
15222
15223    /// Assert that one snapshot exposes a coherent projection generation.
15224    fn require_test_projection(
15225        store: &AtlasStore,
15226        generation: u64,
15227        label: &str,
15228    ) -> Result<(), Box<dyn Error>> {
15229        let path = "src/lib.rs";
15230        let publication = store
15231            .index_publication()?
15232            .ok_or_else(|| io::Error::other("publication missing"))?;
15233        require_eq(
15234            &publication.generation,
15235            &IndexGeneration::new(generation),
15236            "publication generation",
15237        )?;
15238        let node = store
15239            .load_node_by_path(path)?
15240            .ok_or_else(|| io::Error::other("projection node missing"))?;
15241        require_eq(
15242            &node.node.content_hash,
15243            &Some(format!("hash-{label}")),
15244            "projection node hash",
15245        )?;
15246        require_eq(
15247            &node.summary,
15248            &Some(format!("{label} summary")),
15249            "projection node summary",
15250        )?;
15251        let text = store
15252            .load_file_text(path)?
15253            .ok_or_else(|| io::Error::other("projection text missing"))?;
15254        require_eq(&text.content, &label.to_string(), "projection text")?;
15255        let symbols = store.load_symbols(Some(path), None, 10)?;
15256        require_eq(
15257            &symbols.first().map(|symbol| symbol.name.as_str()),
15258            &Some(format!("{label}_symbol")).as_deref(),
15259            "projection symbol",
15260        )?;
15261        let relations = store.load_symbol_relations(Some(path), None, 10)?;
15262        require_eq(
15263            &relations
15264                .first()
15265                .map(|relation| relation.target_name.as_str()),
15266            &Some(format!("{label}_target")).as_deref(),
15267            "projection relation",
15268        )?;
15269        let metadata = store
15270            .load_source_parse_metadata(path)?
15271            .ok_or_else(|| io::Error::other("projection parse metadata missing"))?;
15272        require_eq(&metadata.symbol_count, &1, "projection symbol metadata")?;
15273        require_eq(&metadata.relation_count, &1, "projection relation metadata")?;
15274        Ok(())
15275    }
15276
15277    /// Assert the connection settings required for every production writer.
15278    fn require_writable_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15279        let foreign_keys =
15280            connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
15281        require_eq(&foreign_keys, &1, "writable foreign-key enforcement")?;
15282        require_wal_profile(connection)?;
15283        let synchronous =
15284            connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
15285        require_eq(&synchronous, &2, "writable FULL synchronous mode")?;
15286        require_busy_timeout(connection)
15287    }
15288
15289    /// Assert the connection settings required for every production reader.
15290    fn require_read_connection_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15291        let query_only =
15292            connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
15293        require_eq(&query_only, &1, "read query-only mode")?;
15294        require_wal_profile(connection)?;
15295        require_busy_timeout(connection)
15296    }
15297
15298    /// Assert that a connection observes the selected durable journal mode.
15299    fn require_wal_profile(connection: &Connection) -> Result<(), Box<dyn Error>> {
15300        let journal_mode =
15301            connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
15302        require_eq(
15303            &journal_mode.to_ascii_lowercase(),
15304            &"wal".to_string(),
15305            "WAL journal mode",
15306        )
15307    }
15308
15309    /// Assert the bounded contention wait shared by ordinary connections.
15310    fn require_busy_timeout(connection: &Connection) -> Result<(), Box<dyn Error>> {
15311        let busy_timeout =
15312            connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))?;
15313        require_eq(
15314            &u128::try_from(busy_timeout)?,
15315            &SQLITE_BUSY_TIMEOUT.as_millis(),
15316            "bounded connection busy timeout",
15317        )
15318    }
15319
15320    /// Build a representative folder node for store tests.
15321    fn test_folder_node(path: &str) -> Node {
15322        Node {
15323            path: path.to_string(),
15324            kind: NodeKind::Folder,
15325            parent_path: normalized_parent(path),
15326            extension: None,
15327            language: None,
15328            size_bytes: None,
15329            mtime_ns: Some(10),
15330            content_hash: None,
15331        }
15332    }
15333
15334    /// Return the default low-cost purpose review query used by agent linting.
15335    fn low_query() -> HealthQuery {
15336        HealthQuery {
15337            start_index: 0,
15338            limit: 20,
15339            category: Some(CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string()),
15340            severity: Some(Severity::Warning),
15341            path_prefix: Some(".".to_string()),
15342            summary_only: false,
15343            scope: HealthScope::purpose_default(),
15344        }
15345    }
15346
15347    /// Collect health finding paths in returned order.
15348    fn health_paths(page: &HealthFindingsPage) -> Vec<&str> {
15349        page.findings
15350            .iter()
15351            .map(|finding| finding.path.as_str())
15352            .collect()
15353    }
15354
15355    /// Require a test condition without panicking.
15356    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
15357        if condition {
15358            Ok(())
15359        } else {
15360            Err(io::Error::other(message).into())
15361        }
15362    }
15363
15364    /// Require two test values to be equal without panicking.
15365    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15366    where
15367        T: Debug + PartialEq,
15368    {
15369        if actual == expected {
15370            Ok(())
15371        } else {
15372            Err(io::Error::other(format!(
15373                "{label} mismatch: expected {expected:?}, got {actual:?}"
15374            ))
15375            .into())
15376        }
15377    }
15378}